diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeca1a33..94b5ad78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '24' # node:sqlite (used by integration tests) is available unflagged - # @offgrid/sync is a file: dependency on the SIBLING shared monorepo ("file:../shared/packages/sync"). - # It arrived with the sync work and CI never provisioned it, so every job that installs would fail on the - # same missing module. actions/checkout refuses a path outside the workspace, so it lands inside and is - # moved up one level - exactly where the file: specifier points. + # Shared packages are file: dependencies on the SIBLING shared monorepo. CI must provision it before + # installing Desktop. actions/checkout refuses a path outside the workspace, so it lands inside and is + # moved up one level - exactly where the file: specifiers point. # # Matching branch first, main as the fallback, the same shape as the pro checkout above: a PR that changes # the app and the shared package together must be tested against the package it expects. @@ -93,7 +92,7 @@ jobs: - name: Put shared beside this checkout run: | if [ ! -d _shared ]; then - echo "::error::off-grid-ai/shared was not checked out - @offgrid/sync cannot resolve. Check CI_CROSS_REPO_TOKEN." + echo "::error::off-grid-ai/shared was not checked out - shared packages cannot resolve. Check CI_CROSS_REPO_TOKEN." exit 1 fi rm -rf ../shared @@ -107,6 +106,7 @@ jobs: # later gates run against dependencies nobody committed. A drifted lock should stop the build. npm --prefix ../shared ci npm --prefix ../shared/packages/sync run build + npm --prefix ../shared/packages/models run build - run: npm ci # Hard gates: types + the full test suite. - name: Typecheck (core) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0f4f188..d361fb64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,7 @@ jobs: - name: Build llama-server (pinned deployment target) run: | command -v cmake >/dev/null || brew install cmake - MACOS_DEPLOYMENT_TARGET=13.0 LLAMA_REF=b9838 bash scripts/build-llama.sh + MACOS_DEPLOYMENT_TARGET=13.0 bash scripts/build-llama.sh otool -l resources/bin/llama/llama-server | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print "[ci] bundled engine minos="$2; exit}' # Rebuild the one-shot Whisper CLI instead of trusting the committed LFS # payload. The production script disables OpenMP/BLAS auto-discovery and @@ -189,7 +189,7 @@ jobs: with: node-version: '20' cache: 'npm' - # `@offgrid/sync` is a file: dependency on the shared monorepo, so it must sit BESIDE this + # Shared packages are file: dependencies on the shared monorepo, so it must sit BESIDE this # checkout before `npm ci` runs - an install cannot resolve a path that does not exist, and the # typecheck inside `npm run build` then fails on every import of it. `ci.yml` has done this since # the sync work landed; this workflow did not, which is why no release has built since 2026-07-30. @@ -218,7 +218,7 @@ jobs: - name: Put shared beside this checkout run: | if [ ! -d _shared ]; then - echo "::error::off-grid-ai/shared was not checked out - @offgrid/sync cannot resolve. Check CI_CROSS_REPO_TOKEN." + echo "::error::off-grid-ai/shared was not checked out - shared packages cannot resolve. Check CI_CROSS_REPO_TOKEN." exit 1 fi rm -rf ../shared @@ -228,6 +228,7 @@ jobs: # drifted lock stops the release instead of resolving a graph nobody committed. npm --prefix ../shared ci npm --prefix ../shared/packages/sync run build + npm --prefix ../shared/packages/models run build - name: Install dependencies run: npm ci # Stamp the resolved version into package.json so electron-builder picks the @@ -426,7 +427,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - # `@offgrid/sync` is a file: dependency on the shared monorepo, so it must sit BESIDE this + # Shared packages are file: dependencies on the shared monorepo, so it must sit BESIDE this # checkout before `npm ci` runs - an install cannot resolve a path that does not exist, and the # typecheck inside `npm run build` then fails on every import of it. `ci.yml` has done this since # the sync work landed; this workflow did not, which is why no release has built since 2026-07-30. @@ -458,7 +459,7 @@ jobs: shell: bash run: | if [ ! -d _shared ]; then - echo "::error::off-grid-ai/shared was not checked out - @offgrid/sync cannot resolve. Check CI_CROSS_REPO_TOKEN." + echo "::error::off-grid-ai/shared was not checked out - shared packages cannot resolve. Check CI_CROSS_REPO_TOKEN." exit 1 fi rm -rf ../shared @@ -468,6 +469,7 @@ jobs: # drifted lock stops the release instead of resolving a graph nobody committed. npm --prefix ../shared ci npm --prefix ../shared/packages/sync run build + npm --prefix ../shared/packages/models run build - name: Install dependencies run: npm ci - name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg) diff --git a/.gitignore b/.gitignore index b01caaae..6eab2653 100644 --- a/.gitignore +++ b/.gitignore @@ -31,14 +31,17 @@ resources/bin/mflux/ resources/bin/parakeet/ # Pro MultipeerConnectivity helper - built from the private Pro repo and staged for packaging. resources/bin/proximity-helper +resources/bin/keychain-bootstrap.node # Gateway probe scratch output .gateway-probe/ # TS build cache *.tsbuildinfo -# Private pro features — separate repo, added as a git submodule once the remote -# exists. Ignored for now so the open repo never tracks pro source. -/pro/ +# Private pro features — a separate repo, tracked as a git submodule now that the remote exists. +# The submodule records only a URL and a commit, so this open repo still never carries pro source - +# while pinning exactly which pro revision belongs with this branch. NOT ignored any more: while it +# was, six licensing tests under pro/ stayed tracked here AND in desktop-pro, so an edit showed up in +# both repos and the two could diverge with no signal. # Vendored @offgrid packages ship their built dist !packages/*/dist/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ef7afb44 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pro"] + path = pro + url = https://github.com/off-grid-ai/desktop-pro.git diff --git a/AGENTS.md b/AGENTS.md index 182aa424..148553d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,11 +152,13 @@ The `pro/` directory is a **git submodule** pointing at the private `desktop-pro Design to abstractions, not concrete types. When implementations are interchangeable (model backends, TTS/STT engines, image/diffusion runtimes, connectors), the rest of the app depends on one service/interface — never branch on a concrete type in UI/stores (`if (engine === 'kokoro')`, `instanceof X`). Push the decision behind the abstraction; adding an implementation should need zero changes to callers. Normalize capability gaps inside the service, not the UI. -**Before every code edit, stop and ask three questions — out loud, in the response:** +**Before every code edit, stop and ask these five questions — out loud, in the response:** -1. **Is there enough here to abstract?** Two or more concrete cases handled by the same caller (text vs vision vs image models, Slack vs Mail surfaces, kokoro vs piper TTS) means there's a seam. One case, used once, is not — don't abstract speculatively (YAGNI). -2. **Can we apply SOLID here?** Mainly: does one thing own one responsibility (SRP), and do callers depend on an interface rather than the concretes (DSP)? A `kind === 'x'` / `instanceof` / per-type `switch` in a caller — _especially in the renderer_ — is the tell that the decision belongs behind a service. -3. **Are we actually using it?** A mapping or rule must be defined ONCE and reused. If the same kind→modality map, the same routing `if`, or the same capability check appears in two layers (e.g. main process AND renderer), that's duplication, not abstraction — collapse it to a single source of truth and have both sides call it. +1. **SSOT — who owns this fact?** Name the one authoritative owner for the state, rule, identity, or mapping being changed. Look for any second layer answering the same question; two authorities are a bug even when they currently agree. +2. **Abstraction / YAGNI — is there enough here to abstract?** Two or more concrete cases handled by the same caller (text vs vision vs image models, Slack vs Mail surfaces, kokoro vs piper TTS) means there's a seam. One case, used once, is not — don't abstract speculatively. +3. **SRP — does each owner have one reason to change?** Keep policy, orchestration, persistence, transport, and presentation in their owning layers. Do not make a writer schedule work, a renderer derive business truth, or an adapter own product policy. +4. **SOLID — do callers depend on the seam?** Apply dependency inversion and substitutability: callers depend on an interface, implementations remain interchangeable, interfaces stay focused, and adding an implementation extends the system without type switches in callers. A `kind === 'x'` / `instanceof` / per-type `switch` — _especially in the renderer_ — means the decision belongs behind the abstraction. +5. **DRY — is the knowledge defined once and reused?** A mapping, ordering, capability rule, or routing decision must have one definition. If the same knowledge appears in two layers, collapse it to the SSOT and make both consumers call it; do not keep copies aligned by convention. If the answer to 1 is "no", say so and write the simple version. If "yes", build the seam before piling on the second concrete branch — retrofitting after drift is the expensive path. diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md index 36f9d449..b051752c 100644 --- a/docs/GAPS_BACKLOG.md +++ b/docs/GAPS_BACKLOG.md @@ -7,6 +7,20 @@ how to reproduce, and the fix direction. Close with evidence; never hide. ## OPEN +### SYN-004 (P1) - Late-pair full graph is not verified between the real Desktop and Mobile apps + +**Evidence (2026-08-13):** the production send paths now backfill state records, generated images, +message attachments, and knowledge documents when a device pairs after the data exists. Shared tests +prove byte-bounded anti-entropy. Desktop tests use the real service, SQLite, and temporary files. +Mobile tests use two real sync engines and prove exact file bytes and durable controls. These tests do +not start both actual apps and verify final receiver materialization and UI in one journey. + +**Evidence required to close:** create a project, settings, chat text, enhanced prompt, reasoning, +completed tool, generated image, attachment, and knowledge document on device A before device B is +paired. Pair the real apps through the production connect path. Verify every record, relationship, +and byte on B, restart B, and verify that the same graph returns without duplicate transfers or +detached files. Run Desktop-to-Mobile and Mobile-to-Desktop on physical macOS, iOS, and Android. + ### DEF-001 (P1) - Replay capture control reports a state that is not factual **Evidence (2026-08-08):** macOS Settings reported `Screen access: denied` and `Permission @@ -1181,3 +1195,94 @@ Independently: `0 failed` next to a logged `request.failed` is its own bug - the reaching Activity even for the file that DID have a row. --- + +## MTP is a mobile feature, and it should be a `@offgrid/models` one + +**Status:** open. Filed 2026-08-12. Deliberately NOT in the current PR - it is a package extraction, +not a fix, and folding it in would bloat a release branch that is about sync feedback. + +**The gap.** MTP support exists on mobile and does not exist on desktop. Both apps consume the same +model layer, so a capability that lives in one app's screens is a rule with one home and two +audiences - the shape this repo keeps finding at the root of its bugs. + +**Why it is a shared-package problem, not a desktop one.** Copying the mobile implementation across +would make it a rule with TWO homes, which is worse than having it once. The model layer is the owner: +whether a model needs MTP, which artefacts it implies, and how a device advertises support are all +properties of the MODEL, and every surface should be asking `@offgrid/models` rather than each app +deciding for itself. Desktop then gets it by consuming the package, and so does any future surface. + +**Fix shape.** + +1. Move the MTP capability rule into `shared/packages/models` - one definition of what MTP is and which + models require it, keyed off the catalog entry, with no app-specific branching inside it. +2. Mobile stops deciding and starts asking. Its current behaviour is the reference for what the rule + must produce, so it is the regression check that the extraction changed nothing. +3. Desktop consumes the same rule. No `if (platform)` in either app: a platform that cannot serve MTP + reports that through the same interface, it does not get a special case at the call site. + +**Watch for:** the mobile implementation almost certainly carries assumptions that are really about the +phone runtime rather than about MTP. Those belong on the platform adapter, not in the shared rule - and +the tell is any code in the extracted package that names a platform. + +--- + +## Every non-image attachment syncs to desktop as "text" and previews blank + +**Verdict:** fix-the-guard — the kinds exist, the write path never uses them. + +Reproduced on hardware 16 Aug 2026: a message sent from iPhone with three attachments (a camera +photo, a library screenshot and `mobile.pdf`). On desktop the PDF arrives as a chip reading + +``` +mobile.pdf text +``` + +and opening it shows an empty viewer with only Download and Close - nothing renders. Android shows +the same message correctly, because it has its own renderer. + +The attachment type already names the kinds: + +```ts +kind: 'text' | 'pdf' | 'docx' | 'image' | 'audio' | 'video' +``` + +but the write path in `src/renderer/src/components/MemoryChat.tsx` (~2940) only ever chooses between +two of them: + +```ts +if (isImage) { kind: 'image', code: a.path } +else if (a.text) { kind: 'text', code: a.text } +``` + +A PDF has no image path, so it falls through to `text` and renders `a.text` - which for a binary +document is empty. Hence a labelled-but-blank preview rather than an error. + +**This is not PDF-specific.** Any non-image attachment takes the same branch. It was predicted from +the v0.0.103 diff for VOICE NOTES - "Mac classifies any non-image as kind: 'text', so a .wav arrives +as a paperclip chip labelled text, and clicking it opens a blank viewer" - and never reproduced until +now. A PDF and a voice note are the same bug. + +**Why it matters:** the attachment did sync. The bytes are there (Download works). What is broken is +the classification and therefore the rendering, so the user sees a file they cannot open and +concludes the transfer failed. + +**Fix:** classify by MIME/extension into the kinds the union already declares, carry `path` for +binary kinds rather than `text`, and render a PDF/document viewer for them. `src/main/mime.ts` +already exists and is the obvious source of truth. + +--- + +## Model transfer can use more of a fast local network + +**Status:** open, non-blocking for the current release. Filed 2026-08-21. + +The current release removes the JavaScript checksum bottleneck, receives complete authenticated +frames on iOS, and keeps a bounded encrypted send window full. The real desktop-to-iPhone test then +reached about 6.5 MB/s on a 5 GHz connection while a second desktop-to-Android transfer used the same +uplink. The Mac had negotiated a 195 Mb/s Wi-Fi link at -68 dBm, so this test does not establish the +maximum rate of either device or the new transport. + +Follow up with an isolated physical-device benchmark on a strong 5 GHz or 6 GHz link. Measure one +desktop-to-phone transfer at a time, separate checksum preparation from wire time, and compare both +directions. Also persist verified file checksums so a repeat send does not hash the same multi-GB +model again after an app restart. Keep the 4 MiB authenticated frame format and bounded memory. diff --git a/docs/WINDOWS_SUPPORT.md b/docs/WINDOWS_SUPPORT.md index b2c2dd26..c2ad8f87 100644 --- a/docs/WINDOWS_SUPPORT.md +++ b/docs/WINDOWS_SUPPORT.md @@ -31,7 +31,7 @@ at the bottom. | -------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Windows CI build | 🟢 | `.github/workflows/windows-build.yml` (`windows-2022`) builds + packages a branch artifact; `release.yml`'s `build-win` job publishes the installer + updater feed to the release. Verified by installing a build on real Windows hardware. | | Native-module compile (node-gyp) | 🟢 | Pinned toolchain: `windows-2022` (VS 2022) + Python 3.12. `windows-latest`/VS 2026 + Python 3.13 break node-gyp 11 — documented in the workflow. Covers `better-sqlite3-multiple-ciphers`, `node-llama-cpp`, `sharp`. | -| Windows runtime binaries fetch | 🟢 | `scripts/fetch-win-binaries.ps1` pulls win64 `llama-server` / `whisper-cli` / `sd-cli` / `ffmpeg` (+ DLLs) from upstream GitHub releases at build time. **`llama-server` is pinned to `b9838`** (byte-for-byte parity with the macOS engine); `whisper-cli` / `sd-cli` / `ffmpeg` resolve dynamically from their latest upstream releases. Repo LFS binaries are macOS-only and skipped (`lfs: false`). Fails loud if `llama-server.exe` is missing. | +| Windows runtime binaries fetch | 🟢 | `scripts/fetch-win-binaries.ps1` pulls win64 `llama-server` / `whisper-cli` / `sd-cli` / `ffmpeg` (+ DLLs) from upstream GitHub releases at build time. **`llama-server` is pinned by `package.json` `offgrid.llamaRef`**. macOS compiles it; Windows gets the same-revision prebuilt, so bytes can differ. `whisper-cli` / `sd-cli` / `ffmpeg` resolve dynamically from their latest upstream releases. Repo LFS binaries are macOS-only and skipped (`lfs: false`). Fails loud if `llama-server.exe` is missing. | | NSIS installer | 🟢 | `electron-builder.yml` → `win.executableName`, `nsis` block (desktop shortcut, uninstall name). Untested end-to-end. | | Code signing | 🟡 | Optional via `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets; **unset → unsigned build → SmartScreen will warn** on install. No cert configured yet. | | Auto-update | 🟢 | `electron-updater` is cross-platform (`src/main/updater.ts`); `release.yml`'s `build-win` job publishes `latest.yml` (stable) / `beta.yml` (nightly) to the release, so Windows installs self-update like macOS. | diff --git a/e2e/devices-sync.spec.ts b/e2e/devices-sync.spec.ts index 491d0803..b882ba16 100644 --- a/e2e/devices-sync.spec.ts +++ b/e2e/devices-sync.spec.ts @@ -518,7 +518,7 @@ test.describe('Devices surface — pro tier', () => { await page.getByRole('button', { name: 'Rescan network' }).click() await expect(page.getByRole('button', { name: 'Rescan network' })).toBeEnabled() await expect(page.getByText('Synthetic Android').first()).toBeVisible() - await expect(page.getByText('Scanning LAN and Nearby routes.')).toHaveCount(0) + await expect(page.getByText('Scanning WiFi and Nearby routes.')).toHaveCount(0) await expect(page.getByRole('alert')).toHaveCount(0) const timestamp = new Date().toISOString() diff --git a/integration-tests/conversation-rename.dbtest.ts b/integration-tests/conversation-rename.dbtest.ts index 1f380e45..16e7d0c3 100644 --- a/integration-tests/conversation-rename.dbtest.ts +++ b/integration-tests/conversation-rename.dbtest.ts @@ -7,7 +7,7 @@ * the stored name survives navigation and is rendered in both sidebar and tab. */ import React from 'react' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fs from 'fs' import os from 'os' @@ -53,7 +53,10 @@ function renderChat(): void { } async function beginRename(user: ReturnType, title: string): Promise { - await user.click(await screen.findByRole('button', { name: `Conversation actions for ${title}` })) + fireEvent.pointerDown( + await screen.findByRole('button', { name: `Conversation actions for ${title}` }), + { button: 0, ctrlKey: false } + ) await user.click(screen.getByRole('menuitem', { name: 'Rename' })) } diff --git a/integration-tests/memory-chat-tts.ui.integration.dbtest.ts b/integration-tests/memory-chat-tts.ui.integration.dbtest.ts index 9a3f85f8..815dbde2 100644 --- a/integration-tests/memory-chat-tts.ui.integration.dbtest.ts +++ b/integration-tests/memory-chat-tts.ui.integration.dbtest.ts @@ -6,7 +6,7 @@ * Chromium's media boundary. All Off Grid code between those boundaries stays production. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Blob as NodeBlob } from 'node:buffer' import fs from 'node:fs' @@ -374,9 +374,10 @@ describe('assistant reply speech integration (#105)', () => { expect(database.getRagMessages(conversationId)).toHaveLength(2) await user.click(screen.getByTitle('Voice mode on — speak and listen in voice notes')) + await waitFor(() => expect(database.getSetting('composerVoiceMode', true)).toBe(false)) const composer = await screen.findByPlaceholderText(/^ask /i) - await user.type(composer, 'Typed chat remains usable after voice recovery') - expect((composer as HTMLTextAreaElement).value).toBe( + fireEvent.change(composer, { target: { value: 'Typed chat remains usable after voice recovery' } }) + expect((screen.getByPlaceholderText(/^ask /i) as HTMLTextAreaElement).value).toBe( 'Typed chat remains usable after voice recovery' ) }, 20_000) diff --git a/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx b/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx index 4b2371e5..80c2d430 100644 --- a/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx +++ b/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx @@ -10,7 +10,7 @@ import path from 'node:path' // The DB Vitest config uses the classic JSX transform, which reads this binding at runtime. // eslint-disable-next-line @typescript-eslint/no-unused-vars import React from 'react' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -211,7 +211,7 @@ describe('production workspace bridge', () => { renderChat() const composer = await screen.findByPlaceholderText(/^ask /i) - await user.type(composer, 'Prove the complete local chat path') + fireEvent.change(composer, { target: { value: 'Prove the complete local chat path' } }) await user.click(screen.getByRole('button', { name: /^send$/i })) expect(await inTranscript('The production bridge persisted this answer.')).toBeTruthy() diff --git a/native/keychain-bootstrap/main.cc b/native/keychain-bootstrap/main.cc new file mode 100644 index 00000000..8aed16aa --- /dev/null +++ b/native/keychain-bootstrap/main.cc @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include +#include + +// Node-API is ABI-stable. Declaring the three symbols this tiny bridge uses +// avoids downloading Node/Electron headers during an otherwise offline build. +struct napi_env__; +struct napi_value__; +using napi_env = napi_env__*; +using napi_value = napi_value__*; +using napi_status = int32_t; +extern "C" napi_status napi_create_int32(napi_env, int32_t, napi_value*); +extern "C" napi_status napi_set_named_property(napi_env, napi_value, const char*, napi_value); + +namespace { +enum class RepairStatus : int32_t { + healthy = 0, + repaired = 1, + unavailable = 2, + failed = 3 +}; + +struct RepairResult { + RepairStatus status; + OSStatus os_status; +}; + +bool contains(CFArrayRef keychains, SecKeychainRef candidate) { + const CFIndex count = CFArrayGetCount(keychains); + for (CFIndex index = 0; index < count; ++index) { + const auto item = static_cast( + const_cast(CFArrayGetValueAtIndex(keychains, index))); + if (CFEqual(item, candidate)) return true; + } + return false; +} + +RepairResult repair_missing_default_keychain() { + SecKeychainRef current_default = nullptr; + OSStatus status = SecKeychainCopyDomainDefault( + kSecPreferencesDomainUser, + ¤t_default); + if (status == errSecSuccess && current_default != nullptr) { + CFRelease(current_default); + return {RepairStatus::healthy, errSecSuccess}; + } + if (status != errSecNoDefaultKeychain) { + return {RepairStatus::unavailable, status}; + } + + const passwd* account = getpwuid(getuid()); + const char* home = account == nullptr ? nullptr : account->pw_dir; + if (home == nullptr || home[0] == '\0') { + return {RepairStatus::unavailable, errSecNoSuchKeychain}; + } + const std::string login_path = + std::string(home) + "/Library/Keychains/login.keychain-db"; + + SecKeychainRef login = nullptr; + status = SecKeychainOpen(login_path.c_str(), &login); + if (status != errSecSuccess || login == nullptr) { + return {RepairStatus::unavailable, status}; + } + + CFArrayRef existing = nullptr; + const OSStatus copy_status = SecKeychainCopyDomainSearchList( + kSecPreferencesDomainUser, + &existing); + CFMutableArrayRef repaired = CFArrayCreateMutable( + kCFAllocatorDefault, + 0, + &kCFTypeArrayCallBacks); + if (copy_status == errSecSuccess && existing != nullptr) { + CFArrayAppendArray( + repaired, + existing, + CFRangeMake(0, CFArrayGetCount(existing))); + } + if (!contains(repaired, login)) CFArrayAppendValue(repaired, login); + + status = SecKeychainSetDomainSearchList(kSecPreferencesDomainUser, repaired); + if (status == errSecSuccess) { + status = SecKeychainSetDomainDefault(kSecPreferencesDomainUser, login); + } + + if (existing != nullptr) CFRelease(existing); + CFRelease(repaired); + CFRelease(login); + return status == errSecSuccess + ? RepairResult{RepairStatus::repaired, errSecSuccess} + : RepairResult{RepairStatus::failed, status}; +} + +void set_int(napi_env env, napi_value exports, const char* name, int32_t value) { + napi_value property = nullptr; + if (napi_create_int32(env, value, &property) == 0 && property != nullptr) { + napi_set_named_property(env, exports, name, property); + } +} +} // namespace + +extern "C" __attribute__((visibility("default"))) napi_value +napi_register_module_v1(napi_env env, napi_value exports) { + const RepairResult result = repair_missing_default_keychain(); + set_int(env, exports, "status", static_cast(result.status)); + set_int(env, exports, "osStatus", result.os_status); + return exports; +} diff --git a/package-lock.json b/package-lock.json index 31ad8bd9..c29ede0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@offgrid/clipboard": "file:./packages/clipboard", "@offgrid/design": "file:./packages/design", - "@offgrid/models": "file:./packages/models", + "@offgrid/models": "file:../shared/packages/models", "@offgrid/rag": "file:./packages/rag", "@offgrid/sync": "file:../shared/packages/sync", "@phosphor-icons/react": "^2.1.10", @@ -107,7 +107,6 @@ "../shared/packages/models": { "name": "@offgrid/models", "version": "0.0.1", - "extraneous": true, "license": "AGPL-3.0-only" }, "../shared/packages/rag": { @@ -1883,552 +1882,48 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@huggingface/jinja": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", - "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@huggingface/transformers": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", - "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", - "license": "Apache-2.0", - "dependencies": { - "@huggingface/jinja": "^0.5.3", - "onnxruntime-node": "1.21.0", - "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", - "sharp": "^0.34.1" - } - }, - "node_modules/@huggingface/transformers/node_modules/@huggingface/jinja": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", - "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18.14.1" }, - "funding": { - "url": "https://opencollective.com/libvips" + "peerDependencies": { + "hono": "^4" } }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" } }, - "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@huggingface/transformers/node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, "node_modules/@huggingface/transformers/node_modules/chownr": { @@ -2518,62 +2013,6 @@ "node": ">=12.0.0" } }, - "node_modules/@huggingface/transformers/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@huggingface/transformers/node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/@huggingface/transformers/node_modules/tar": { "version": "7.5.16", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", @@ -2661,9 +2100,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -2679,13 +2118,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -2701,20 +2140,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" + "@img/sharp-libvips-darwin-x64": "1.3.2" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.3" }, "engines": { "node": ">=20.9.0" @@ -2724,9 +2163,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -2740,9 +2179,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -2756,9 +2195,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -2775,9 +2214,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -2794,9 +2233,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -2813,9 +2252,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -2832,9 +2271,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -2851,9 +2290,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -2870,9 +2309,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -2889,9 +2328,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", - "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -2908,9 +2347,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", - "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -2929,13 +2368,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.1" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", - "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -2954,13 +2393,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.1" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", - "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -2979,13 +2418,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.1" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", - "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -3004,13 +2443,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.1" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", - "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -3029,13 +2468,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.1" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", - "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -3054,13 +2493,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.1" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", - "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -3079,13 +2518,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", - "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -3104,13 +2543,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", - "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3124,16 +2563,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", - "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.3" }, "engines": { "node": ">=20.9.0" @@ -3143,9 +2582,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", - "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -3162,9 +2601,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", - "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -3181,9 +2620,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", - "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -3853,7 +3292,7 @@ "link": true }, "node_modules/@offgrid/models": { - "resolved": "packages/models", + "resolved": "../shared/packages/models", "link": true }, "node_modules/@offgrid/rag": { @@ -7660,73 +7099,6 @@ "onnxruntime-node": "1.14.0" } }, - "node_modules/@xenova/transformers/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, - "node_modules/@xenova/transformers/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@xenova/transformers/node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@xenova/transformers/node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/@xenova/transformers/node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -8552,20 +7924,6 @@ "dev": true, "license": "MIT" }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -8583,98 +7941,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", - "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", - "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", - "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", - "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.8.1", - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", - "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9347,19 +8613,6 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9378,16 +8631,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -11244,15 +10487,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -11425,12 +10659,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -13050,12 +12278,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -18122,14 +17344,14 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", - "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -18138,31 +17360,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.2", - "@img/sharp-darwin-x64": "0.35.2", - "@img/sharp-freebsd-wasm32": "0.35.2", - "@img/sharp-libvips-darwin-arm64": "1.3.1", - "@img/sharp-libvips-darwin-x64": "1.3.1", - "@img/sharp-libvips-linux-arm": "1.3.1", - "@img/sharp-libvips-linux-arm64": "1.3.1", - "@img/sharp-libvips-linux-ppc64": "1.3.1", - "@img/sharp-libvips-linux-riscv64": "1.3.1", - "@img/sharp-libvips-linux-s390x": "1.3.1", - "@img/sharp-libvips-linux-x64": "1.3.1", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", - "@img/sharp-libvips-linuxmusl-x64": "1.3.1", - "@img/sharp-linux-arm": "0.35.2", - "@img/sharp-linux-arm64": "0.35.2", - "@img/sharp-linux-ppc64": "0.35.2", - "@img/sharp-linux-riscv64": "0.35.2", - "@img/sharp-linux-s390x": "0.35.2", - "@img/sharp-linux-x64": "0.35.2", - "@img/sharp-linuxmusl-arm64": "0.35.2", - "@img/sharp-linuxmusl-x64": "0.35.2", - "@img/sharp-webcontainers-wasm32": "0.35.2", - "@img/sharp-win32-arm64": "0.35.2", - "@img/sharp-win32-ia32": "0.35.2", - "@img/sharp-win32-x64": "0.35.2" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { @@ -18329,15 +17556,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -18518,17 +17736,6 @@ "node": ">= 0.4" } }, - "node_modules/streamx": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", - "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -18982,15 +18189,6 @@ "license": "ISC", "optional": true }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -19069,15 +18267,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -20943,11 +20132,6 @@ "version": "0.0.1", "license": "AGPL-3.0-only" }, - "packages/models": { - "name": "@offgrid/models", - "version": "0.0.1", - "license": "AGPL-3.0-only" - }, "packages/rag": { "name": "@offgrid/rag", "version": "0.0.1", diff --git a/package.json b/package.json index 852b8361..411379d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "off-grid-ai", "productName": "Off Grid AI Desktop", "version": "0.0.42", - "description": "Off Grid AI — a private, local-first AI that runs open models (text, vision, image, voice) entirely on your device. No cloud, no accounts.", + "offgrid": { + "llamaRef": "b10369" + }, + "description": "Off Grid AI \u2014 a private, local-first AI that runs open models (text, vision, image, voice) entirely on your device. No cloud, no accounts.", "license": "AGPL-3.0-only", "main": "./out/main/index.js", "author": "Off Grid AI ", @@ -23,13 +26,13 @@ "demo": "npm run build && npm run demo:run", "demo:run": "mkdir -p \"$PWD/.demo-profile\" && ln -sfn \"$HOME/Library/Application Support/Off Grid AI Desktop/models\" \"$PWD/.demo-profile/models\" && OFFGRID_PRO=1 OFFGRID_SEED=force OFFGRID_SEED_PRO=force OFFGRID_USER_DATA=\"$PWD/.demo-profile\" electron-vite preview", "demo:fresh": "rm -rf \"$PWD/.demo-profile\" && npm run demo", - "dev": "electron-vite dev", - "gateway": "OFFGRID_SERVER_ONLY=1 electron-vite dev", - "build": "node scripts/stage-pro-native.mjs && npm run typecheck && electron-vite build", + "dev": "node scripts/stage-native.mjs && electron-vite dev", + "gateway": "node scripts/stage-native.mjs && OFFGRID_SERVER_ONLY=1 electron-vite dev", + "build": "node scripts/stage-native.mjs && npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", "build:win": "npm run build && electron-builder --win", - "build:mac": "electron-vite build && electron-builder --mac", + "build:mac": "node scripts/stage-native.mjs && electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", "test:e2e": "electron-vite build && OFFGRID_E2E_HEADLESS=1 playwright test", "test:sync:physical": "node scripts/physical-sync/iosMacKnowledgeSync.mjs", @@ -42,6 +45,10 @@ "knip": "knip --no-gitignore", "coverage:all": "bash scripts/coverage-all.sh" }, + "//overrides": "ONE sharp for the whole tree. sharp ships libvips as `libvips-42.dll`, and Windows resolves a DLL by NAME across the process: whichever copy loads first wins for every later binding. embeddings.ts loads @xenova/transformers (sharp 0.32 / libvips 8.14.5) at startup, so our own sharp 0.35 (libvips 8.18.3) then asked that older DLL for symbols it does not export and died with ERR_DLOPEN_FAILED. Every file attachment failed on Windows and only there — macOS binds by path, so three copies coexist. Proven by load order: sharp alone loads; @xenova-then-sharp does not. Deduping to one version is the fix, not load-order juggling.", + "overrides": { + "sharp": "^0.35.2" + }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -51,7 +58,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@offgrid/clipboard": "file:./packages/clipboard", "@offgrid/design": "file:./packages/design", - "@offgrid/models": "file:./packages/models", + "@offgrid/models": "file:../shared/packages/models", "@offgrid/rag": "file:./packages/rag", "@offgrid/sync": "file:../shared/packages/sync", "@phosphor-icons/react": "^2.1.10", diff --git a/packages/models/dist/adapters/node.d.mts b/packages/models/dist/adapters/node.d.mts deleted file mode 100644 index efe8fd1f..00000000 --- a/packages/models/dist/adapters/node.d.mts +++ /dev/null @@ -1,14 +0,0 @@ -import { D as DownloadBridge } from '../types-CZMZpuKL.mjs'; - -declare class NodeDownloadBridge implements DownloadBridge { - private readonly modelsDir; - constructor(modelsDir: string); - pathFor(fileName: string): string; - exists(destPath: string, expectedBytes?: number): Promise; - download(url: string, destPath: string, opts: { - onProgress?: (written: number, total: number) => void; - signal?: AbortSignal; - }): Promise; -} - -export { NodeDownloadBridge }; diff --git a/packages/models/dist/adapters/node.d.ts b/packages/models/dist/adapters/node.d.ts deleted file mode 100644 index 2cda3504..00000000 --- a/packages/models/dist/adapters/node.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { D as DownloadBridge } from '../types-CZMZpuKL.js'; - -declare class NodeDownloadBridge implements DownloadBridge { - private readonly modelsDir; - constructor(modelsDir: string); - pathFor(fileName: string): string; - exists(destPath: string, expectedBytes?: number): Promise; - download(url: string, destPath: string, opts: { - onProgress?: (written: number, total: number) => void; - signal?: AbortSignal; - }): Promise; -} - -export { NodeDownloadBridge }; diff --git a/packages/models/dist/adapters/node.js b/packages/models/dist/adapters/node.js deleted file mode 100644 index 9110a5ca..00000000 --- a/packages/models/dist/adapters/node.js +++ /dev/null @@ -1,94 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/adapters/node.ts -var node_exports = {}; -__export(node_exports, { - NodeDownloadBridge: () => NodeDownloadBridge -}); -module.exports = __toCommonJS(node_exports); -var import_fs = __toESM(require("fs")); -var import_path = __toESM(require("path")); -var NodeDownloadBridge = class { - constructor(modelsDir) { - this.modelsDir = modelsDir; - import_fs.default.mkdirSync(modelsDir, { recursive: true }); - } - modelsDir; - pathFor(fileName) { - return import_path.default.join(this.modelsDir, fileName); - } - async exists(destPath, expectedBytes) { - try { - const st = import_fs.default.statSync(destPath); - return expectedBytes ? st.size === expectedBytes : st.size > 0; - } catch { - return false; - } - } - async download(url, destPath, opts) { - const tmp = `${destPath}.part`; - let start = 0; - try { - start = import_fs.default.statSync(tmp).size; - } catch { - start = 0; - } - const headers = {}; - if (start > 0) headers.Range = `bytes=${start}-`; - const res = await fetch(url, { headers, signal: opts.signal }); - if (!res.ok && res.status !== 206) { - throw new Error(`download failed: HTTP ${res.status} for ${url}`); - } - if (!res.body) throw new Error("download failed: empty body"); - const contentLength = Number(res.headers.get("content-length") ?? 0); - const total = contentLength + (res.status === 206 ? start : 0); - const out = import_fs.default.createWriteStream(tmp, { flags: start > 0 && res.status === 206 ? "a" : "w" }); - let written = res.status === 206 ? start : 0; - const reader = res.body.getReader(); - try { - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - out.write(Buffer.from(value)); - written += value.length; - opts.onProgress?.(written, total || written); - } - } finally { - out.end(); - await new Promise((resolve) => out.on("finish", () => resolve())); - } - import_fs.default.renameSync(tmp, destPath); - return written; - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - NodeDownloadBridge -}); diff --git a/packages/models/dist/adapters/node.mjs b/packages/models/dist/adapters/node.mjs deleted file mode 100644 index bdefcbbd..00000000 --- a/packages/models/dist/adapters/node.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// src/adapters/node.ts -import fs from "fs"; -import path from "path"; -var NodeDownloadBridge = class { - constructor(modelsDir) { - this.modelsDir = modelsDir; - fs.mkdirSync(modelsDir, { recursive: true }); - } - modelsDir; - pathFor(fileName) { - return path.join(this.modelsDir, fileName); - } - async exists(destPath, expectedBytes) { - try { - const st = fs.statSync(destPath); - return expectedBytes ? st.size === expectedBytes : st.size > 0; - } catch { - return false; - } - } - async download(url, destPath, opts) { - const tmp = `${destPath}.part`; - let start = 0; - try { - start = fs.statSync(tmp).size; - } catch { - start = 0; - } - const headers = {}; - if (start > 0) headers.Range = `bytes=${start}-`; - const res = await fetch(url, { headers, signal: opts.signal }); - if (!res.ok && res.status !== 206) { - throw new Error(`download failed: HTTP ${res.status} for ${url}`); - } - if (!res.body) throw new Error("download failed: empty body"); - const contentLength = Number(res.headers.get("content-length") ?? 0); - const total = contentLength + (res.status === 206 ? start : 0); - const out = fs.createWriteStream(tmp, { flags: start > 0 && res.status === 206 ? "a" : "w" }); - let written = res.status === 206 ? start : 0; - const reader = res.body.getReader(); - try { - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - out.write(Buffer.from(value)); - written += value.length; - opts.onProgress?.(written, total || written); - } - } finally { - out.end(); - await new Promise((resolve) => out.on("finish", () => resolve())); - } - fs.renameSync(tmp, destPath); - return written; - } -}; -export { - NodeDownloadBridge -}; diff --git a/packages/models/dist/index.d.mts b/packages/models/dist/index.d.mts deleted file mode 100644 index 15697478..00000000 --- a/packages/models/dist/index.d.mts +++ /dev/null @@ -1,309 +0,0 @@ -import { M as ModelEntry, a as ModelKind, b as ModelRecommendationTier, c as ModelFile, D as DownloadBridge, d as ModelStore, e as DownloadProgress } from './types-CZMZpuKL.mjs'; -export { f as DownloadStatus, I as ImageGenMode, g as ImageGenProvider, h as ImageGenRequest, i as ImageGenResult, s as supportsMode, v as validateImageGenRequest } from './types-CZMZpuKL.mjs'; - -declare const RECOMMENDATION_TIERS: ModelRecommendationTier[]; -declare function recommendForRam(ramGb: number): ModelRecommendationTier; -declare const CATALOG: ModelEntry[]; -declare function modelsByKind(kind: ModelKind): ModelEntry[]; -declare const MODEL_KINDS: ModelKind[]; - -/** True iff the file set includes a vision projector (mmproj). This is what actually - * gives a chat model image input at load time. */ -declare function hasVisionProjector(files: readonly ModelFile[]): boolean; -/** Derive a model's kind from its files: a projector upgrades a chat model to vision. - * Non-chat kinds (image/voice/transcription) are returned unchanged — an mmproj is a - * chat/VLM concept and never reclassifies them. */ -declare function deriveKind(files: readonly ModelFile[], declared: ModelKind): ModelKind; - -declare class ModelDownloader { - private readonly bridge; - private readonly store; - private aborts; - private listeners; - constructor(bridge: DownloadBridge, store: ModelStore); - onProgress(cb: (p: DownloadProgress) => void): () => void; - isInstalled(modelId: string): boolean; - cancel(modelId: string): void; - private emit; - download(entry: ModelEntry): Promise; -} - -type Credibility = 'offgrid' | 'official' | 'verified-quantizer' | 'community'; -declare const OFFICIAL_MODEL_AUTHORS: Record; -declare const VERIFIED_QUANTIZERS: Record; -declare const CREDIBILITY_LABELS: Record; -/** Classify a HF author into a credibility tier. */ -declare function determineCredibility(author: string): Credibility; - -type FetchLike$1 = (url: string, init?: { - headers?: Record; -}) => Promise<{ - ok: boolean; - status: number; - json: () => Promise; -}>; -interface HFSearchResult { - id: string; - name: string; - org: string; - downloads?: number; - likes?: number; - lastModified?: string; - credibility: Credibility; -} -/** A selectable quantization variant within a HF repo (for the file picker). */ -interface ModelFileVariant { - fileName: string; - quant: string; - quality: string; - recommended: boolean; - sizeBytes: number; - downloadUrl: string; - /** Matched vision projector for this weight, when the repo is multimodal. */ - mmproj?: { - fileName: string; - url: string; - sizeBytes?: number; - }; -} -/** Search the HF hub for models, scoped to a modality (kind) when given so each - * tab only surfaces models it can actually use. */ -declare function searchHuggingFace(query: string, opts?: { - limit?: number; - sort?: string; - kind?: ModelKind; - fetchImpl?: FetchLike$1; -}): Promise; -/** List a repo's GGUF quantization variants (with matched mmproj), for a file - * picker. Sorted recommended-first, then smallest. */ -declare function getModelFiles(repoId: string, opts?: { - fetchImpl?: FetchLike$1; -}): Promise; -/** - * Resolve a HF repo into a downloadable ModelEntry: a primary GGUF (preferring - * Q4_K_M) plus a matching mmproj when the repo is multimodal. Returns null if no - * usable GGUF is found. - */ -declare function resolveHuggingFaceModel(repoId: string, opts?: { - kind?: ModelKind; - fetchImpl?: FetchLike$1; -}): Promise; - -interface QuantInfo { - bitsPerWeight: number; - quality: string; - description: string; - recommended: boolean; -} -declare const QUANTIZATION_INFO: Record; -/** Extract a quantization label from a GGUF filename. */ -declare function extractQuantization(fileName: string): string; -declare function isMMProjFile(fileName: string): boolean; -declare function formatFileSize(bytes: number): string; - -type ChatRole = 'system' | 'user' | 'assistant'; -interface ChatMessage { - role: ChatRole; - content: string; -} -interface ChatOptions { - model?: string; - temperature?: number; - maxTokens?: number; - signal?: AbortSignal; -} -interface ProviderModel { - id: string; - name: string; -} -/** Local or remote LLM. chat() streams text chunks. */ -interface InferenceProvider { - readonly id: string; - readonly name: string; - listModels(): Promise; - chat(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable; -} -type RemoteServerKind = 'openai' | 'ollama'; -interface RemoteServerConfig { - id: string; - name: string; - kind: RemoteServerKind; - /** Base URL. OpenAI-compatible includes the /v1 suffix; Ollama is the host root. */ - endpoint: string; - apiKey?: string; -} -interface FetchResponse { - ok: boolean; - status: number; - json(): Promise; - body: ReadableStream | null; -} -type FetchLike = (url: string, init?: { - method?: string; - headers?: Record; - body?: string; - signal?: AbortSignal; -}) => Promise; -/** OpenAI-compatible provider: local llama-server, LM Studio, LocalAI, OpenAI. */ -declare function openAICompatibleProvider(cfg: { - id: string; - name: string; - endpoint: string; - apiKey?: string; - fetchImpl?: FetchLike; -}): InferenceProvider; -/** Ollama provider (/api/tags, /api/chat NDJSON). */ -declare function ollamaProvider(cfg: { - id: string; - name: string; - endpoint: string; - fetchImpl?: FetchLike; -}): InferenceProvider; -/** Build a provider from a remote server config. */ -declare function createProvider(server: RemoteServerConfig, fetchImpl?: FetchLike): InferenceProvider; -/** Registry of available providers (local + remote) with an active selection. */ -declare class ProviderRegistry { - private providers; - private activeId; - register(provider: InferenceProvider): void; - unregister(id: string): void; - list(): InferenceProvider[]; - setActive(id: string): void; - active(): InferenceProvider | null; -} - -type ModelTypeFilter = 'all' | 'text' | 'vision' | 'code' | 'image-gen'; -type CredibilityFilter = 'all' | Credibility; -type SizeFilter = 'all' | 'tiny' | 'small' | 'medium' | 'large'; -type SortOption = 'recommended' | 'bestfit' | 'size' | 'downloads' | 'recency'; -interface FilterState { - orgs: string[]; - type: ModelTypeFilter; - source: CredibilityFilter; - size: SizeFilter; - quant: string; - sort: SortOption; -} -declare const initialFilterState: FilterState; -/** Normalized model the filters/sorts operate on (map HF results into this). */ -interface FilterableModel { - id: string; - name: string; - org: string; - credibility?: Credibility; - params?: number | null; - tags?: string[]; - downloads?: number; - likes?: number; - lastModified?: string; - minRamGb?: number; - files?: { - sizeBytes?: number; - quant?: string; - }[]; -} -declare const SIZE_OPTIONS: readonly [{ - readonly key: "tiny"; - readonly label: "Tiny (<2B)"; - readonly min: 0; - readonly max: 2; -}, { - readonly key: "small"; - readonly label: "Small (2-5B)"; - readonly min: 2; - readonly max: 5; -}, { - readonly key: "medium"; - readonly label: "Medium (5-15B)"; - readonly min: 5; - readonly max: 15; -}, { - readonly key: "large"; - readonly label: "Large (15B+)"; - readonly min: 15; - readonly max: number; -}]; -declare const MODEL_TYPE_OPTIONS: readonly [{ - readonly key: "text"; - readonly label: "Text"; -}, { - readonly key: "vision"; - readonly label: "Vision"; -}, { - readonly key: "code"; - readonly label: "Code"; -}, { - readonly key: "image-gen"; - readonly label: "Image"; -}]; -declare const CREDIBILITY_OPTIONS: readonly [{ - readonly key: "offgrid"; - readonly label: "Off Grid"; -}, { - readonly key: "official"; - readonly label: "Official"; -}, { - readonly key: "verified-quantizer"; - readonly label: "Verified"; -}, { - readonly key: "community"; - readonly label: "Community"; -}]; -declare const SORT_OPTIONS: readonly [{ - readonly key: "recommended"; - readonly label: "Recommended"; -}, { - readonly key: "bestfit"; - readonly label: "Best fit"; -}, { - readonly key: "downloads"; - readonly label: "Downloads"; -}, { - readonly key: "size"; - readonly label: "Size"; -}, { - readonly key: "recency"; - readonly label: "Recent"; -}]; -/** Parse a billions-of-parameters count from a model name/id, in billions - * ("Qwen3.5-2B" -> 2, "SmolVLM2-500M" -> 0.5). Returns null if none found. */ -declare function parseParamCount(nameOrId: string): number | null; -/** Detect a model's type from its name + tags. */ -declare function getModelType(name: string, tags?: string[]): ModelTypeFilter; -/** Lower is better. Ideal model uses ~40% of RAM; penalize >75% (too slow). */ -declare function bestFitScore(m: FilterableModel, ramGb: number): number; -declare function hasActiveFilters(state: FilterState): boolean; -declare function applyFilters(models: T[], state: FilterState): T[]; -declare function applySort(models: T[], sort: SortOption, ramGb?: number): T[]; -/** Apply filters then sort in one pass. */ -declare function filterAndSort(models: T[], state: FilterState, ramGb?: number): T[]; - -/** The minimal shape the recommendation reads — id, kind, tags. Structural so - * callers can pass either the package `ModelEntry` or a renderer-local model type - * (whose `kind` is a plain string) without a cast. */ -interface RecommendableModel { - id: string; - kind: string; - tags?: string[]; -} -/** RAM (GB) at or below which the lighter (Light-tagged) quant is recommended. - * 16GB is the ceiling: verified that the full Q8 DreamShaper pegs memory (~4.7GB - * peak) and can freeze a 16GB Mac, while the Q4 (~3.08GB peak) does not. */ -declare const LIGHT_MODEL_RAM_CEILING_GB = 16; -/** - * The image model id best suited to a machine with `ramGb` RAM, or null when no - * image model qualifies. General over the 'Light' tag: - * - ramGb <= LIGHT_MODEL_RAM_CEILING_GB → prefer a Light-tagged image model; - * - ramGb > ceiling → prefer the full (non-Light) sibling - * of a family that HAS a Light variant. - * The "has a Light sibling" constraint keeps the badge on the versatile default - * family (DreamShaper) rather than an unrelated heavy model. Falls back to any - * Light model when only that exists (small machine) / the family's full entry. - */ -declare function recommendedImageModelId(models: RecommendableModel[], ramGb: number | null | undefined): string | null; - -export { CATALOG, CREDIBILITY_LABELS, CREDIBILITY_OPTIONS, type ChatMessage, type ChatOptions, type ChatRole, type Credibility, type CredibilityFilter, DownloadBridge, DownloadProgress, type FetchLike, type FilterState, type FilterableModel, type HFSearchResult, type InferenceProvider, LIGHT_MODEL_RAM_CEILING_GB, MODEL_KINDS, MODEL_TYPE_OPTIONS, ModelDownloader, ModelEntry, ModelFile, type ModelFileVariant, ModelKind, ModelRecommendationTier, ModelStore, type ModelTypeFilter, OFFICIAL_MODEL_AUTHORS, type ProviderModel, ProviderRegistry, QUANTIZATION_INFO, type QuantInfo, RECOMMENDATION_TIERS, type RecommendableModel, type RemoteServerConfig, type RemoteServerKind, SIZE_OPTIONS, SORT_OPTIONS, type SizeFilter, type SortOption, VERIFIED_QUANTIZERS, applyFilters, applySort, bestFitScore, createProvider, deriveKind, determineCredibility, extractQuantization, filterAndSort, formatFileSize, getModelFiles, getModelType, hasActiveFilters, hasVisionProjector, initialFilterState, isMMProjFile, modelsByKind, ollamaProvider, openAICompatibleProvider, parseParamCount, recommendForRam, recommendedImageModelId, resolveHuggingFaceModel, searchHuggingFace }; diff --git a/packages/models/dist/index.d.ts b/packages/models/dist/index.d.ts deleted file mode 100644 index 2f014382..00000000 --- a/packages/models/dist/index.d.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { M as ModelEntry, a as ModelKind, b as ModelRecommendationTier, c as ModelFile, D as DownloadBridge, d as ModelStore, e as DownloadProgress } from './types-CZMZpuKL.js'; -export { f as DownloadStatus, I as ImageGenMode, g as ImageGenProvider, h as ImageGenRequest, i as ImageGenResult, s as supportsMode, v as validateImageGenRequest } from './types-CZMZpuKL.js'; - -declare const RECOMMENDATION_TIERS: ModelRecommendationTier[]; -declare function recommendForRam(ramGb: number): ModelRecommendationTier; -declare const CATALOG: ModelEntry[]; -declare function modelsByKind(kind: ModelKind): ModelEntry[]; -declare const MODEL_KINDS: ModelKind[]; - -/** True iff the file set includes a vision projector (mmproj). This is what actually - * gives a chat model image input at load time. */ -declare function hasVisionProjector(files: readonly ModelFile[]): boolean; -/** Derive a model's kind from its files: a projector upgrades a chat model to vision. - * Non-chat kinds (image/voice/transcription) are returned unchanged — an mmproj is a - * chat/VLM concept and never reclassifies them. */ -declare function deriveKind(files: readonly ModelFile[], declared: ModelKind): ModelKind; - -declare class ModelDownloader { - private readonly bridge; - private readonly store; - private aborts; - private listeners; - constructor(bridge: DownloadBridge, store: ModelStore); - onProgress(cb: (p: DownloadProgress) => void): () => void; - isInstalled(modelId: string): boolean; - cancel(modelId: string): void; - private emit; - download(entry: ModelEntry): Promise; -} - -type Credibility = 'offgrid' | 'official' | 'verified-quantizer' | 'community'; -declare const OFFICIAL_MODEL_AUTHORS: Record; -declare const VERIFIED_QUANTIZERS: Record; -declare const CREDIBILITY_LABELS: Record; -/** Classify a HF author into a credibility tier. */ -declare function determineCredibility(author: string): Credibility; - -type FetchLike$1 = (url: string, init?: { - headers?: Record; -}) => Promise<{ - ok: boolean; - status: number; - json: () => Promise; -}>; -interface HFSearchResult { - id: string; - name: string; - org: string; - downloads?: number; - likes?: number; - lastModified?: string; - credibility: Credibility; -} -/** A selectable quantization variant within a HF repo (for the file picker). */ -interface ModelFileVariant { - fileName: string; - quant: string; - quality: string; - recommended: boolean; - sizeBytes: number; - downloadUrl: string; - /** Matched vision projector for this weight, when the repo is multimodal. */ - mmproj?: { - fileName: string; - url: string; - sizeBytes?: number; - }; -} -/** Search the HF hub for models, scoped to a modality (kind) when given so each - * tab only surfaces models it can actually use. */ -declare function searchHuggingFace(query: string, opts?: { - limit?: number; - sort?: string; - kind?: ModelKind; - fetchImpl?: FetchLike$1; -}): Promise; -/** List a repo's GGUF quantization variants (with matched mmproj), for a file - * picker. Sorted recommended-first, then smallest. */ -declare function getModelFiles(repoId: string, opts?: { - fetchImpl?: FetchLike$1; -}): Promise; -/** - * Resolve a HF repo into a downloadable ModelEntry: a primary GGUF (preferring - * Q4_K_M) plus a matching mmproj when the repo is multimodal. Returns null if no - * usable GGUF is found. - */ -declare function resolveHuggingFaceModel(repoId: string, opts?: { - kind?: ModelKind; - fetchImpl?: FetchLike$1; -}): Promise; - -interface QuantInfo { - bitsPerWeight: number; - quality: string; - description: string; - recommended: boolean; -} -declare const QUANTIZATION_INFO: Record; -/** Extract a quantization label from a GGUF filename. */ -declare function extractQuantization(fileName: string): string; -declare function isMMProjFile(fileName: string): boolean; -declare function formatFileSize(bytes: number): string; - -type ChatRole = 'system' | 'user' | 'assistant'; -interface ChatMessage { - role: ChatRole; - content: string; -} -interface ChatOptions { - model?: string; - temperature?: number; - maxTokens?: number; - signal?: AbortSignal; -} -interface ProviderModel { - id: string; - name: string; -} -/** Local or remote LLM. chat() streams text chunks. */ -interface InferenceProvider { - readonly id: string; - readonly name: string; - listModels(): Promise; - chat(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable; -} -type RemoteServerKind = 'openai' | 'ollama'; -interface RemoteServerConfig { - id: string; - name: string; - kind: RemoteServerKind; - /** Base URL. OpenAI-compatible includes the /v1 suffix; Ollama is the host root. */ - endpoint: string; - apiKey?: string; -} -interface FetchResponse { - ok: boolean; - status: number; - json(): Promise; - body: ReadableStream | null; -} -type FetchLike = (url: string, init?: { - method?: string; - headers?: Record; - body?: string; - signal?: AbortSignal; -}) => Promise; -/** OpenAI-compatible provider: local llama-server, LM Studio, LocalAI, OpenAI. */ -declare function openAICompatibleProvider(cfg: { - id: string; - name: string; - endpoint: string; - apiKey?: string; - fetchImpl?: FetchLike; -}): InferenceProvider; -/** Ollama provider (/api/tags, /api/chat NDJSON). */ -declare function ollamaProvider(cfg: { - id: string; - name: string; - endpoint: string; - fetchImpl?: FetchLike; -}): InferenceProvider; -/** Build a provider from a remote server config. */ -declare function createProvider(server: RemoteServerConfig, fetchImpl?: FetchLike): InferenceProvider; -/** Registry of available providers (local + remote) with an active selection. */ -declare class ProviderRegistry { - private providers; - private activeId; - register(provider: InferenceProvider): void; - unregister(id: string): void; - list(): InferenceProvider[]; - setActive(id: string): void; - active(): InferenceProvider | null; -} - -type ModelTypeFilter = 'all' | 'text' | 'vision' | 'code' | 'image-gen'; -type CredibilityFilter = 'all' | Credibility; -type SizeFilter = 'all' | 'tiny' | 'small' | 'medium' | 'large'; -type SortOption = 'recommended' | 'bestfit' | 'size' | 'downloads' | 'recency'; -interface FilterState { - orgs: string[]; - type: ModelTypeFilter; - source: CredibilityFilter; - size: SizeFilter; - quant: string; - sort: SortOption; -} -declare const initialFilterState: FilterState; -/** Normalized model the filters/sorts operate on (map HF results into this). */ -interface FilterableModel { - id: string; - name: string; - org: string; - credibility?: Credibility; - params?: number | null; - tags?: string[]; - downloads?: number; - likes?: number; - lastModified?: string; - minRamGb?: number; - files?: { - sizeBytes?: number; - quant?: string; - }[]; -} -declare const SIZE_OPTIONS: readonly [{ - readonly key: "tiny"; - readonly label: "Tiny (<2B)"; - readonly min: 0; - readonly max: 2; -}, { - readonly key: "small"; - readonly label: "Small (2-5B)"; - readonly min: 2; - readonly max: 5; -}, { - readonly key: "medium"; - readonly label: "Medium (5-15B)"; - readonly min: 5; - readonly max: 15; -}, { - readonly key: "large"; - readonly label: "Large (15B+)"; - readonly min: 15; - readonly max: number; -}]; -declare const MODEL_TYPE_OPTIONS: readonly [{ - readonly key: "text"; - readonly label: "Text"; -}, { - readonly key: "vision"; - readonly label: "Vision"; -}, { - readonly key: "code"; - readonly label: "Code"; -}, { - readonly key: "image-gen"; - readonly label: "Image"; -}]; -declare const CREDIBILITY_OPTIONS: readonly [{ - readonly key: "offgrid"; - readonly label: "Off Grid"; -}, { - readonly key: "official"; - readonly label: "Official"; -}, { - readonly key: "verified-quantizer"; - readonly label: "Verified"; -}, { - readonly key: "community"; - readonly label: "Community"; -}]; -declare const SORT_OPTIONS: readonly [{ - readonly key: "recommended"; - readonly label: "Recommended"; -}, { - readonly key: "bestfit"; - readonly label: "Best fit"; -}, { - readonly key: "downloads"; - readonly label: "Downloads"; -}, { - readonly key: "size"; - readonly label: "Size"; -}, { - readonly key: "recency"; - readonly label: "Recent"; -}]; -/** Parse a billions-of-parameters count from a model name/id, in billions - * ("Qwen3.5-2B" -> 2, "SmolVLM2-500M" -> 0.5). Returns null if none found. */ -declare function parseParamCount(nameOrId: string): number | null; -/** Detect a model's type from its name + tags. */ -declare function getModelType(name: string, tags?: string[]): ModelTypeFilter; -/** Lower is better. Ideal model uses ~40% of RAM; penalize >75% (too slow). */ -declare function bestFitScore(m: FilterableModel, ramGb: number): number; -declare function hasActiveFilters(state: FilterState): boolean; -declare function applyFilters(models: T[], state: FilterState): T[]; -declare function applySort(models: T[], sort: SortOption, ramGb?: number): T[]; -/** Apply filters then sort in one pass. */ -declare function filterAndSort(models: T[], state: FilterState, ramGb?: number): T[]; - -/** The minimal shape the recommendation reads — id, kind, tags. Structural so - * callers can pass either the package `ModelEntry` or a renderer-local model type - * (whose `kind` is a plain string) without a cast. */ -interface RecommendableModel { - id: string; - kind: string; - tags?: string[]; -} -/** RAM (GB) at or below which the lighter (Light-tagged) quant is recommended. - * 16GB is the ceiling: verified that the full Q8 DreamShaper pegs memory (~4.7GB - * peak) and can freeze a 16GB Mac, while the Q4 (~3.08GB peak) does not. */ -declare const LIGHT_MODEL_RAM_CEILING_GB = 16; -/** - * The image model id best suited to a machine with `ramGb` RAM, or null when no - * image model qualifies. General over the 'Light' tag: - * - ramGb <= LIGHT_MODEL_RAM_CEILING_GB → prefer a Light-tagged image model; - * - ramGb > ceiling → prefer the full (non-Light) sibling - * of a family that HAS a Light variant. - * The "has a Light sibling" constraint keeps the badge on the versatile default - * family (DreamShaper) rather than an unrelated heavy model. Falls back to any - * Light model when only that exists (small machine) / the family's full entry. - */ -declare function recommendedImageModelId(models: RecommendableModel[], ramGb: number | null | undefined): string | null; - -export { CATALOG, CREDIBILITY_LABELS, CREDIBILITY_OPTIONS, type ChatMessage, type ChatOptions, type ChatRole, type Credibility, type CredibilityFilter, DownloadBridge, DownloadProgress, type FetchLike, type FilterState, type FilterableModel, type HFSearchResult, type InferenceProvider, LIGHT_MODEL_RAM_CEILING_GB, MODEL_KINDS, MODEL_TYPE_OPTIONS, ModelDownloader, ModelEntry, ModelFile, type ModelFileVariant, ModelKind, ModelRecommendationTier, ModelStore, type ModelTypeFilter, OFFICIAL_MODEL_AUTHORS, type ProviderModel, ProviderRegistry, QUANTIZATION_INFO, type QuantInfo, RECOMMENDATION_TIERS, type RecommendableModel, type RemoteServerConfig, type RemoteServerKind, SIZE_OPTIONS, SORT_OPTIONS, type SizeFilter, type SortOption, VERIFIED_QUANTIZERS, applyFilters, applySort, bestFitScore, createProvider, deriveKind, determineCredibility, extractQuantization, filterAndSort, formatFileSize, getModelFiles, getModelType, hasActiveFilters, hasVisionProjector, initialFilterState, isMMProjFile, modelsByKind, ollamaProvider, openAICompatibleProvider, parseParamCount, recommendForRam, recommendedImageModelId, resolveHuggingFaceModel, searchHuggingFace }; diff --git a/packages/models/dist/index.js b/packages/models/dist/index.js deleted file mode 100644 index 8a5eb59a..00000000 --- a/packages/models/dist/index.js +++ /dev/null @@ -1,1737 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/index.ts -var index_exports = {}; -__export(index_exports, { - CATALOG: () => CATALOG, - CREDIBILITY_LABELS: () => CREDIBILITY_LABELS, - CREDIBILITY_OPTIONS: () => CREDIBILITY_OPTIONS, - LIGHT_MODEL_RAM_CEILING_GB: () => LIGHT_MODEL_RAM_CEILING_GB, - MODEL_KINDS: () => MODEL_KINDS, - MODEL_TYPE_OPTIONS: () => MODEL_TYPE_OPTIONS, - ModelDownloader: () => ModelDownloader, - OFFICIAL_MODEL_AUTHORS: () => OFFICIAL_MODEL_AUTHORS, - ProviderRegistry: () => ProviderRegistry, - QUANTIZATION_INFO: () => QUANTIZATION_INFO, - RECOMMENDATION_TIERS: () => RECOMMENDATION_TIERS, - SIZE_OPTIONS: () => SIZE_OPTIONS, - SORT_OPTIONS: () => SORT_OPTIONS, - VERIFIED_QUANTIZERS: () => VERIFIED_QUANTIZERS, - applyFilters: () => applyFilters, - applySort: () => applySort, - bestFitScore: () => bestFitScore, - createProvider: () => createProvider, - deriveKind: () => deriveKind, - determineCredibility: () => determineCredibility, - extractQuantization: () => extractQuantization, - filterAndSort: () => filterAndSort, - formatFileSize: () => formatFileSize, - getModelFiles: () => getModelFiles, - getModelType: () => getModelType, - hasActiveFilters: () => hasActiveFilters, - hasVisionProjector: () => hasVisionProjector, - initialFilterState: () => initialFilterState, - isMMProjFile: () => isMMProjFile, - modelsByKind: () => modelsByKind, - ollamaProvider: () => ollamaProvider, - openAICompatibleProvider: () => openAICompatibleProvider, - parseParamCount: () => parseParamCount, - recommendForRam: () => recommendForRam, - recommendedImageModelId: () => recommendedImageModelId, - resolveHuggingFaceModel: () => resolveHuggingFaceModel, - searchHuggingFace: () => searchHuggingFace, - supportsMode: () => supportsMode, - validateImageGenRequest: () => validateImageGenRequest -}); -module.exports = __toCommonJS(index_exports); - -// src/capabilities.ts -function hasVisionProjector(files) { - return files.some((f) => f.role === "mmproj"); -} -function deriveKind(files, declared) { - if ((declared === "text" || declared === "vision") && hasVisionProjector(files)) { - return "vision"; - } - return declared; -} - -// src/catalog.ts -var HF = "https://huggingface.co"; -var resolve = (repo, file) => `${HF}/${repo}/resolve/main/${file}`; -var RECOMMENDATION_TIERS = [ - { minRamGb: 3, maxRamGb: 4, maxParams: 1.5, quantization: "Q4_K_M" }, - { minRamGb: 4, maxRamGb: 6, maxParams: 3, quantization: "Q4_K_M" }, - { minRamGb: 6, maxRamGb: 8, maxParams: 4, quantization: "Q4_K_M" }, - { minRamGb: 8, maxRamGb: 12, maxParams: 8, quantization: "Q4_K_M" }, - { minRamGb: 12, maxRamGb: 16, maxParams: 13, quantization: "Q4_K_M" }, - { minRamGb: 16, maxRamGb: Infinity, maxParams: 30, quantization: "Q4_K_M" } -]; -function recommendForRam(ramGb) { - return RECOMMENDATION_TIERS.find((t) => ramGb >= t.minRamGb && ramGb < t.maxRamGb) ?? RECOMMENDATION_TIERS[RECOMMENDATION_TIERS.length - 1]; -} -var RAW_CATALOG = [ - // --- text (SLMs) — post-Jan-2026 only; the latest small-model challengers, - // quantized for desktop. Dates are the source repo's HF createdAt. --- - { - id: "unsloth/Qwen3.5-0.8B-GGUF", - name: "Qwen 3.5 0.8B", - kind: "text", - org: "Qwen", - description: "Tiny, very fast \u2014 runs on almost anything", - params: 0.8, - minRamGb: 3, - quant: "Q4_K_M", - releaseDate: "2026-03-01", - files: [ - { - name: "Qwen3.5-0.8B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-0.8B-GGUF", "Qwen3.5-0.8B-Q4_K_M.gguf"), - sizeBytes: 53e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-0.8B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-0.8B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 207346528, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-2B-GGUF", - name: "Qwen 3.5 2B", - kind: "text", - org: "Qwen", - description: "Hybrid thinking + chat, long context", - params: 2, - minRamGb: 4, - quant: "Q4_K_M", - releaseDate: "2026-02-28", - files: [ - { - name: "Qwen3.5-2B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-2B-GGUF", "Qwen3.5-2B-Q4_K_M.gguf"), - sizeBytes: 128e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-2B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-2B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 671372992, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-4B-GGUF", - name: "Qwen 3.5 4B", - kind: "text", - org: "Qwen", - description: "Strong small general model \u2014 hybrid thinking + chat, long context", - params: 4, - minRamGb: 6, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-03-02", - files: [ - { - name: "Qwen3.5-4B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-Q4_K_M.gguf"), - sizeBytes: 274e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-4B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-4B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 675569344, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-9B-GGUF", - name: "Qwen 3.5 9B", - kind: "text", - org: "Qwen", - description: "Higher-quality general reasoning; needs a bit more RAM", - params: 9, - minRamGb: 8, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-02-28", - files: [ - { - name: "Qwen3.5-9B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"), - sizeBytes: 568e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-9B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-9B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 921705024, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-27B-GGUF", - name: "Qwen 3.5 27B", - kind: "text", - org: "Qwen", - description: "Top Qwen3.5 quality \u2014 large; 24GB+ machines", - params: 27, - minRamGb: 24, - quant: "Q4_K_M", - releaseDate: "2026-02-24", - files: [ - { - name: "Qwen3.5-27B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-27B-GGUF", "Qwen3.5-27B-Q4_K_M.gguf"), - sizeBytes: 1674e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-27B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-27B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 931145984, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-E2B-it-GGUF", - name: "Gemma 4 E2B", - kind: "vision", - org: "google", - description: "Google\u2019s small efficient model \u2014 fast, capable, reads images", - params: 2, - minRamGb: 5, - quant: "Q4_K_M", - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-E2B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-E2B-it-GGUF", "gemma-4-E2B-it-Q4_K_M.gguf"), - sizeBytes: 311e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-E2B-it-F16.gguf", - url: resolve("unsloth/gemma-4-E2B-it-GGUF", "mmproj-F16.gguf"), - sizeBytes: 985654080, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-12b-it-GGUF", - name: "Gemma 4 12B", - kind: "text", - org: "google", - description: "Strong mid-size Gemma 4 \u2014 great general quality", - params: 12, - minRamGb: 12, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-05-29", - files: [ - { - name: "gemma-4-12b-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-12b-it-GGUF", "gemma-4-12b-it-Q4_K_M.gguf"), - sizeBytes: 712e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-12b-it-BF16.gguf", - url: resolve("unsloth/gemma-4-12b-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 175115840, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-26B-A4B-it-GGUF", - name: "Gemma 4 26B A4B (MoE)", - kind: "text", - org: "google", - description: "MoE \u2014 26B quality at ~4B active speed; needs 20GB+", - params: 26, - minRamGb: 20, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-26B-A4B-it-GGUF", "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"), - sizeBytes: 1695e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-26B-A4B-it-BF16.gguf", - url: resolve("unsloth/gemma-4-26B-A4B-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 1194828256, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-31B-it-GGUF", - name: "Gemma 4 31B", - kind: "text", - org: "google", - description: "Largest Gemma 4 dense \u2014 top quality; 24GB+ machines", - params: 31, - minRamGb: 24, - quant: "Q4_K_M", - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-31B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-31B-it-GGUF", "gemma-4-31B-it-Q4_K_M.gguf"), - sizeBytes: 1832e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-31B-it-BF16.gguf", - url: resolve("unsloth/gemma-4-31B-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 1200726496, - role: "mmproj" - } - ] - }, - // --- vision (multimodal LLM) --- - { - id: "unsloth/gemma-4-E4B-it-GGUF", - name: "Gemma 4 E4B", - kind: "vision", - org: "google", - description: "Thinking + vision, MoE", - params: 4, - minRamGb: 6, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-E4B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-E4B-it-GGUF", "gemma-4-E4B-it-Q4_K_M.gguf"), - sizeBytes: 498e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-E4B-it-F16.gguf", - url: resolve("unsloth/gemma-4-E4B-it-GGUF", "mmproj-F16.gguf"), - sizeBytes: 99e7, - role: "mmproj" - } - ] - }, - { - id: "ggml-org/SmolVLM2-2.2B-Instruct-GGUF", - name: "SmolVLM2 2.2B", - kind: "vision", - org: "HuggingFaceTB", - description: "Compact, fast vision-language model \u2014 great on modest RAM", - params: 2.2, - minRamGb: 6, - quant: "Q4_K_M", - releaseDate: "2025-04-21", - files: [ - { - name: "SmolVLM2-2.2B-Instruct-Q4_K_M.gguf", - url: resolve("ggml-org/SmolVLM2-2.2B-Instruct-GGUF", "SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"), - sizeBytes: 111e7, - role: "primary" - }, - { - name: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf", - url: resolve( - "ggml-org/SmolVLM2-2.2B-Instruct-GGUF", - "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf" - ), - sizeBytes: 87e7, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3-VL-2B-Instruct-GGUF", - name: "Qwen3-VL 2B", - kind: "vision", - org: "Qwen", - description: "Small vision-language model \u2014 fast and capable", - params: 2, - minRamGb: 6, - quant: "Q4_K_M", - releaseDate: "2025-10-30", - files: [ - { - name: "Qwen3-VL-2B-Instruct-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-VL-2B-Instruct-GGUF", "Qwen3-VL-2B-Instruct-Q4_K_M.gguf"), - sizeBytes: 111e7, - role: "primary" - }, - { - name: "mmproj-Qwen3-VL-2B-Instruct-F16.gguf", - url: resolve("unsloth/Qwen3-VL-2B-Instruct-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 82e7, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3-VL-8B-Instruct-GGUF", - name: "Qwen3-VL 8B", - kind: "vision", - org: "Qwen", - description: "Stronger VLM \u2014 better detail + OCR; needs more RAM", - params: 8, - minRamGb: 10, - quant: "Q4_K_M", - releaseDate: "2025-10-30", - files: [ - { - name: "Qwen3-VL-8B-Instruct-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL-8B-Instruct-Q4_K_M.gguf"), - sizeBytes: 503e7, - role: "primary" - }, - { - name: "mmproj-Qwen3-VL-8B-Instruct-F16.gguf", - url: resolve("unsloth/Qwen3-VL-8B-Instruct-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 116e7, - role: "mmproj" - } - ] - }, - // --- image generation — 2026 / fast few-step models only (open weight) --- - { - id: "leejet/Z-Image-Turbo-GGUF", - name: "Z-Image Turbo (2026)", - kind: "image", - // NOT tagged 'Fast': despite the "Turbo" name this is a FLUX-class diffusion - // transformer (DiT + Qwen3-4B text encoder + FLUX VAE) — heavy and slow on - // Apple Silicon via ggml, not a few-step SDXL distill. 'Fast' is reserved for - // models verified fast on-device (dreamshaper-turbo, realvis-lightning). - tags: ["Recommended", "2026", "Top quality"], - org: "Alibaba Tongyi", - description: "Flagship 2026 model \u2014 1024px in ~8 steps, top quality-per-byte, strong bilingual text. Apache-2.0. (diffusion + Qwen3 encoder + VAE)", - minRamGb: 12, - imageModes: ["txt2img"], - files: [ - { - name: "z_image_turbo-Q4_K.gguf", - url: resolve("leejet/Z-Image-Turbo-GGUF", "z_image_turbo-Q4_K.gguf"), - role: "primary", - sizeBytes: 386e7 - }, - { - name: "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-4B-Instruct-2507-GGUF", "Qwen3-4B-Instruct-2507-Q4_K_M.gguf"), - role: "aux", - sizeBytes: 25e8 - }, - { - name: "ae.safetensors", - url: resolve("second-state/FLUX.1-schnell-GGUF", "ae.safetensors"), - role: "aux", - sizeBytes: 34e7 - } - ] - }, - // NOTE: MLX/mflux image models are PARKED (2026-06-23) — the only non-gated - // on-device MLX LoRA options are too large to ship (Z-Image ~13GB 8-bit / ~33GB - // bf16; FLUX.1-schnell 4-bit ~10GB). No MLX catalog entry is exposed. The - // dormant runtime plumbing lives in src/main/mflux.ts (re-enable by repopulating - // MFLUX_MODELS + restoring an entry here with runtime:'mflux'). - { - id: "mzwing/SDXL-Lightning-GGUF", - name: "SDXL Lightning (4-step)", - kind: "image", - tags: ["Recommended", "Fast"], - org: "ByteDance", - description: "Near-SDXL quality at 1024px in 4 steps (~7\xD7 faster). ~4GB model. Best balance \u2014 recommended.", - minRamGb: 8, - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "sdxl_lightning_4step.q8_0.gguf", - url: resolve("mzwing/SDXL-Lightning-GGUF", "sdxl_lightning_4step.q8_0.gguf"), - role: "primary", - sizeBytes: 4099e6 - } - ] - }, - { - id: "OlegSkutte/sdxl-turbo-GGUF", - name: "SDXL Turbo (fast drafts)", - kind: "image", - tags: ["Fastest", "Drafts"], - org: "Stability AI", - description: "Distilled SDXL \u2014 1-4 steps, ~10s drafts at 512px. Fastest option; lower fidelity.", - minRamGb: 8, - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "sd_xl_turbo_1.0.q8_0.gguf", - url: resolve("OlegSkutte/sdxl-turbo-GGUF", "sd_xl_turbo_1.0.q8_0.gguf"), - role: "primary", - sizeBytes: 41e8 - } - ] - }, - // SDXL finetunes — Off Grid GGUF builds (q8). The community GGUF quants of these - // are mis-exported and won't load in sd.cpp, so we converted the official - // OpenRAIL checkpoints ourselves (offgrid-ai HF org) → correct, ~4GB, on-device. - { - id: "offgrid-ai/realvisxl-v5.0-GGUF", - name: "RealVisXL v5.0 (photoreal)", - kind: "image", - tags: ["High quality", "Photoreal"], - org: "RealVis", - description: "Top photorealism SDXL \u2014 Off Grid GGUF build of SG161222/RealVisXL_V5.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-08-05", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-Q8_0.gguf", - url: resolve("offgrid-ai/realvisxl-v5.0-GGUF", "realvisxl-v5.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, runs on a 16GB Mac. Tagged 'Light' - // so the RAM-aware default + "Recommended" badge pick it on <= 16GB machines. - id: "offgrid-ai/realvisxl-v5.0-GGUF-Q4", - name: "RealVisXL v5.0 (Light)", - kind: "image", - tags: ["Photoreal", "Light"], - org: "RealVis", - description: "Top photorealism SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-08-05", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-Q4_K.gguf", - url: resolve("offgrid-ai/realvisxl-v5.0-GGUF", "realvisxl-v5.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - name: "RealVisXL v5.0 Lightning (photoreal)", - kind: "image", - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ["Photoreal"], - org: "RealVis", - description: "Photoreal SDXL, few-step (fast) \u2014 Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-09-02", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-lightning-Q8_0.gguf", - url: resolve( - "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - "realvisxl-v5.0-lightning-Q8_0.gguf" - ), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — few-step photoreal, ~35% less memory, 16GB-friendly. - id: "offgrid-ai/realvisxl-v5.0-lightning-GGUF-Q4", - name: "RealVisXL v5.0 Lightning (Light)", - kind: "image", - tags: ["Fast", "Photoreal", "Light"], - org: "RealVis", - description: "Photoreal SDXL, few-step (fast). Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-09-02", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-lightning-Q4_K.gguf", - url: resolve( - "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - "realvisxl-v5.0-lightning-Q4_K.gguf" - ), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - name: "DreamShaper XL v2 Turbo (versatile)", - kind: "image", - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ["Versatile"], - org: "Lykon", - description: "The all-rounder \u2014 photoreal, art, fantasy, 3D. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo. Full Q8 quant (best quality); best on 24GB+ RAM.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-02-07", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "dreamshaper-xl-v2-turbo-Q8_0.gguf", - url: resolve( - "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - "dreamshaper-xl-v2-turbo-Q8_0.gguf" - ), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Lighter Q4_K quant of the same distilled turbo model — ~35% less memory - // (~3.08GB peak vs ~4.7GB), so it runs on a 16GB Mac without pegging unified - // memory. Same repo, distinct id + filename so download/active-tracking treat - // it as a separate installable model. Tagged 'Light' → the RAM-aware default + - // "Recommended" badge pick it on machines with <= 16GB RAM. - id: "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF-Q4", - name: "DreamShaper XL v2 Turbo (Light)", - kind: "image", - tags: ["Versatile", "Fast", "Light"], - org: "Lykon", - description: "The all-rounder \u2014 photoreal, art, fantasy, 3D. Q4 quant: ~35% less memory than the full model, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-02-07", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "dreamshaper-xl-v2-turbo-Q4_K.gguf", - url: resolve( - "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - "dreamshaper-xl-v2-turbo-Q4_K.gguf" - ), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/juggernaut-xl-v9-GGUF", - name: "Juggernaut XL v9 (photoreal)", - kind: "image", - tags: ["High quality", "Photoreal"], - org: "RunDiffusion", - description: "Versatile photoreal SDXL \u2014 cinematic, portraits, landscapes. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-02-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "juggernaut-xl-v9-Q8_0.gguf", - url: resolve("offgrid-ai/juggernaut-xl-v9-GGUF", "juggernaut-xl-v9-Q8_0.gguf"), - role: "primary", - sizeBytes: 435e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/juggernaut-xl-v9-GGUF-Q4", - name: "Juggernaut XL v9 (Light)", - kind: "image", - tags: ["Photoreal", "Light"], - org: "RunDiffusion", - description: "Versatile photoreal SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-02-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "juggernaut-xl-v9-Q4_K.gguf", - url: resolve("offgrid-ai/juggernaut-xl-v9-GGUF", "juggernaut-xl-v9-Q4_K.gguf"), - role: "primary", - sizeBytes: 29e8 - } - ] - }, - { - id: "offgrid-ai/animagine-xl-4.0-GGUF", - name: "Animagine XL 4.0 (anime)", - kind: "image", - tags: ["High quality", "Anime"], - org: "Cagliostro", - description: "Leading anime SDXL \u2014 strong character knowledge. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2025-01-10", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "animagine-xl-4.0-Q8_0.gguf", - url: resolve("offgrid-ai/animagine-xl-4.0-GGUF", "animagine-xl-4.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/animagine-xl-4.0-GGUF-Q4", - name: "Animagine XL 4.0 (Light)", - kind: "image", - tags: ["Anime", "Light"], - org: "Cagliostro", - description: "Leading anime SDXL \u2014 strong character knowledge. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2025-01-10", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "animagine-xl-4.0-Q4_K.gguf", - url: resolve("offgrid-ai/animagine-xl-4.0-GGUF", "animagine-xl-4.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/illustrious-xl-v2.0-GGUF", - name: "Illustrious XL v2.0 (anime)", - kind: "image", - tags: ["High quality", "Anime"], - org: "OnomaAI", - description: "Top anime / illustration SDXL base. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2025-04-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "illustrious-xl-v2.0-Q8_0.gguf", - url: resolve("offgrid-ai/illustrious-xl-v2.0-GGUF", "illustrious-xl-v2.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/illustrious-xl-v2.0-GGUF-Q4", - name: "Illustrious XL v2.0 (Light)", - kind: "image", - tags: ["Anime", "Light"], - org: "OnomaAI", - description: "Top anime / illustration SDXL base. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2025-04-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "illustrious-xl-v2.0-Q4_K.gguf", - url: resolve("offgrid-ai/illustrious-xl-v2.0-GGUF", "illustrious-xl-v2.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/pony-diffusion-v6-xl-GGUF", - name: "Pony Diffusion V6 XL (stylized)", - kind: "image", - tags: ["High quality", "Stylized"], - org: "PurpleSmartAI", - description: "Dominant SDXL for stylized characters & illustration; highly promptable. Off Grid GGUF build of Pony Diffusion V6 XL.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-05-25", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "pony-diffusion-v6-xl-Q8_0.gguf", - url: resolve("offgrid-ai/pony-diffusion-v6-xl-GGUF", "pony-diffusion-v6-xl-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/pony-diffusion-v6-xl-GGUF-Q4", - name: "Pony Diffusion V6 XL (Light)", - kind: "image", - tags: ["Stylized", "Light"], - org: "PurpleSmartAI", - description: "Dominant SDXL for stylized characters & illustration. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Pony Diffusion V6 XL.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-05-25", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "pony-diffusion-v6-xl-Q4_K.gguf", - url: resolve("offgrid-ai/pony-diffusion-v6-xl-GGUF", "pony-diffusion-v6-xl-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - // --- voice (TTS); open models, ONNX runtime (no Python) --- - { - id: "onnx-community/Kokoro-82M-v1.0-ONNX", - name: "Kokoro TTS 82M", - kind: "voice", - org: "hexgrad", - description: "Lightweight, natural text-to-speech (ONNX); great default", - minRamGb: 3, - files: [ - { - name: "kokoro-82m-v1.0.onnx", - url: resolve("onnx-community/Kokoro-82M-v1.0-ONNX", "onnx/model_quantized.onnx"), - role: "primary", - sizeBytes: 92361116 - } - ] - }, - { - id: "rhasspy/piper-voices/en_US-lessac-medium", - name: "Piper - Lessac (English)", - kind: "voice", - org: "rhasspy", - description: "Fast multi-voice text-to-speech (ONNX); many languages available", - minRamGb: 2, - files: [ - { - name: "en_US-lessac-medium.onnx", - url: resolve("rhasspy/piper-voices", "en/en_US/lessac/medium/en_US-lessac-medium.onnx"), - role: "primary", - sizeBytes: 63201294 - }, - { - name: "en_US-lessac-medium.onnx.json", - url: resolve( - "rhasspy/piper-voices", - "en/en_US/lessac/medium/en_US-lessac-medium.onnx.json" - ), - role: "aux" - } - ] - }, - // --- transcription (STT / whisper); all from ggerganov/whisper.cpp (ggml .bin) --- - { - id: "ggerganov/whisper.cpp/tiny", - name: "Whisper Tiny", - kind: "transcription", - org: "ggerganov", - description: "Fastest, smallest \u2014 lowest accuracy", - minRamGb: 2, - files: [ - { - name: "ggml-tiny.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-tiny.bin"), - role: "primary", - sizeBytes: 777e5 - } - ] - }, - { - id: "ggerganov/whisper.cpp/base", - name: "Whisper Base", - kind: "transcription", - org: "ggerganov", - description: "Offline speech-to-text (base) \u2014 good speed/quality default", - minRamGb: 3, - files: [ - { - name: "ggml-base.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-base.bin"), - role: "primary", - sizeBytes: 147951e3 - } - ] - }, - { - id: "ggerganov/whisper.cpp/small", - name: "Whisper Small", - kind: "transcription", - org: "ggerganov", - description: "Offline speech-to-text (higher accuracy)", - minRamGb: 4, - files: [ - { - name: "ggml-small.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-small.bin"), - role: "primary", - sizeBytes: 487601e3 - } - ] - }, - { - id: "ggerganov/whisper.cpp/medium", - name: "Whisper Medium", - kind: "transcription", - org: "ggerganov", - description: "High accuracy; slower", - minRamGb: 6, - files: [ - { - name: "ggml-medium.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-medium.bin"), - role: "primary", - sizeBytes: 1533e6 - } - ] - }, - { - id: "ggerganov/whisper.cpp/large-v3-turbo", - name: "Whisper Large v3 Turbo", - kind: "transcription", - org: "ggerganov", - description: "Near-large accuracy, much faster \u2014 recommended", - minRamGb: 6, - files: [ - { - name: "ggml-large-v3-turbo.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-large-v3-turbo.bin"), - role: "primary", - sizeBytes: 1624e6 - } - ] - }, - { - id: "ggerganov/whisper.cpp/large-v3", - name: "Whisper Large v3", - kind: "transcription", - org: "ggerganov", - description: "Highest accuracy (large); needs more RAM", - minRamGb: 8, - files: [ - { - name: "ggml-large-v3.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-large-v3.bin"), - role: "primary", - sizeBytes: 3095e6 - } - ] - }, - // --- transcription (Parakeet, NVIDIA NeMo) — sherpa-onnx offline transducer (ONNX). - // A model is 4 files (encoder/decoder/joiner/tokens); on-disk names are slug-prefixed - // so multiple Parakeet models coexist in the flat models dir without colliding. Higher - // accuracy than whisper; served by the bundled sherpa-onnx CLI (engine: 'parakeet'). --- - { - id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - name: "Parakeet TDT 0.6B v2", - kind: "transcription", - engine: "parakeet", - org: "nvidia", - description: "High-accuracy English STT (int8) - tops the open ASR leaderboard", - minRamGb: 4, - tags: ["Accurate", "English"], - files: [ - { - name: "parakeet-v2.encoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "encoder.int8.onnx"), - role: "primary", - sizeBytes: 652e6 - }, - { - name: "parakeet-v2.decoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "decoder.int8.onnx"), - role: "aux", - sizeBytes: 726e4 - }, - { - name: "parakeet-v2.joiner.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "joiner.int8.onnx"), - role: "aux", - sizeBytes: 174e4 - }, - { - name: "parakeet-v2.tokens.txt", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "tokens.txt"), - role: "tokenizer", - sizeBytes: 9600 - } - ] - }, - { - id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - name: "Parakeet TDT 0.6B v3", - kind: "transcription", - engine: "parakeet", - org: "nvidia", - description: "Multilingual STT (int8) - 25 European languages", - minRamGb: 4, - isNew: true, - tags: ["Accurate", "Multilingual"], - files: [ - { - name: "parakeet-v3.encoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "encoder.int8.onnx"), - role: "primary", - sizeBytes: 652e6 - }, - { - name: "parakeet-v3.decoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "decoder.int8.onnx"), - role: "aux", - sizeBytes: 726e4 - }, - { - name: "parakeet-v3.joiner.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "joiner.int8.onnx"), - role: "aux", - sizeBytes: 174e4 - }, - { - name: "parakeet-v3.tokens.txt", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "tokens.txt"), - role: "tokenizer", - sizeBytes: 9600 - } - ] - } -]; -var CATALOG = RAW_CATALOG.map((e) => ({ - ...e, - kind: deriveKind(e.files, e.kind) -})); -function modelsByKind(kind) { - return CATALOG.filter((m) => m.kind === kind); -} -var MODEL_KINDS = ["text", "vision", "image", "voice", "transcription"]; - -// src/download.ts -var ModelDownloader = class { - constructor(bridge, store) { - this.bridge = bridge; - this.store = store; - } - bridge; - store; - aborts = /* @__PURE__ */ new Map(); - listeners = /* @__PURE__ */ new Set(); - onProgress(cb) { - this.listeners.add(cb); - return () => this.listeners.delete(cb); - } - isInstalled(modelId) { - return this.store.isInstalled(modelId); - } - cancel(modelId) { - this.aborts.get(modelId)?.abort(); - } - emit(p) { - for (const l of this.listeners) l(p); - } - async download(entry) { - const controller = new AbortController(); - this.aborts.set(entry.id, controller); - const totalKnown = entry.files.reduce((n, f) => n + (f.sizeBytes ?? 0), 0); - let basePrev = 0; - try { - for (const file of entry.files) { - const dest = this.bridge.pathFor(file.name); - if (await this.bridge.exists(dest, file.sizeBytes)) { - basePrev += file.sizeBytes ?? 0; - continue; - } - await this.bridge.download(file.url, dest, { - signal: controller.signal, - onProgress: (written, total) => { - const totalBytes = totalKnown || basePrev + total; - const bytesDownloaded = basePrev + written; - this.emit({ - modelId: entry.id, - status: "downloading", - bytesDownloaded, - totalBytes, - progress: totalBytes ? Math.min(1, bytesDownloaded / totalBytes) : 0, - currentFile: file.name - }); - } - }); - basePrev += file.sizeBytes ?? 0; - } - this.store.markInstalled(entry); - this.emit({ - modelId: entry.id, - status: "completed", - progress: 1, - bytesDownloaded: totalKnown, - totalBytes: totalKnown - }); - return true; - } catch (err) { - const aborted = controller.signal.aborted; - this.emit({ - modelId: entry.id, - status: aborted ? "paused" : "failed", - progress: 0, - bytesDownloaded: 0, - totalBytes: totalKnown, - error: aborted ? void 0 : err instanceof Error ? err.message : String(err) - }); - return false; - } finally { - this.aborts.delete(entry.id); - } - } -}; - -// src/quant.ts -var QUANTIZATION_INFO = { - Q2_K: { - bitsPerWeight: 2.625, - quality: "Low", - description: "Extreme compression, noticeable quality loss", - recommended: false - }, - Q3_K_S: { - bitsPerWeight: 3.4375, - quality: "Low-Medium", - description: "High compression, some quality loss", - recommended: false - }, - Q3_K_M: { - bitsPerWeight: 3.4375, - quality: "Medium", - description: "Good compression with acceptable quality", - recommended: false - }, - Q4_0: { - bitsPerWeight: 4, - quality: "Medium", - description: "Basic 4-bit quantization", - recommended: false - }, - Q4_K_S: { - bitsPerWeight: 4.5, - quality: "Medium-Good", - description: "Good balance of size and quality", - recommended: true - }, - Q4_K_M: { - bitsPerWeight: 4.5, - quality: "Good", - description: "Optimal balance - best for most devices", - recommended: true - }, - Q5_K_S: { - bitsPerWeight: 5.5, - quality: "Good-High", - description: "Higher quality, larger size", - recommended: false - }, - Q5_K_M: { - bitsPerWeight: 5.5, - quality: "High", - description: "Near original quality", - recommended: false - }, - Q6_K: { - bitsPerWeight: 6.5, - quality: "Very High", - description: "Minimal quality loss", - recommended: false - }, - Q8_0: { - bitsPerWeight: 8, - quality: "Excellent", - description: "Best quality, largest size", - recommended: false - } -}; -function extractQuantization(fileName) { - const upper = fileName.toUpperCase(); - for (const quant of Object.keys(QUANTIZATION_INFO)) { - if (upper.includes(quant.replace("_", "")) || upper.includes(quant)) return quant; - } - const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/); - return match ? match[0].toUpperCase() : "Unknown"; -} -function isMMProjFile(fileName) { - const lower = fileName.toLowerCase(); - return lower.includes("mmproj") || lower.includes("projector") || lower.includes("clip") && lower.endsWith(".gguf"); -} -function formatFileSize(bytes) { - if (!bytes) return "Unknown"; - const units = ["B", "KB", "MB", "GB"]; - let n = bytes; - let i = 0; - while (n >= 1024 && i < units.length - 1) { - n /= 1024; - i++; - } - return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; -} - -// src/credibility.ts -var OFFGRID_AUTHORS = ["offgrid-ai", "offgrid"]; -var OFFICIAL_MODEL_AUTHORS = { - "meta-llama": "Meta", - microsoft: "Microsoft", - google: "Google", - Qwen: "Alibaba", - mistralai: "Mistral AI", - HuggingFaceTB: "Hugging Face", - HuggingFaceH4: "Hugging Face", - bigscience: "BigScience", - EleutherAI: "EleutherAI", - tiiuae: "TII UAE", - stabilityai: "Stability AI", - databricks: "Databricks", - THUDM: "Tsinghua University", - "baichuan-inc": "Baichuan", - internlm: "InternLM", - "01-ai": "01.AI", - "deepseek-ai": "DeepSeek", - CohereForAI: "Cohere", - allenai: "Allen AI", - nvidia: "NVIDIA", - apple: "Apple" -}; -var VERIFIED_QUANTIZERS = { - TheBloke: "TheBloke", - bartowski: "bartowski", - QuantFactory: "QuantFactory", - mradermacher: "mradermacher", - "second-state": "Second State", - MaziyarPanahi: "Maziyar Panahi", - Triangle104: "Triangle104", - unsloth: "Unsloth", - "ggml-org": "GGML (HuggingFace)", - ggerganov: "Georgi Gerganov", - // Strong community quantizers (formerly badged separately) — trusted GGUFs. - "lmstudio-community": "Community GGUF", - "lmstudio-ai": "Community GGUF" -}; -var CREDIBILITY_LABELS = { - offgrid: { - label: "Off Grid", - description: "Curated & converted by Off Grid \u2014 verified to run on-device", - color: "#34D399" - }, - official: { label: "Official", description: "From the original model creator", color: "#22C55E" }, - "verified-quantizer": { - label: "Verified", - description: "From a trusted quantization provider", - color: "#A78BFA" - }, - community: { label: "Community", description: "Community contributed model", color: "#64748B" } -}; -function determineCredibility(author) { - if (OFFGRID_AUTHORS.includes(author)) return "offgrid"; - if (author in OFFICIAL_MODEL_AUTHORS) return "official"; - if (author in VERIFIED_QUANTIZERS) return "verified-quantizer"; - return "community"; -} - -// src/filters.ts -var initialFilterState = { - orgs: [], - type: "all", - source: "all", - size: "all", - quant: "all", - sort: "recommended" -}; -var SIZE_OPTIONS = [ - { key: "tiny", label: "Tiny (<2B)", min: 0, max: 2 }, - { key: "small", label: "Small (2-5B)", min: 2, max: 5 }, - { key: "medium", label: "Medium (5-15B)", min: 5, max: 15 }, - { key: "large", label: "Large (15B+)", min: 15, max: Infinity } -]; -var MODEL_TYPE_OPTIONS = [ - { key: "text", label: "Text" }, - { key: "vision", label: "Vision" }, - { key: "code", label: "Code" }, - { key: "image-gen", label: "Image" } -]; -var CREDIBILITY_OPTIONS = [ - { key: "offgrid", label: "Off Grid" }, - { key: "official", label: "Official" }, - { key: "verified-quantizer", label: "Verified" }, - { key: "community", label: "Community" } -]; -var SORT_OPTIONS = [ - { key: "recommended", label: "Recommended" }, - { key: "bestfit", label: "Best fit" }, - { key: "downloads", label: "Downloads" }, - { key: "size", label: "Size" }, - { key: "recency", label: "Recent" } -]; -var PARAM_RE = /\b(\d+(?:\.\d+)?)\s?([BbMm])\b/; -function parseParamCount(nameOrId) { - const m = PARAM_RE.exec(nameOrId); - if (!m) return null; - const n = Number.parseFloat(m[1]); - return /[Mm]/.test(m[2]) ? n / 1e3 : n; -} -function getModelType(name, tags = []) { - const n = name.toLowerCase(); - const t = tags.map((x) => x.toLowerCase()); - if (t.some( - (x) => x.includes("diffusion") || x.includes("text-to-image") || x.includes("image-generation") || x.includes("diffusers") - ) || n.includes("stable-diffusion") || n.includes("sd-") || n.includes("sdxl") || n.includes("flux")) - return "image-gen"; - if (t.some((x) => x.includes("vision") || x.includes("multimodal") || x.includes("image-text")) || n.includes("vision") || n.includes("vlm") || n.includes("-vl") || n.includes("llava")) - return "vision"; - if (t.some((x) => x.includes("code")) || n.includes("code") || n.includes("coder")) return "code"; - return "text"; -} -function bestFitScore(m, ramGb) { - const params = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) ?? 0; - const minRam = m.minRamGb ?? params * 0.75; - const ratio = ramGb ? minRam / ramGb : 0; - const penalty = ratio > 0.75 ? (ratio - 0.75) * 4 : 0; - return Math.abs(ratio - 0.4) + penalty; -} -function hasActiveFilters(state) { - return state.orgs.length > 0 || state.type !== "all" || state.source !== "all" || state.size !== "all" || state.quant !== "all"; -} -function applyFilters(models, state) { - return models.filter((m) => { - if (state.source !== "all" && m.credibility !== state.source) return false; - if (state.type !== "all" && getModelType(m.name, m.tags) !== state.type) return false; - if (state.orgs.length > 0 && !state.orgs.includes(m.org)) return false; - if (state.size !== "all") { - const p = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id); - const opt = SIZE_OPTIONS.find((s) => s.key === state.size); - if (opt && (p == null || p < opt.min || p >= opt.max)) return false; - } - if (state.quant !== "all" && m.files && m.files.length > 0) { - if (!m.files.some((f) => f.quant === state.quant)) return false; - } - return true; - }); -} -function applySort(models, sort, ramGb = 0) { - if (sort === "recommended") return models; - const arr = [...models]; - const p = (m) => m.params ?? parseParamCount(m.name) ?? 0; - switch (sort) { - case "bestfit": - return arr.sort((a, b) => bestFitScore(a, ramGb) - bestFitScore(b, ramGb)); - case "size": - return arr.sort((a, b) => p(a) - p(b)); - case "downloads": - return arr.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)); - case "recency": - return arr.sort((a, b) => (b.lastModified ?? "").localeCompare(a.lastModified ?? "")); - default: - return arr; - } -} -function filterAndSort(models, state, ramGb = 0) { - return applySort(applyFilters(models, state), state.sort, ramGb); -} - -// src/hf.ts -var KIND_PIPELINE = { - text: "text-generation", - vision: "image-text-to-text", - image: "text-to-image", - voice: "text-to-speech", - transcription: "automatic-speech-recognition" -}; -var GGUF_KINDS = /* @__PURE__ */ new Set(["text", "vision", "image"]); -var HF2 = "https://huggingface.co"; -var HF_API = "https://huggingface.co/api"; -var defaultFetch = (url, init) => fetch(url, init); -var isMmproj = (name) => /mmproj|clip/i.test(name); -var baseName = (p) => p.split("/").pop() ?? p; -async function searchHuggingFace(query, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const kind = opts.kind; - const params = new URLSearchParams({ - sort: opts.sort ?? "downloads", - direction: "-1", - // Over-fetch so post-filtering by detected type still leaves a full page. - limit: String((opts.limit ?? 30) * 2) - }); - if (!kind || GGUF_KINDS.has(kind)) params.set("filter", "gguf"); - else if (kind) params.set("pipeline_tag", KIND_PIPELINE[kind]); - if (query) params.set("search", query); - const res = await fetchImpl(`${HF_API}/models?${params.toString()}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) throw new Error(`Hugging Face search failed: HTTP ${res.status}`); - const data = await res.json(); - let out = data.map((m) => { - const id = m.id ?? m.modelId ?? ""; - const org = id.split("/")[0] ?? ""; - return { - id, - name: baseName(id), - org, - downloads: m.downloads, - likes: m.likes, - lastModified: m.lastModified, - credibility: determineCredibility(org) - }; - }); - if (kind === "text") - out = out.filter((m) => { - const t = getModelType(m.name); - return t === "text" || t === "code"; - }); - else if (kind === "vision") out = out.filter((m) => getModelType(m.name) === "vision"); - else if (kind === "image") out = out.filter((m) => getModelType(m.name) === "image-gen"); - return out.slice(0, opts.limit ?? 30); -} -async function getModelFiles(repoId, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) return []; - const data = await res.json(); - const gguf = (data.siblings ?? []).filter((f) => f.rfilename.endsWith(".gguf")); - const mmprojFiles = gguf.filter((f) => isMMProjFile(f.rfilename)); - const weights = gguf.filter((f) => !isMMProjFile(f.rfilename)); - const url = (rf) => `${HF2}/${repoId}/resolve/main/${rf}`; - const matchMmproj = (weightName) => { - if (mmprojFiles.length === 0) return void 0; - const wq = extractQuantization(weightName); - const exact = wq !== "Unknown" ? mmprojFiles.find((f) => extractQuantization(f.rfilename) === wq) : void 0; - const f16 = mmprojFiles.find((f) => { - const l = f.rfilename.toLowerCase(); - return (l.includes("f16") || l.includes("fp16")) && !l.includes("bf16"); - }); - const pick = exact ?? f16 ?? mmprojFiles[0]; - return { fileName: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size }; - }; - return weights.map((f) => { - const quant = extractQuantization(f.rfilename); - const info = QUANTIZATION_INFO[quant]; - return { - fileName: baseName(f.rfilename), - quant, - quality: info?.quality ?? "Unknown", - recommended: info?.recommended ?? false, - sizeBytes: f.size ?? 0, - downloadUrl: url(f.rfilename), - mmproj: matchMmproj(f.rfilename) - }; - }).sort((a, b) => Number(b.recommended) - Number(a.recommended) || a.sizeBytes - b.sizeBytes); -} -async function resolveHuggingFaceModel(repoId, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) return null; - const data = await res.json(); - const siblings = data.siblings ?? []; - const url = (rf) => `${HF2}/${repoId}/resolve/main/${rf}`; - const org = repoId.split("/")[0]; - const ggml = siblings.filter((f) => /ggml.*\.bin$/i.test(f.rfilename)); - if (ggml.length > 0) { - const pick = ggml.find((f) => /ggml-base\.bin$/i.test(f.rfilename)) ?? [...ggml].sort((a, b) => (a.size ?? 0) - (b.size ?? 0))[0]; - return { - id: repoId, - name: baseName(repoId), - kind: "transcription", - org, - files: [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: "primary" - } - ] - }; - } - const onnx = siblings.filter((f) => /\.onnx$/i.test(f.rfilename)); - if (onnx.length > 0 && siblings.every((f) => !f.rfilename.endsWith(".gguf"))) { - const pick = onnx.find((f) => /quant/i.test(f.rfilename)) ?? onnx[0]; - const files2 = [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: "primary" - } - ]; - const cfg = siblings.find((f) => f.rfilename === `${pick.rfilename}.json`); - if (cfg) - files2.push({ - name: baseName(cfg.rfilename), - url: url(cfg.rfilename), - sizeBytes: cfg.size, - role: "aux" - }); - return { id: repoId, name: baseName(repoId), kind: "voice", org, files: files2 }; - } - const gguf = siblings.filter((f) => f.rfilename.endsWith(".gguf")); - if (gguf.length === 0) return null; - const weights = gguf.filter((f) => !isMmproj(f.rfilename)); - const mmprojFiles = gguf.filter((f) => isMmproj(f.rfilename)); - const primary = weights.find((f) => /q4_k_m/i.test(f.rfilename)) ?? weights[0] ?? gguf[0]; - if (!primary) return null; - const files = [ - { - name: baseName(primary.rfilename), - url: url(primary.rfilename), - sizeBytes: primary.size, - role: "primary" - } - ]; - if (mmprojFiles[0]) { - files.push({ - name: baseName(mmprojFiles[0].rfilename), - url: url(mmprojFiles[0].rfilename), - sizeBytes: mmprojFiles[0].size, - role: "mmproj" - }); - } - return { - id: repoId, - name: baseName(repoId), - // Same data-derived rule as the curated catalog: a projector ⇒ vision. - kind: deriveKind(files, opts.kind ?? "text"), - org, - files - }; -} - -// src/providers.ts -var defaultFetch2 = (url, init) => fetch(url, init); -function authHeaders(apiKey) { - return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; -} -async function* lines(body) { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const parts = buf.split("\n"); - buf = parts.pop() ?? ""; - for (const line of parts) yield line; - } - if (buf.trim()) yield buf; -} -function openAICompatibleProvider(cfg) { - const f = cfg.fetchImpl ?? defaultFetch2; - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/models`, { - headers: { Accept: "application/json", ...authHeaders(cfg.apiKey) } - }); - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`); - const data = await res.json(); - return (data.data ?? []).map((m) => ({ id: m.id, name: m.id })); - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json", ...authHeaders(cfg.apiKey) }, - body: JSON.stringify({ - model: opts?.model, - messages, - stream: true, - temperature: opts?.temperature, - max_tokens: opts?.maxTokens - }), - signal: opts?.signal - }); - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`); - for await (const line of lines(res.body)) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") return; - try { - const j = JSON.parse(data); - const c = j.choices?.[0]?.delta?.content; - if (c) yield c; - } catch { - } - } - } - }; -} -function ollamaProvider(cfg) { - const f = cfg.fetchImpl ?? defaultFetch2; - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/api/tags`, { headers: { Accept: "application/json" } }); - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`); - const data = await res.json(); - return (data.models ?? []).map((m) => ({ id: m.name, name: m.name })); - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: opts?.model, messages, stream: true }), - signal: opts?.signal - }); - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`); - for await (const line of lines(res.body)) { - const t = line.trim(); - if (!t) continue; - try { - const j = JSON.parse(t); - if (j.message?.content) yield j.message.content; - if (j.done) return; - } catch { - } - } - } - }; -} -function createProvider(server, fetchImpl) { - if (server.kind === "ollama") { - return ollamaProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - fetchImpl - }); - } - return openAICompatibleProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - apiKey: server.apiKey, - fetchImpl - }); -} -var ProviderRegistry = class { - providers = /* @__PURE__ */ new Map(); - activeId = null; - register(provider) { - this.providers.set(provider.id, provider); - if (!this.activeId) this.activeId = provider.id; - } - unregister(id) { - this.providers.delete(id); - if (this.activeId === id) this.activeId = this.providers.keys().next().value ?? null; - } - list() { - return [...this.providers.values()]; - } - setActive(id) { - if (this.providers.has(id)) this.activeId = id; - } - active() { - return this.activeId ? this.providers.get(this.activeId) ?? null : null; - } -}; - -// src/imagegen.ts -function supportsMode(provider, mode) { - return provider.modes.includes(mode); -} -function validateImageGenRequest(provider, req) { - if (!supportsMode(provider, req.mode)) return `provider does not support ${req.mode}`; - if (req.mode === "img2img" && !req.initImage) return "img2img requires an initImage"; - if (!req.prompt.trim()) return "prompt is required"; - return null; -} - -// src/recommend-image.ts -var LIGHT_MODEL_RAM_CEILING_GB = 16; -var hasLightTag = (m) => (m.tags ?? []).some((t) => /^light$/i.test(t)); -var isVersatile = (m) => (m.tags ?? []).some((t) => /^versatile$/i.test(t)); -var pickVersatileFirst = (candidates) => candidates.find(isVersatile) ?? candidates[0]; -var familyKey = (m) => m.id.replace(/-Q\d[\w]*$/i, ""); -function recommendedImageModelId(models, ramGb) { - if (!ramGb || !Number.isFinite(ramGb)) return null; - const images = models.filter((m) => m.kind === "image"); - if (!images.length) return null; - const light = images.filter(hasLightTag); - const lightFamilies = new Set(light.map(familyKey)); - const fullOfLightFamily = images.filter((m) => !hasLightTag(m) && lightFamilies.has(familyKey(m))); - if (ramGb <= LIGHT_MODEL_RAM_CEILING_GB) { - return (pickVersatileFirst(light) ?? images[0]).id; - } - return (pickVersatileFirst(fullOfLightFamily) ?? images.find((m) => !hasLightTag(m)) ?? images[0]).id; -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - CATALOG, - CREDIBILITY_LABELS, - CREDIBILITY_OPTIONS, - LIGHT_MODEL_RAM_CEILING_GB, - MODEL_KINDS, - MODEL_TYPE_OPTIONS, - ModelDownloader, - OFFICIAL_MODEL_AUTHORS, - ProviderRegistry, - QUANTIZATION_INFO, - RECOMMENDATION_TIERS, - SIZE_OPTIONS, - SORT_OPTIONS, - VERIFIED_QUANTIZERS, - applyFilters, - applySort, - bestFitScore, - createProvider, - deriveKind, - determineCredibility, - extractQuantization, - filterAndSort, - formatFileSize, - getModelFiles, - getModelType, - hasActiveFilters, - hasVisionProjector, - initialFilterState, - isMMProjFile, - modelsByKind, - ollamaProvider, - openAICompatibleProvider, - parseParamCount, - recommendForRam, - recommendedImageModelId, - resolveHuggingFaceModel, - searchHuggingFace, - supportsMode, - validateImageGenRequest -}); diff --git a/packages/models/dist/index.mjs b/packages/models/dist/index.mjs deleted file mode 100644 index babeb3bc..00000000 --- a/packages/models/dist/index.mjs +++ /dev/null @@ -1,1672 +0,0 @@ -// src/capabilities.ts -function hasVisionProjector(files) { - return files.some((f) => f.role === "mmproj"); -} -function deriveKind(files, declared) { - if ((declared === "text" || declared === "vision") && hasVisionProjector(files)) { - return "vision"; - } - return declared; -} - -// src/catalog.ts -var HF = "https://huggingface.co"; -var resolve = (repo, file) => `${HF}/${repo}/resolve/main/${file}`; -var RECOMMENDATION_TIERS = [ - { minRamGb: 3, maxRamGb: 4, maxParams: 1.5, quantization: "Q4_K_M" }, - { minRamGb: 4, maxRamGb: 6, maxParams: 3, quantization: "Q4_K_M" }, - { minRamGb: 6, maxRamGb: 8, maxParams: 4, quantization: "Q4_K_M" }, - { minRamGb: 8, maxRamGb: 12, maxParams: 8, quantization: "Q4_K_M" }, - { minRamGb: 12, maxRamGb: 16, maxParams: 13, quantization: "Q4_K_M" }, - { minRamGb: 16, maxRamGb: Infinity, maxParams: 30, quantization: "Q4_K_M" } -]; -function recommendForRam(ramGb) { - return RECOMMENDATION_TIERS.find((t) => ramGb >= t.minRamGb && ramGb < t.maxRamGb) ?? RECOMMENDATION_TIERS[RECOMMENDATION_TIERS.length - 1]; -} -var RAW_CATALOG = [ - // --- text (SLMs) — post-Jan-2026 only; the latest small-model challengers, - // quantized for desktop. Dates are the source repo's HF createdAt. --- - { - id: "unsloth/Qwen3.5-0.8B-GGUF", - name: "Qwen 3.5 0.8B", - kind: "text", - org: "Qwen", - description: "Tiny, very fast \u2014 runs on almost anything", - params: 0.8, - minRamGb: 3, - quant: "Q4_K_M", - releaseDate: "2026-03-01", - files: [ - { - name: "Qwen3.5-0.8B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-0.8B-GGUF", "Qwen3.5-0.8B-Q4_K_M.gguf"), - sizeBytes: 53e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-0.8B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-0.8B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 207346528, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-2B-GGUF", - name: "Qwen 3.5 2B", - kind: "text", - org: "Qwen", - description: "Hybrid thinking + chat, long context", - params: 2, - minRamGb: 4, - quant: "Q4_K_M", - releaseDate: "2026-02-28", - files: [ - { - name: "Qwen3.5-2B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-2B-GGUF", "Qwen3.5-2B-Q4_K_M.gguf"), - sizeBytes: 128e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-2B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-2B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 671372992, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-4B-GGUF", - name: "Qwen 3.5 4B", - kind: "text", - org: "Qwen", - description: "Strong small general model \u2014 hybrid thinking + chat, long context", - params: 4, - minRamGb: 6, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-03-02", - files: [ - { - name: "Qwen3.5-4B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-Q4_K_M.gguf"), - sizeBytes: 274e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-4B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-4B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 675569344, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-9B-GGUF", - name: "Qwen 3.5 9B", - kind: "text", - org: "Qwen", - description: "Higher-quality general reasoning; needs a bit more RAM", - params: 9, - minRamGb: 8, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-02-28", - files: [ - { - name: "Qwen3.5-9B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"), - sizeBytes: 568e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-9B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-9B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 921705024, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3.5-27B-GGUF", - name: "Qwen 3.5 27B", - kind: "text", - org: "Qwen", - description: "Top Qwen3.5 quality \u2014 large; 24GB+ machines", - params: 27, - minRamGb: 24, - quant: "Q4_K_M", - releaseDate: "2026-02-24", - files: [ - { - name: "Qwen3.5-27B-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3.5-27B-GGUF", "Qwen3.5-27B-Q4_K_M.gguf"), - sizeBytes: 1674e7, - role: "primary" - }, - { - name: "mmproj-Qwen3.5-27B-BF16.gguf", - url: resolve("unsloth/Qwen3.5-27B-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 931145984, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-E2B-it-GGUF", - name: "Gemma 4 E2B", - kind: "vision", - org: "google", - description: "Google\u2019s small efficient model \u2014 fast, capable, reads images", - params: 2, - minRamGb: 5, - quant: "Q4_K_M", - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-E2B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-E2B-it-GGUF", "gemma-4-E2B-it-Q4_K_M.gguf"), - sizeBytes: 311e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-E2B-it-F16.gguf", - url: resolve("unsloth/gemma-4-E2B-it-GGUF", "mmproj-F16.gguf"), - sizeBytes: 985654080, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-12b-it-GGUF", - name: "Gemma 4 12B", - kind: "text", - org: "google", - description: "Strong mid-size Gemma 4 \u2014 great general quality", - params: 12, - minRamGb: 12, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-05-29", - files: [ - { - name: "gemma-4-12b-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-12b-it-GGUF", "gemma-4-12b-it-Q4_K_M.gguf"), - sizeBytes: 712e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-12b-it-BF16.gguf", - url: resolve("unsloth/gemma-4-12b-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 175115840, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-26B-A4B-it-GGUF", - name: "Gemma 4 26B A4B (MoE)", - kind: "text", - org: "google", - description: "MoE \u2014 26B quality at ~4B active speed; needs 20GB+", - params: 26, - minRamGb: 20, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-26B-A4B-it-GGUF", "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"), - sizeBytes: 1695e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-26B-A4B-it-BF16.gguf", - url: resolve("unsloth/gemma-4-26B-A4B-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 1194828256, - role: "mmproj" - } - ] - }, - { - id: "unsloth/gemma-4-31B-it-GGUF", - name: "Gemma 4 31B", - kind: "text", - org: "google", - description: "Largest Gemma 4 dense \u2014 top quality; 24GB+ machines", - params: 31, - minRamGb: 24, - quant: "Q4_K_M", - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-31B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-31B-it-GGUF", "gemma-4-31B-it-Q4_K_M.gguf"), - sizeBytes: 1832e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-31B-it-BF16.gguf", - url: resolve("unsloth/gemma-4-31B-it-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 1200726496, - role: "mmproj" - } - ] - }, - // --- vision (multimodal LLM) --- - { - id: "unsloth/gemma-4-E4B-it-GGUF", - name: "Gemma 4 E4B", - kind: "vision", - org: "google", - description: "Thinking + vision, MoE", - params: 4, - minRamGb: 6, - quant: "Q4_K_M", - tags: ["Challenger"], - releaseDate: "2026-04-01", - files: [ - { - name: "gemma-4-E4B-it-Q4_K_M.gguf", - url: resolve("unsloth/gemma-4-E4B-it-GGUF", "gemma-4-E4B-it-Q4_K_M.gguf"), - sizeBytes: 498e7, - role: "primary" - }, - { - name: "mmproj-gemma-4-E4B-it-F16.gguf", - url: resolve("unsloth/gemma-4-E4B-it-GGUF", "mmproj-F16.gguf"), - sizeBytes: 99e7, - role: "mmproj" - } - ] - }, - { - id: "ggml-org/SmolVLM2-2.2B-Instruct-GGUF", - name: "SmolVLM2 2.2B", - kind: "vision", - org: "HuggingFaceTB", - description: "Compact, fast vision-language model \u2014 great on modest RAM", - params: 2.2, - minRamGb: 6, - quant: "Q4_K_M", - releaseDate: "2025-04-21", - files: [ - { - name: "SmolVLM2-2.2B-Instruct-Q4_K_M.gguf", - url: resolve("ggml-org/SmolVLM2-2.2B-Instruct-GGUF", "SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"), - sizeBytes: 111e7, - role: "primary" - }, - { - name: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf", - url: resolve( - "ggml-org/SmolVLM2-2.2B-Instruct-GGUF", - "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf" - ), - sizeBytes: 87e7, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3-VL-2B-Instruct-GGUF", - name: "Qwen3-VL 2B", - kind: "vision", - org: "Qwen", - description: "Small vision-language model \u2014 fast and capable", - params: 2, - minRamGb: 6, - quant: "Q4_K_M", - releaseDate: "2025-10-30", - files: [ - { - name: "Qwen3-VL-2B-Instruct-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-VL-2B-Instruct-GGUF", "Qwen3-VL-2B-Instruct-Q4_K_M.gguf"), - sizeBytes: 111e7, - role: "primary" - }, - { - name: "mmproj-Qwen3-VL-2B-Instruct-F16.gguf", - url: resolve("unsloth/Qwen3-VL-2B-Instruct-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 82e7, - role: "mmproj" - } - ] - }, - { - id: "unsloth/Qwen3-VL-8B-Instruct-GGUF", - name: "Qwen3-VL 8B", - kind: "vision", - org: "Qwen", - description: "Stronger VLM \u2014 better detail + OCR; needs more RAM", - params: 8, - minRamGb: 10, - quant: "Q4_K_M", - releaseDate: "2025-10-30", - files: [ - { - name: "Qwen3-VL-8B-Instruct-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL-8B-Instruct-Q4_K_M.gguf"), - sizeBytes: 503e7, - role: "primary" - }, - { - name: "mmproj-Qwen3-VL-8B-Instruct-F16.gguf", - url: resolve("unsloth/Qwen3-VL-8B-Instruct-GGUF", "mmproj-BF16.gguf"), - sizeBytes: 116e7, - role: "mmproj" - } - ] - }, - // --- image generation — 2026 / fast few-step models only (open weight) --- - { - id: "leejet/Z-Image-Turbo-GGUF", - name: "Z-Image Turbo (2026)", - kind: "image", - // NOT tagged 'Fast': despite the "Turbo" name this is a FLUX-class diffusion - // transformer (DiT + Qwen3-4B text encoder + FLUX VAE) — heavy and slow on - // Apple Silicon via ggml, not a few-step SDXL distill. 'Fast' is reserved for - // models verified fast on-device (dreamshaper-turbo, realvis-lightning). - tags: ["Recommended", "2026", "Top quality"], - org: "Alibaba Tongyi", - description: "Flagship 2026 model \u2014 1024px in ~8 steps, top quality-per-byte, strong bilingual text. Apache-2.0. (diffusion + Qwen3 encoder + VAE)", - minRamGb: 12, - imageModes: ["txt2img"], - files: [ - { - name: "z_image_turbo-Q4_K.gguf", - url: resolve("leejet/Z-Image-Turbo-GGUF", "z_image_turbo-Q4_K.gguf"), - role: "primary", - sizeBytes: 386e7 - }, - { - name: "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", - url: resolve("unsloth/Qwen3-4B-Instruct-2507-GGUF", "Qwen3-4B-Instruct-2507-Q4_K_M.gguf"), - role: "aux", - sizeBytes: 25e8 - }, - { - name: "ae.safetensors", - url: resolve("second-state/FLUX.1-schnell-GGUF", "ae.safetensors"), - role: "aux", - sizeBytes: 34e7 - } - ] - }, - // NOTE: MLX/mflux image models are PARKED (2026-06-23) — the only non-gated - // on-device MLX LoRA options are too large to ship (Z-Image ~13GB 8-bit / ~33GB - // bf16; FLUX.1-schnell 4-bit ~10GB). No MLX catalog entry is exposed. The - // dormant runtime plumbing lives in src/main/mflux.ts (re-enable by repopulating - // MFLUX_MODELS + restoring an entry here with runtime:'mflux'). - { - id: "mzwing/SDXL-Lightning-GGUF", - name: "SDXL Lightning (4-step)", - kind: "image", - tags: ["Recommended", "Fast"], - org: "ByteDance", - description: "Near-SDXL quality at 1024px in 4 steps (~7\xD7 faster). ~4GB model. Best balance \u2014 recommended.", - minRamGb: 8, - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "sdxl_lightning_4step.q8_0.gguf", - url: resolve("mzwing/SDXL-Lightning-GGUF", "sdxl_lightning_4step.q8_0.gguf"), - role: "primary", - sizeBytes: 4099e6 - } - ] - }, - { - id: "OlegSkutte/sdxl-turbo-GGUF", - name: "SDXL Turbo (fast drafts)", - kind: "image", - tags: ["Fastest", "Drafts"], - org: "Stability AI", - description: "Distilled SDXL \u2014 1-4 steps, ~10s drafts at 512px. Fastest option; lower fidelity.", - minRamGb: 8, - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "sd_xl_turbo_1.0.q8_0.gguf", - url: resolve("OlegSkutte/sdxl-turbo-GGUF", "sd_xl_turbo_1.0.q8_0.gguf"), - role: "primary", - sizeBytes: 41e8 - } - ] - }, - // SDXL finetunes — Off Grid GGUF builds (q8). The community GGUF quants of these - // are mis-exported and won't load in sd.cpp, so we converted the official - // OpenRAIL checkpoints ourselves (offgrid-ai HF org) → correct, ~4GB, on-device. - { - id: "offgrid-ai/realvisxl-v5.0-GGUF", - name: "RealVisXL v5.0 (photoreal)", - kind: "image", - tags: ["High quality", "Photoreal"], - org: "RealVis", - description: "Top photorealism SDXL \u2014 Off Grid GGUF build of SG161222/RealVisXL_V5.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-08-05", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-Q8_0.gguf", - url: resolve("offgrid-ai/realvisxl-v5.0-GGUF", "realvisxl-v5.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, runs on a 16GB Mac. Tagged 'Light' - // so the RAM-aware default + "Recommended" badge pick it on <= 16GB machines. - id: "offgrid-ai/realvisxl-v5.0-GGUF-Q4", - name: "RealVisXL v5.0 (Light)", - kind: "image", - tags: ["Photoreal", "Light"], - org: "RealVis", - description: "Top photorealism SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-08-05", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-Q4_K.gguf", - url: resolve("offgrid-ai/realvisxl-v5.0-GGUF", "realvisxl-v5.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - name: "RealVisXL v5.0 Lightning (photoreal)", - kind: "image", - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ["Photoreal"], - org: "RealVis", - description: "Photoreal SDXL, few-step (fast) \u2014 Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-09-02", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-lightning-Q8_0.gguf", - url: resolve( - "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - "realvisxl-v5.0-lightning-Q8_0.gguf" - ), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — few-step photoreal, ~35% less memory, 16GB-friendly. - id: "offgrid-ai/realvisxl-v5.0-lightning-GGUF-Q4", - name: "RealVisXL v5.0 Lightning (Light)", - kind: "image", - tags: ["Fast", "Photoreal", "Light"], - org: "RealVis", - description: "Photoreal SDXL, few-step (fast). Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-09-02", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "realvisxl-v5.0-lightning-Q4_K.gguf", - url: resolve( - "offgrid-ai/realvisxl-v5.0-lightning-GGUF", - "realvisxl-v5.0-lightning-Q4_K.gguf" - ), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - name: "DreamShaper XL v2 Turbo (versatile)", - kind: "image", - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ["Versatile"], - org: "Lykon", - description: "The all-rounder \u2014 photoreal, art, fantasy, 3D. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo. Full Q8 quant (best quality); best on 24GB+ RAM.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-02-07", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "dreamshaper-xl-v2-turbo-Q8_0.gguf", - url: resolve( - "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - "dreamshaper-xl-v2-turbo-Q8_0.gguf" - ), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Lighter Q4_K quant of the same distilled turbo model — ~35% less memory - // (~3.08GB peak vs ~4.7GB), so it runs on a 16GB Mac without pegging unified - // memory. Same repo, distinct id + filename so download/active-tracking treat - // it as a separate installable model. Tagged 'Light' → the RAM-aware default + - // "Recommended" badge pick it on machines with <= 16GB RAM. - id: "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF-Q4", - name: "DreamShaper XL v2 Turbo (Light)", - kind: "image", - tags: ["Versatile", "Fast", "Light"], - org: "Lykon", - description: "The all-rounder \u2014 photoreal, art, fantasy, 3D. Q4 quant: ~35% less memory than the full model, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-02-07", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "dreamshaper-xl-v2-turbo-Q4_K.gguf", - url: resolve( - "offgrid-ai/dreamshaper-xl-v2-turbo-GGUF", - "dreamshaper-xl-v2-turbo-Q4_K.gguf" - ), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/juggernaut-xl-v9-GGUF", - name: "Juggernaut XL v9 (photoreal)", - kind: "image", - tags: ["High quality", "Photoreal"], - org: "RunDiffusion", - description: "Versatile photoreal SDXL \u2014 cinematic, portraits, landscapes. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-02-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "juggernaut-xl-v9-Q8_0.gguf", - url: resolve("offgrid-ai/juggernaut-xl-v9-GGUF", "juggernaut-xl-v9-Q8_0.gguf"), - role: "primary", - sizeBytes: 435e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/juggernaut-xl-v9-GGUF-Q4", - name: "Juggernaut XL v9 (Light)", - kind: "image", - tags: ["Photoreal", "Light"], - org: "RunDiffusion", - description: "Versatile photoreal SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-02-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "juggernaut-xl-v9-Q4_K.gguf", - url: resolve("offgrid-ai/juggernaut-xl-v9-GGUF", "juggernaut-xl-v9-Q4_K.gguf"), - role: "primary", - sizeBytes: 29e8 - } - ] - }, - { - id: "offgrid-ai/animagine-xl-4.0-GGUF", - name: "Animagine XL 4.0 (anime)", - kind: "image", - tags: ["High quality", "Anime"], - org: "Cagliostro", - description: "Leading anime SDXL \u2014 strong character knowledge. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2025-01-10", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "animagine-xl-4.0-Q8_0.gguf", - url: resolve("offgrid-ai/animagine-xl-4.0-GGUF", "animagine-xl-4.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/animagine-xl-4.0-GGUF-Q4", - name: "Animagine XL 4.0 (Light)", - kind: "image", - tags: ["Anime", "Light"], - org: "Cagliostro", - description: "Leading anime SDXL \u2014 strong character knowledge. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2025-01-10", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "animagine-xl-4.0-Q4_K.gguf", - url: resolve("offgrid-ai/animagine-xl-4.0-GGUF", "animagine-xl-4.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/illustrious-xl-v2.0-GGUF", - name: "Illustrious XL v2.0 (anime)", - kind: "image", - tags: ["High quality", "Anime"], - org: "OnomaAI", - description: "Top anime / illustration SDXL base. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2025-04-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "illustrious-xl-v2.0-Q8_0.gguf", - url: resolve("offgrid-ai/illustrious-xl-v2.0-GGUF", "illustrious-xl-v2.0-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/illustrious-xl-v2.0-GGUF-Q4", - name: "Illustrious XL v2.0 (Light)", - kind: "image", - tags: ["Anime", "Light"], - org: "OnomaAI", - description: "Top anime / illustration SDXL base. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2025-04-18", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "illustrious-xl-v2.0-Q4_K.gguf", - url: resolve("offgrid-ai/illustrious-xl-v2.0-GGUF", "illustrious-xl-v2.0-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - { - id: "offgrid-ai/pony-diffusion-v6-xl-GGUF", - name: "Pony Diffusion V6 XL (stylized)", - kind: "image", - tags: ["High quality", "Stylized"], - org: "PurpleSmartAI", - description: "Dominant SDXL for stylized characters & illustration; highly promptable. Off Grid GGUF build of Pony Diffusion V6 XL.", - minRamGb: 8, - quant: "Q8_0", - releaseDate: "2024-05-25", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "pony-diffusion-v6-xl-Q8_0.gguf", - url: resolve("offgrid-ai/pony-diffusion-v6-xl-GGUF", "pony-diffusion-v6-xl-Q8_0.gguf"), - role: "primary", - sizeBytes: 418e7 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: "offgrid-ai/pony-diffusion-v6-xl-GGUF-Q4", - name: "Pony Diffusion V6 XL (Light)", - kind: "image", - tags: ["Stylized", "Light"], - org: "PurpleSmartAI", - description: "Dominant SDXL for stylized characters & illustration. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Pony Diffusion V6 XL.", - minRamGb: 8, - quant: "Q4_K", - releaseDate: "2024-05-25", - imageModes: ["txt2img", "img2img"], - files: [ - { - name: "pony-diffusion-v6-xl-Q4_K.gguf", - url: resolve("offgrid-ai/pony-diffusion-v6-xl-GGUF", "pony-diffusion-v6-xl-Q4_K.gguf"), - role: "primary", - sizeBytes: 28e8 - } - ] - }, - // --- voice (TTS); open models, ONNX runtime (no Python) --- - { - id: "onnx-community/Kokoro-82M-v1.0-ONNX", - name: "Kokoro TTS 82M", - kind: "voice", - org: "hexgrad", - description: "Lightweight, natural text-to-speech (ONNX); great default", - minRamGb: 3, - files: [ - { - name: "kokoro-82m-v1.0.onnx", - url: resolve("onnx-community/Kokoro-82M-v1.0-ONNX", "onnx/model_quantized.onnx"), - role: "primary", - sizeBytes: 92361116 - } - ] - }, - { - id: "rhasspy/piper-voices/en_US-lessac-medium", - name: "Piper - Lessac (English)", - kind: "voice", - org: "rhasspy", - description: "Fast multi-voice text-to-speech (ONNX); many languages available", - minRamGb: 2, - files: [ - { - name: "en_US-lessac-medium.onnx", - url: resolve("rhasspy/piper-voices", "en/en_US/lessac/medium/en_US-lessac-medium.onnx"), - role: "primary", - sizeBytes: 63201294 - }, - { - name: "en_US-lessac-medium.onnx.json", - url: resolve( - "rhasspy/piper-voices", - "en/en_US/lessac/medium/en_US-lessac-medium.onnx.json" - ), - role: "aux" - } - ] - }, - // --- transcription (STT / whisper); all from ggerganov/whisper.cpp (ggml .bin) --- - { - id: "ggerganov/whisper.cpp/tiny", - name: "Whisper Tiny", - kind: "transcription", - org: "ggerganov", - description: "Fastest, smallest \u2014 lowest accuracy", - minRamGb: 2, - files: [ - { - name: "ggml-tiny.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-tiny.bin"), - role: "primary", - sizeBytes: 777e5 - } - ] - }, - { - id: "ggerganov/whisper.cpp/base", - name: "Whisper Base", - kind: "transcription", - org: "ggerganov", - description: "Offline speech-to-text (base) \u2014 good speed/quality default", - minRamGb: 3, - files: [ - { - name: "ggml-base.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-base.bin"), - role: "primary", - sizeBytes: 147951e3 - } - ] - }, - { - id: "ggerganov/whisper.cpp/small", - name: "Whisper Small", - kind: "transcription", - org: "ggerganov", - description: "Offline speech-to-text (higher accuracy)", - minRamGb: 4, - files: [ - { - name: "ggml-small.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-small.bin"), - role: "primary", - sizeBytes: 487601e3 - } - ] - }, - { - id: "ggerganov/whisper.cpp/medium", - name: "Whisper Medium", - kind: "transcription", - org: "ggerganov", - description: "High accuracy; slower", - minRamGb: 6, - files: [ - { - name: "ggml-medium.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-medium.bin"), - role: "primary", - sizeBytes: 1533e6 - } - ] - }, - { - id: "ggerganov/whisper.cpp/large-v3-turbo", - name: "Whisper Large v3 Turbo", - kind: "transcription", - org: "ggerganov", - description: "Near-large accuracy, much faster \u2014 recommended", - minRamGb: 6, - files: [ - { - name: "ggml-large-v3-turbo.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-large-v3-turbo.bin"), - role: "primary", - sizeBytes: 1624e6 - } - ] - }, - { - id: "ggerganov/whisper.cpp/large-v3", - name: "Whisper Large v3", - kind: "transcription", - org: "ggerganov", - description: "Highest accuracy (large); needs more RAM", - minRamGb: 8, - files: [ - { - name: "ggml-large-v3.bin", - url: resolve("ggerganov/whisper.cpp", "ggml-large-v3.bin"), - role: "primary", - sizeBytes: 3095e6 - } - ] - }, - // --- transcription (Parakeet, NVIDIA NeMo) — sherpa-onnx offline transducer (ONNX). - // A model is 4 files (encoder/decoder/joiner/tokens); on-disk names are slug-prefixed - // so multiple Parakeet models coexist in the flat models dir without colliding. Higher - // accuracy than whisper; served by the bundled sherpa-onnx CLI (engine: 'parakeet'). --- - { - id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - name: "Parakeet TDT 0.6B v2", - kind: "transcription", - engine: "parakeet", - org: "nvidia", - description: "High-accuracy English STT (int8) - tops the open ASR leaderboard", - minRamGb: 4, - tags: ["Accurate", "English"], - files: [ - { - name: "parakeet-v2.encoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "encoder.int8.onnx"), - role: "primary", - sizeBytes: 652e6 - }, - { - name: "parakeet-v2.decoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "decoder.int8.onnx"), - role: "aux", - sizeBytes: 726e4 - }, - { - name: "parakeet-v2.joiner.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "joiner.int8.onnx"), - role: "aux", - sizeBytes: 174e4 - }, - { - name: "parakeet-v2.tokens.txt", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "tokens.txt"), - role: "tokenizer", - sizeBytes: 9600 - } - ] - }, - { - id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - name: "Parakeet TDT 0.6B v3", - kind: "transcription", - engine: "parakeet", - org: "nvidia", - description: "Multilingual STT (int8) - 25 European languages", - minRamGb: 4, - isNew: true, - tags: ["Accurate", "Multilingual"], - files: [ - { - name: "parakeet-v3.encoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "encoder.int8.onnx"), - role: "primary", - sizeBytes: 652e6 - }, - { - name: "parakeet-v3.decoder.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "decoder.int8.onnx"), - role: "aux", - sizeBytes: 726e4 - }, - { - name: "parakeet-v3.joiner.int8.onnx", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "joiner.int8.onnx"), - role: "aux", - sizeBytes: 174e4 - }, - { - name: "parakeet-v3.tokens.txt", - url: resolve("csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "tokens.txt"), - role: "tokenizer", - sizeBytes: 9600 - } - ] - } -]; -var CATALOG = RAW_CATALOG.map((e) => ({ - ...e, - kind: deriveKind(e.files, e.kind) -})); -function modelsByKind(kind) { - return CATALOG.filter((m) => m.kind === kind); -} -var MODEL_KINDS = ["text", "vision", "image", "voice", "transcription"]; - -// src/download.ts -var ModelDownloader = class { - constructor(bridge, store) { - this.bridge = bridge; - this.store = store; - } - bridge; - store; - aborts = /* @__PURE__ */ new Map(); - listeners = /* @__PURE__ */ new Set(); - onProgress(cb) { - this.listeners.add(cb); - return () => this.listeners.delete(cb); - } - isInstalled(modelId) { - return this.store.isInstalled(modelId); - } - cancel(modelId) { - this.aborts.get(modelId)?.abort(); - } - emit(p) { - for (const l of this.listeners) l(p); - } - async download(entry) { - const controller = new AbortController(); - this.aborts.set(entry.id, controller); - const totalKnown = entry.files.reduce((n, f) => n + (f.sizeBytes ?? 0), 0); - let basePrev = 0; - try { - for (const file of entry.files) { - const dest = this.bridge.pathFor(file.name); - if (await this.bridge.exists(dest, file.sizeBytes)) { - basePrev += file.sizeBytes ?? 0; - continue; - } - await this.bridge.download(file.url, dest, { - signal: controller.signal, - onProgress: (written, total) => { - const totalBytes = totalKnown || basePrev + total; - const bytesDownloaded = basePrev + written; - this.emit({ - modelId: entry.id, - status: "downloading", - bytesDownloaded, - totalBytes, - progress: totalBytes ? Math.min(1, bytesDownloaded / totalBytes) : 0, - currentFile: file.name - }); - } - }); - basePrev += file.sizeBytes ?? 0; - } - this.store.markInstalled(entry); - this.emit({ - modelId: entry.id, - status: "completed", - progress: 1, - bytesDownloaded: totalKnown, - totalBytes: totalKnown - }); - return true; - } catch (err) { - const aborted = controller.signal.aborted; - this.emit({ - modelId: entry.id, - status: aborted ? "paused" : "failed", - progress: 0, - bytesDownloaded: 0, - totalBytes: totalKnown, - error: aborted ? void 0 : err instanceof Error ? err.message : String(err) - }); - return false; - } finally { - this.aborts.delete(entry.id); - } - } -}; - -// src/quant.ts -var QUANTIZATION_INFO = { - Q2_K: { - bitsPerWeight: 2.625, - quality: "Low", - description: "Extreme compression, noticeable quality loss", - recommended: false - }, - Q3_K_S: { - bitsPerWeight: 3.4375, - quality: "Low-Medium", - description: "High compression, some quality loss", - recommended: false - }, - Q3_K_M: { - bitsPerWeight: 3.4375, - quality: "Medium", - description: "Good compression with acceptable quality", - recommended: false - }, - Q4_0: { - bitsPerWeight: 4, - quality: "Medium", - description: "Basic 4-bit quantization", - recommended: false - }, - Q4_K_S: { - bitsPerWeight: 4.5, - quality: "Medium-Good", - description: "Good balance of size and quality", - recommended: true - }, - Q4_K_M: { - bitsPerWeight: 4.5, - quality: "Good", - description: "Optimal balance - best for most devices", - recommended: true - }, - Q5_K_S: { - bitsPerWeight: 5.5, - quality: "Good-High", - description: "Higher quality, larger size", - recommended: false - }, - Q5_K_M: { - bitsPerWeight: 5.5, - quality: "High", - description: "Near original quality", - recommended: false - }, - Q6_K: { - bitsPerWeight: 6.5, - quality: "Very High", - description: "Minimal quality loss", - recommended: false - }, - Q8_0: { - bitsPerWeight: 8, - quality: "Excellent", - description: "Best quality, largest size", - recommended: false - } -}; -function extractQuantization(fileName) { - const upper = fileName.toUpperCase(); - for (const quant of Object.keys(QUANTIZATION_INFO)) { - if (upper.includes(quant.replace("_", "")) || upper.includes(quant)) return quant; - } - const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/); - return match ? match[0].toUpperCase() : "Unknown"; -} -function isMMProjFile(fileName) { - const lower = fileName.toLowerCase(); - return lower.includes("mmproj") || lower.includes("projector") || lower.includes("clip") && lower.endsWith(".gguf"); -} -function formatFileSize(bytes) { - if (!bytes) return "Unknown"; - const units = ["B", "KB", "MB", "GB"]; - let n = bytes; - let i = 0; - while (n >= 1024 && i < units.length - 1) { - n /= 1024; - i++; - } - return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; -} - -// src/credibility.ts -var OFFGRID_AUTHORS = ["offgrid-ai", "offgrid"]; -var OFFICIAL_MODEL_AUTHORS = { - "meta-llama": "Meta", - microsoft: "Microsoft", - google: "Google", - Qwen: "Alibaba", - mistralai: "Mistral AI", - HuggingFaceTB: "Hugging Face", - HuggingFaceH4: "Hugging Face", - bigscience: "BigScience", - EleutherAI: "EleutherAI", - tiiuae: "TII UAE", - stabilityai: "Stability AI", - databricks: "Databricks", - THUDM: "Tsinghua University", - "baichuan-inc": "Baichuan", - internlm: "InternLM", - "01-ai": "01.AI", - "deepseek-ai": "DeepSeek", - CohereForAI: "Cohere", - allenai: "Allen AI", - nvidia: "NVIDIA", - apple: "Apple" -}; -var VERIFIED_QUANTIZERS = { - TheBloke: "TheBloke", - bartowski: "bartowski", - QuantFactory: "QuantFactory", - mradermacher: "mradermacher", - "second-state": "Second State", - MaziyarPanahi: "Maziyar Panahi", - Triangle104: "Triangle104", - unsloth: "Unsloth", - "ggml-org": "GGML (HuggingFace)", - ggerganov: "Georgi Gerganov", - // Strong community quantizers (formerly badged separately) — trusted GGUFs. - "lmstudio-community": "Community GGUF", - "lmstudio-ai": "Community GGUF" -}; -var CREDIBILITY_LABELS = { - offgrid: { - label: "Off Grid", - description: "Curated & converted by Off Grid \u2014 verified to run on-device", - color: "#34D399" - }, - official: { label: "Official", description: "From the original model creator", color: "#22C55E" }, - "verified-quantizer": { - label: "Verified", - description: "From a trusted quantization provider", - color: "#A78BFA" - }, - community: { label: "Community", description: "Community contributed model", color: "#64748B" } -}; -function determineCredibility(author) { - if (OFFGRID_AUTHORS.includes(author)) return "offgrid"; - if (author in OFFICIAL_MODEL_AUTHORS) return "official"; - if (author in VERIFIED_QUANTIZERS) return "verified-quantizer"; - return "community"; -} - -// src/filters.ts -var initialFilterState = { - orgs: [], - type: "all", - source: "all", - size: "all", - quant: "all", - sort: "recommended" -}; -var SIZE_OPTIONS = [ - { key: "tiny", label: "Tiny (<2B)", min: 0, max: 2 }, - { key: "small", label: "Small (2-5B)", min: 2, max: 5 }, - { key: "medium", label: "Medium (5-15B)", min: 5, max: 15 }, - { key: "large", label: "Large (15B+)", min: 15, max: Infinity } -]; -var MODEL_TYPE_OPTIONS = [ - { key: "text", label: "Text" }, - { key: "vision", label: "Vision" }, - { key: "code", label: "Code" }, - { key: "image-gen", label: "Image" } -]; -var CREDIBILITY_OPTIONS = [ - { key: "offgrid", label: "Off Grid" }, - { key: "official", label: "Official" }, - { key: "verified-quantizer", label: "Verified" }, - { key: "community", label: "Community" } -]; -var SORT_OPTIONS = [ - { key: "recommended", label: "Recommended" }, - { key: "bestfit", label: "Best fit" }, - { key: "downloads", label: "Downloads" }, - { key: "size", label: "Size" }, - { key: "recency", label: "Recent" } -]; -var PARAM_RE = /\b(\d+(?:\.\d+)?)\s?([BbMm])\b/; -function parseParamCount(nameOrId) { - const m = PARAM_RE.exec(nameOrId); - if (!m) return null; - const n = Number.parseFloat(m[1]); - return /[Mm]/.test(m[2]) ? n / 1e3 : n; -} -function getModelType(name, tags = []) { - const n = name.toLowerCase(); - const t = tags.map((x) => x.toLowerCase()); - if (t.some( - (x) => x.includes("diffusion") || x.includes("text-to-image") || x.includes("image-generation") || x.includes("diffusers") - ) || n.includes("stable-diffusion") || n.includes("sd-") || n.includes("sdxl") || n.includes("flux")) - return "image-gen"; - if (t.some((x) => x.includes("vision") || x.includes("multimodal") || x.includes("image-text")) || n.includes("vision") || n.includes("vlm") || n.includes("-vl") || n.includes("llava")) - return "vision"; - if (t.some((x) => x.includes("code")) || n.includes("code") || n.includes("coder")) return "code"; - return "text"; -} -function bestFitScore(m, ramGb) { - const params = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) ?? 0; - const minRam = m.minRamGb ?? params * 0.75; - const ratio = ramGb ? minRam / ramGb : 0; - const penalty = ratio > 0.75 ? (ratio - 0.75) * 4 : 0; - return Math.abs(ratio - 0.4) + penalty; -} -function hasActiveFilters(state) { - return state.orgs.length > 0 || state.type !== "all" || state.source !== "all" || state.size !== "all" || state.quant !== "all"; -} -function applyFilters(models, state) { - return models.filter((m) => { - if (state.source !== "all" && m.credibility !== state.source) return false; - if (state.type !== "all" && getModelType(m.name, m.tags) !== state.type) return false; - if (state.orgs.length > 0 && !state.orgs.includes(m.org)) return false; - if (state.size !== "all") { - const p = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id); - const opt = SIZE_OPTIONS.find((s) => s.key === state.size); - if (opt && (p == null || p < opt.min || p >= opt.max)) return false; - } - if (state.quant !== "all" && m.files && m.files.length > 0) { - if (!m.files.some((f) => f.quant === state.quant)) return false; - } - return true; - }); -} -function applySort(models, sort, ramGb = 0) { - if (sort === "recommended") return models; - const arr = [...models]; - const p = (m) => m.params ?? parseParamCount(m.name) ?? 0; - switch (sort) { - case "bestfit": - return arr.sort((a, b) => bestFitScore(a, ramGb) - bestFitScore(b, ramGb)); - case "size": - return arr.sort((a, b) => p(a) - p(b)); - case "downloads": - return arr.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)); - case "recency": - return arr.sort((a, b) => (b.lastModified ?? "").localeCompare(a.lastModified ?? "")); - default: - return arr; - } -} -function filterAndSort(models, state, ramGb = 0) { - return applySort(applyFilters(models, state), state.sort, ramGb); -} - -// src/hf.ts -var KIND_PIPELINE = { - text: "text-generation", - vision: "image-text-to-text", - image: "text-to-image", - voice: "text-to-speech", - transcription: "automatic-speech-recognition" -}; -var GGUF_KINDS = /* @__PURE__ */ new Set(["text", "vision", "image"]); -var HF2 = "https://huggingface.co"; -var HF_API = "https://huggingface.co/api"; -var defaultFetch = (url, init) => fetch(url, init); -var isMmproj = (name) => /mmproj|clip/i.test(name); -var baseName = (p) => p.split("/").pop() ?? p; -async function searchHuggingFace(query, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const kind = opts.kind; - const params = new URLSearchParams({ - sort: opts.sort ?? "downloads", - direction: "-1", - // Over-fetch so post-filtering by detected type still leaves a full page. - limit: String((opts.limit ?? 30) * 2) - }); - if (!kind || GGUF_KINDS.has(kind)) params.set("filter", "gguf"); - else if (kind) params.set("pipeline_tag", KIND_PIPELINE[kind]); - if (query) params.set("search", query); - const res = await fetchImpl(`${HF_API}/models?${params.toString()}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) throw new Error(`Hugging Face search failed: HTTP ${res.status}`); - const data = await res.json(); - let out = data.map((m) => { - const id = m.id ?? m.modelId ?? ""; - const org = id.split("/")[0] ?? ""; - return { - id, - name: baseName(id), - org, - downloads: m.downloads, - likes: m.likes, - lastModified: m.lastModified, - credibility: determineCredibility(org) - }; - }); - if (kind === "text") - out = out.filter((m) => { - const t = getModelType(m.name); - return t === "text" || t === "code"; - }); - else if (kind === "vision") out = out.filter((m) => getModelType(m.name) === "vision"); - else if (kind === "image") out = out.filter((m) => getModelType(m.name) === "image-gen"); - return out.slice(0, opts.limit ?? 30); -} -async function getModelFiles(repoId, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) return []; - const data = await res.json(); - const gguf = (data.siblings ?? []).filter((f) => f.rfilename.endsWith(".gguf")); - const mmprojFiles = gguf.filter((f) => isMMProjFile(f.rfilename)); - const weights = gguf.filter((f) => !isMMProjFile(f.rfilename)); - const url = (rf) => `${HF2}/${repoId}/resolve/main/${rf}`; - const matchMmproj = (weightName) => { - if (mmprojFiles.length === 0) return void 0; - const wq = extractQuantization(weightName); - const exact = wq !== "Unknown" ? mmprojFiles.find((f) => extractQuantization(f.rfilename) === wq) : void 0; - const f16 = mmprojFiles.find((f) => { - const l = f.rfilename.toLowerCase(); - return (l.includes("f16") || l.includes("fp16")) && !l.includes("bf16"); - }); - const pick = exact ?? f16 ?? mmprojFiles[0]; - return { fileName: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size }; - }; - return weights.map((f) => { - const quant = extractQuantization(f.rfilename); - const info = QUANTIZATION_INFO[quant]; - return { - fileName: baseName(f.rfilename), - quant, - quality: info?.quality ?? "Unknown", - recommended: info?.recommended ?? false, - sizeBytes: f.size ?? 0, - downloadUrl: url(f.rfilename), - mmproj: matchMmproj(f.rfilename) - }; - }).sort((a, b) => Number(b.recommended) - Number(a.recommended) || a.sizeBytes - b.sizeBytes); -} -async function resolveHuggingFaceModel(repoId, opts = {}) { - const fetchImpl = opts.fetchImpl ?? defaultFetch; - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: "application/json" } - }); - if (!res.ok) return null; - const data = await res.json(); - const siblings = data.siblings ?? []; - const url = (rf) => `${HF2}/${repoId}/resolve/main/${rf}`; - const org = repoId.split("/")[0]; - const ggml = siblings.filter((f) => /ggml.*\.bin$/i.test(f.rfilename)); - if (ggml.length > 0) { - const pick = ggml.find((f) => /ggml-base\.bin$/i.test(f.rfilename)) ?? [...ggml].sort((a, b) => (a.size ?? 0) - (b.size ?? 0))[0]; - return { - id: repoId, - name: baseName(repoId), - kind: "transcription", - org, - files: [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: "primary" - } - ] - }; - } - const onnx = siblings.filter((f) => /\.onnx$/i.test(f.rfilename)); - if (onnx.length > 0 && siblings.every((f) => !f.rfilename.endsWith(".gguf"))) { - const pick = onnx.find((f) => /quant/i.test(f.rfilename)) ?? onnx[0]; - const files2 = [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: "primary" - } - ]; - const cfg = siblings.find((f) => f.rfilename === `${pick.rfilename}.json`); - if (cfg) - files2.push({ - name: baseName(cfg.rfilename), - url: url(cfg.rfilename), - sizeBytes: cfg.size, - role: "aux" - }); - return { id: repoId, name: baseName(repoId), kind: "voice", org, files: files2 }; - } - const gguf = siblings.filter((f) => f.rfilename.endsWith(".gguf")); - if (gguf.length === 0) return null; - const weights = gguf.filter((f) => !isMmproj(f.rfilename)); - const mmprojFiles = gguf.filter((f) => isMmproj(f.rfilename)); - const primary = weights.find((f) => /q4_k_m/i.test(f.rfilename)) ?? weights[0] ?? gguf[0]; - if (!primary) return null; - const files = [ - { - name: baseName(primary.rfilename), - url: url(primary.rfilename), - sizeBytes: primary.size, - role: "primary" - } - ]; - if (mmprojFiles[0]) { - files.push({ - name: baseName(mmprojFiles[0].rfilename), - url: url(mmprojFiles[0].rfilename), - sizeBytes: mmprojFiles[0].size, - role: "mmproj" - }); - } - return { - id: repoId, - name: baseName(repoId), - // Same data-derived rule as the curated catalog: a projector ⇒ vision. - kind: deriveKind(files, opts.kind ?? "text"), - org, - files - }; -} - -// src/providers.ts -var defaultFetch2 = (url, init) => fetch(url, init); -function authHeaders(apiKey) { - return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; -} -async function* lines(body) { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const parts = buf.split("\n"); - buf = parts.pop() ?? ""; - for (const line of parts) yield line; - } - if (buf.trim()) yield buf; -} -function openAICompatibleProvider(cfg) { - const f = cfg.fetchImpl ?? defaultFetch2; - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/models`, { - headers: { Accept: "application/json", ...authHeaders(cfg.apiKey) } - }); - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`); - const data = await res.json(); - return (data.data ?? []).map((m) => ({ id: m.id, name: m.id })); - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json", ...authHeaders(cfg.apiKey) }, - body: JSON.stringify({ - model: opts?.model, - messages, - stream: true, - temperature: opts?.temperature, - max_tokens: opts?.maxTokens - }), - signal: opts?.signal - }); - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`); - for await (const line of lines(res.body)) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") return; - try { - const j = JSON.parse(data); - const c = j.choices?.[0]?.delta?.content; - if (c) yield c; - } catch { - } - } - } - }; -} -function ollamaProvider(cfg) { - const f = cfg.fetchImpl ?? defaultFetch2; - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/api/tags`, { headers: { Accept: "application/json" } }); - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`); - const data = await res.json(); - return (data.models ?? []).map((m) => ({ id: m.name, name: m.name })); - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: opts?.model, messages, stream: true }), - signal: opts?.signal - }); - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`); - for await (const line of lines(res.body)) { - const t = line.trim(); - if (!t) continue; - try { - const j = JSON.parse(t); - if (j.message?.content) yield j.message.content; - if (j.done) return; - } catch { - } - } - } - }; -} -function createProvider(server, fetchImpl) { - if (server.kind === "ollama") { - return ollamaProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - fetchImpl - }); - } - return openAICompatibleProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - apiKey: server.apiKey, - fetchImpl - }); -} -var ProviderRegistry = class { - providers = /* @__PURE__ */ new Map(); - activeId = null; - register(provider) { - this.providers.set(provider.id, provider); - if (!this.activeId) this.activeId = provider.id; - } - unregister(id) { - this.providers.delete(id); - if (this.activeId === id) this.activeId = this.providers.keys().next().value ?? null; - } - list() { - return [...this.providers.values()]; - } - setActive(id) { - if (this.providers.has(id)) this.activeId = id; - } - active() { - return this.activeId ? this.providers.get(this.activeId) ?? null : null; - } -}; - -// src/imagegen.ts -function supportsMode(provider, mode) { - return provider.modes.includes(mode); -} -function validateImageGenRequest(provider, req) { - if (!supportsMode(provider, req.mode)) return `provider does not support ${req.mode}`; - if (req.mode === "img2img" && !req.initImage) return "img2img requires an initImage"; - if (!req.prompt.trim()) return "prompt is required"; - return null; -} - -// src/recommend-image.ts -var LIGHT_MODEL_RAM_CEILING_GB = 16; -var hasLightTag = (m) => (m.tags ?? []).some((t) => /^light$/i.test(t)); -var isVersatile = (m) => (m.tags ?? []).some((t) => /^versatile$/i.test(t)); -var pickVersatileFirst = (candidates) => candidates.find(isVersatile) ?? candidates[0]; -var familyKey = (m) => m.id.replace(/-Q\d[\w]*$/i, ""); -function recommendedImageModelId(models, ramGb) { - if (!ramGb || !Number.isFinite(ramGb)) return null; - const images = models.filter((m) => m.kind === "image"); - if (!images.length) return null; - const light = images.filter(hasLightTag); - const lightFamilies = new Set(light.map(familyKey)); - const fullOfLightFamily = images.filter((m) => !hasLightTag(m) && lightFamilies.has(familyKey(m))); - if (ramGb <= LIGHT_MODEL_RAM_CEILING_GB) { - return (pickVersatileFirst(light) ?? images[0]).id; - } - return (pickVersatileFirst(fullOfLightFamily) ?? images.find((m) => !hasLightTag(m)) ?? images[0]).id; -} -export { - CATALOG, - CREDIBILITY_LABELS, - CREDIBILITY_OPTIONS, - LIGHT_MODEL_RAM_CEILING_GB, - MODEL_KINDS, - MODEL_TYPE_OPTIONS, - ModelDownloader, - OFFICIAL_MODEL_AUTHORS, - ProviderRegistry, - QUANTIZATION_INFO, - RECOMMENDATION_TIERS, - SIZE_OPTIONS, - SORT_OPTIONS, - VERIFIED_QUANTIZERS, - applyFilters, - applySort, - bestFitScore, - createProvider, - deriveKind, - determineCredibility, - extractQuantization, - filterAndSort, - formatFileSize, - getModelFiles, - getModelType, - hasActiveFilters, - hasVisionProjector, - initialFilterState, - isMMProjFile, - modelsByKind, - ollamaProvider, - openAICompatibleProvider, - parseParamCount, - recommendForRam, - recommendedImageModelId, - resolveHuggingFaceModel, - searchHuggingFace, - supportsMode, - validateImageGenRequest -}; diff --git a/packages/models/dist/types-CZMZpuKL.d.mts b/packages/models/dist/types-CZMZpuKL.d.mts deleted file mode 100644 index 6dcd5990..00000000 --- a/packages/models/dist/types-CZMZpuKL.d.mts +++ /dev/null @@ -1,114 +0,0 @@ -type ImageGenMode = 'txt2img' | 'img2img'; -interface ImageGenRequest { - prompt: string; - mode: ImageGenMode; - negativePrompt?: string; - /** Input image for img2img (base64 data URL or local path). */ - initImage?: string; - /** img2img denoising strength, 0..1 (how much to change the input). */ - strength?: number; - width?: number; - height?: number; - steps?: number; - seed?: number; - signal?: AbortSignal; -} -interface ImageGenResult { - /** Output image (base64 data URL or local path). */ - image: string; - seed?: number; -} -/** A platform diffusion runtime. Implemented per-platform, used the same way. */ -interface ImageGenProvider { - readonly id: string; - /** Modes this provider/model supports (e.g. ['txt2img','img2img']). */ - readonly modes: ImageGenMode[]; - generate(req: ImageGenRequest): Promise; -} -declare function supportsMode(provider: ImageGenProvider, mode: ImageGenMode): boolean; -/** Validate a request against a provider's capabilities before running it. */ -declare function validateImageGenRequest(provider: ImageGenProvider, req: ImageGenRequest): string | null; - -type ModelKind = 'text' | 'vision' | 'image' | 'voice' | 'transcription'; -interface ModelFile { - /** Filename on disk, e.g. "Qwen3.5-2B-Q4_K_M.gguf". */ - name: string; - /** Download URL (often a Hugging Face resolve URL). */ - url: string; - /** Size in bytes when known (for progress + RAM/disk checks). */ - sizeBytes?: number; - /** Marks an auxiliary file (e.g. a vision mmproj) vs the primary weights. */ - role?: 'primary' | 'mmproj' | 'tokenizer' | 'aux'; -} -interface ModelEntry { - /** Stable id, usually the HF repo id, e.g. "unsloth/Qwen3.5-2B-GGUF". */ - id: string; - name: string; - kind: ModelKind; - /** Provider/org, e.g. "google", "Qwen", "openai-whisper". */ - org?: string; - description?: string; - /** Billions of parameters (LLMs); omitted for non-LLM kinds. */ - params?: number; - /** Minimum device RAM (GB) recommended. */ - minRamGb?: number; - /** Quantization label, e.g. "Q4_K_M". */ - quant?: string; - /** Files to download for this model. */ - files: ModelFile[]; - /** For image models: which generation modes it supports (txt2img/img2img). */ - imageModes?: ImageGenMode[]; - isNew?: boolean; - /** Short capability labels shown as chips, e.g. ['Recommended','Fast','Photoreal']. */ - tags?: string[]; - /** Which on-device runtime serves this model. Default 'sd-cli' (stable-diffusion.cpp). - * 'mflux' = the bundled MLX runtime (Apple-Silicon-only; fetches its own weights). */ - runtime?: 'sd-cli' | 'mflux'; - /** For transcription models: which STT engine serves it. Default 'whisper' when - * omitted. 'parakeet' models are multi-file ONNX sets run by the sherpa-onnx CLI. */ - engine?: 'whisper' | 'parakeet'; - /** Release date (ISO yyyy-mm-dd), from the source repo's createdAt. Shown on the - * card and used to surface only recent ("post-Jan-2026") small models. */ - releaseDate?: string; -} -/** RAM tier -> max model size + quant, for recommending a default model. */ -interface ModelRecommendationTier { - minRamGb: number; - maxRamGb: number; - maxParams: number; - quantization: string; -} -type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed'; -interface DownloadProgress { - modelId: string; - status: DownloadStatus; - /** 0..1 across all of the model's files. */ - progress: number; - bytesDownloaded: number; - totalBytes: number; - currentFile?: string; - speedBytesPerSec?: number; - error?: string; -} -/** Platform file download (Node/Electron streams to disk; RN background downloader). */ -interface DownloadBridge { - /** Download `url` to `destPath`. Resume if a partial file exists. Returns the - * bytes written. Call onProgress(bytesWritten, totalBytes) as it streams. */ - download(url: string, destPath: string, opts: { - onProgress?: (written: number, total: number) => void; - signal?: AbortSignal; - }): Promise; - /** Whether a fully-downloaded file already exists (size match). */ - exists(destPath: string, expectedBytes?: number): Promise; - /** Join the models directory with a filename. */ - pathFor(fileName: string): string; -} -/** Records which models are installed (a memory entity or local store). */ -interface ModelStore { - markInstalled(entry: ModelEntry): void; - isInstalled(modelId: string): boolean; - installed(): ModelEntry[]; - remove(modelId: string): void; -} - -export { type DownloadBridge as D, type ImageGenMode as I, type ModelEntry as M, type ModelKind as a, type ModelRecommendationTier as b, type ModelFile as c, type ModelStore as d, type DownloadProgress as e, type DownloadStatus as f, type ImageGenProvider as g, type ImageGenRequest as h, type ImageGenResult as i, supportsMode as s, validateImageGenRequest as v }; diff --git a/packages/models/dist/types-CZMZpuKL.d.ts b/packages/models/dist/types-CZMZpuKL.d.ts deleted file mode 100644 index 6dcd5990..00000000 --- a/packages/models/dist/types-CZMZpuKL.d.ts +++ /dev/null @@ -1,114 +0,0 @@ -type ImageGenMode = 'txt2img' | 'img2img'; -interface ImageGenRequest { - prompt: string; - mode: ImageGenMode; - negativePrompt?: string; - /** Input image for img2img (base64 data URL or local path). */ - initImage?: string; - /** img2img denoising strength, 0..1 (how much to change the input). */ - strength?: number; - width?: number; - height?: number; - steps?: number; - seed?: number; - signal?: AbortSignal; -} -interface ImageGenResult { - /** Output image (base64 data URL or local path). */ - image: string; - seed?: number; -} -/** A platform diffusion runtime. Implemented per-platform, used the same way. */ -interface ImageGenProvider { - readonly id: string; - /** Modes this provider/model supports (e.g. ['txt2img','img2img']). */ - readonly modes: ImageGenMode[]; - generate(req: ImageGenRequest): Promise; -} -declare function supportsMode(provider: ImageGenProvider, mode: ImageGenMode): boolean; -/** Validate a request against a provider's capabilities before running it. */ -declare function validateImageGenRequest(provider: ImageGenProvider, req: ImageGenRequest): string | null; - -type ModelKind = 'text' | 'vision' | 'image' | 'voice' | 'transcription'; -interface ModelFile { - /** Filename on disk, e.g. "Qwen3.5-2B-Q4_K_M.gguf". */ - name: string; - /** Download URL (often a Hugging Face resolve URL). */ - url: string; - /** Size in bytes when known (for progress + RAM/disk checks). */ - sizeBytes?: number; - /** Marks an auxiliary file (e.g. a vision mmproj) vs the primary weights. */ - role?: 'primary' | 'mmproj' | 'tokenizer' | 'aux'; -} -interface ModelEntry { - /** Stable id, usually the HF repo id, e.g. "unsloth/Qwen3.5-2B-GGUF". */ - id: string; - name: string; - kind: ModelKind; - /** Provider/org, e.g. "google", "Qwen", "openai-whisper". */ - org?: string; - description?: string; - /** Billions of parameters (LLMs); omitted for non-LLM kinds. */ - params?: number; - /** Minimum device RAM (GB) recommended. */ - minRamGb?: number; - /** Quantization label, e.g. "Q4_K_M". */ - quant?: string; - /** Files to download for this model. */ - files: ModelFile[]; - /** For image models: which generation modes it supports (txt2img/img2img). */ - imageModes?: ImageGenMode[]; - isNew?: boolean; - /** Short capability labels shown as chips, e.g. ['Recommended','Fast','Photoreal']. */ - tags?: string[]; - /** Which on-device runtime serves this model. Default 'sd-cli' (stable-diffusion.cpp). - * 'mflux' = the bundled MLX runtime (Apple-Silicon-only; fetches its own weights). */ - runtime?: 'sd-cli' | 'mflux'; - /** For transcription models: which STT engine serves it. Default 'whisper' when - * omitted. 'parakeet' models are multi-file ONNX sets run by the sherpa-onnx CLI. */ - engine?: 'whisper' | 'parakeet'; - /** Release date (ISO yyyy-mm-dd), from the source repo's createdAt. Shown on the - * card and used to surface only recent ("post-Jan-2026") small models. */ - releaseDate?: string; -} -/** RAM tier -> max model size + quant, for recommending a default model. */ -interface ModelRecommendationTier { - minRamGb: number; - maxRamGb: number; - maxParams: number; - quantization: string; -} -type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed'; -interface DownloadProgress { - modelId: string; - status: DownloadStatus; - /** 0..1 across all of the model's files. */ - progress: number; - bytesDownloaded: number; - totalBytes: number; - currentFile?: string; - speedBytesPerSec?: number; - error?: string; -} -/** Platform file download (Node/Electron streams to disk; RN background downloader). */ -interface DownloadBridge { - /** Download `url` to `destPath`. Resume if a partial file exists. Returns the - * bytes written. Call onProgress(bytesWritten, totalBytes) as it streams. */ - download(url: string, destPath: string, opts: { - onProgress?: (written: number, total: number) => void; - signal?: AbortSignal; - }): Promise; - /** Whether a fully-downloaded file already exists (size match). */ - exists(destPath: string, expectedBytes?: number): Promise; - /** Join the models directory with a filename. */ - pathFor(fileName: string): string; -} -/** Records which models are installed (a memory entity or local store). */ -interface ModelStore { - markInstalled(entry: ModelEntry): void; - isInstalled(modelId: string): boolean; - installed(): ModelEntry[]; - remove(modelId: string): void; -} - -export { type DownloadBridge as D, type ImageGenMode as I, type ModelEntry as M, type ModelKind as a, type ModelRecommendationTier as b, type ModelFile as c, type ModelStore as d, type DownloadProgress as e, type DownloadStatus as f, type ImageGenProvider as g, type ImageGenRequest as h, type ImageGenResult as i, supportsMode as s, validateImageGenRequest as v }; diff --git a/packages/models/package.json b/packages/models/package.json deleted file mode 100644 index 580e1e8c..00000000 --- a/packages/models/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@offgrid/models", - "version": "0.0.1", - "private": true, - "description": "Off Grid model management: a cross-platform catalog (text, vision, image, voice, transcription) + RAM-based recommendation + download orchestration behind a platform bridge. Replaces a single fixed LLM.", - "license": "AGPL-3.0-only", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - }, - "./node": { - "types": "./dist/adapters/node.d.ts", - "import": "./dist/adapters/node.mjs", - "require": "./dist/adapters/node.js" - } - }, - "scripts": { - "build": "tsup src/index.ts src/adapters/node.ts --format esm,cjs --dts --clean", - "dev": "tsup src/index.ts src/adapters/node.ts --format esm,cjs --dts --watch", - "typecheck": "tsc --noEmit", - "test": "npm run build && node --test test/" - } -} diff --git a/packages/models/src/adapters/node.ts b/packages/models/src/adapters/node.ts deleted file mode 100644 index 957e6ee6..00000000 --- a/packages/models/src/adapters/node.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Node/Electron DownloadBridge: streams files to the models dir with resume -// (HTTP Range) and progress. Uses global fetch (Node 18+/Electron). RN supplies -// its own bridge (background downloader); this lives at @offgrid/models/node. - -import fs from 'fs' -import path from 'path' -import type { DownloadBridge } from '../types' - -export class NodeDownloadBridge implements DownloadBridge { - constructor(private readonly modelsDir: string) { - fs.mkdirSync(modelsDir, { recursive: true }) - } - - pathFor(fileName: string): string { - return path.join(this.modelsDir, fileName) - } - - async exists(destPath: string, expectedBytes?: number): Promise { - try { - const st = fs.statSync(destPath) - return expectedBytes ? st.size === expectedBytes : st.size > 0 - } catch { - return false - } - } - - async download( - url: string, - destPath: string, - opts: { onProgress?: (written: number, total: number) => void; signal?: AbortSignal } - ): Promise { - const tmp = `${destPath}.part` - let start = 0 - try { - start = fs.statSync(tmp).size - } catch { - start = 0 - } - - const headers: Record = {} - if (start > 0) headers.Range = `bytes=${start}-` - - const res = await fetch(url, { headers, signal: opts.signal }) - if (!res.ok && res.status !== 206) { - throw new Error(`download failed: HTTP ${res.status} for ${url}`) - } - if (!res.body) throw new Error('download failed: empty body') - - const contentLength = Number(res.headers.get('content-length') ?? 0) - const total = contentLength + (res.status === 206 ? start : 0) - - const out = fs.createWriteStream(tmp, { flags: start > 0 && res.status === 206 ? 'a' : 'w' }) - let written = res.status === 206 ? start : 0 - - const reader = res.body.getReader() - try { - for (;;) { - const { done, value } = await reader.read() - if (done) break - out.write(Buffer.from(value)) - written += value.length - opts.onProgress?.(written, total || written) - } - } finally { - out.end() - await new Promise((resolve) => out.on('finish', () => resolve())) - } - - fs.renameSync(tmp, destPath) - return written - } -} diff --git a/packages/models/src/capabilities.ts b/packages/models/src/capabilities.ts deleted file mode 100644 index 13078ecd..00000000 --- a/packages/models/src/capabilities.ts +++ /dev/null @@ -1,23 +0,0 @@ -// The single, data-derived capability rule. A model's ability to read images is NOT -// a hand-typed flag (that drifts from reality — Gemma 4 E2B shipped mislabeled as -// text-only) but a fact about its files: a chat model can see iff it ships a vision -// projector (mmproj). Both the curated catalog and the Hugging Face resolver run every -// entry through here, so `kind` can never disagree with the files. - -import type { ModelFile, ModelKind } from './types' - -/** True iff the file set includes a vision projector (mmproj). This is what actually - * gives a chat model image input at load time. */ -export function hasVisionProjector(files: readonly ModelFile[]): boolean { - return files.some((f) => f.role === 'mmproj') -} - -/** Derive a model's kind from its files: a projector upgrades a chat model to vision. - * Non-chat kinds (image/voice/transcription) are returned unchanged — an mmproj is a - * chat/VLM concept and never reclassifies them. */ -export function deriveKind(files: readonly ModelFile[], declared: ModelKind): ModelKind { - if ((declared === 'text' || declared === 'vision') && hasVisionProjector(files)) { - return 'vision' - } - return declared -} diff --git a/packages/models/src/catalog.ts b/packages/models/src/catalog.ts deleted file mode 100644 index ca9d04d0..00000000 --- a/packages/models/src/catalog.ts +++ /dev/null @@ -1,999 +0,0 @@ -// Curated cross-platform model catalog + RAM-based recommendation. Spans the -// kinds Off Grid supports (text, vision, image, voice, transcription); more to -// come. Entries point at Hugging Face resolve URLs. This is editorial/default; -// the HF browser (hf.ts) lets users find anything else. - -import type { ModelEntry, ModelKind, ModelRecommendationTier } from './types' -import { deriveKind } from './capabilities' - -const HF = 'https://huggingface.co' -const resolve = (repo: string, file: string): string => `${HF}/${repo}/resolve/main/${file}` - -// RAM tier -> max LLM size + quant (ported from mobile MODEL_RECOMMENDATIONS). -export const RECOMMENDATION_TIERS: ModelRecommendationTier[] = [ - { minRamGb: 3, maxRamGb: 4, maxParams: 1.5, quantization: 'Q4_K_M' }, - { minRamGb: 4, maxRamGb: 6, maxParams: 3, quantization: 'Q4_K_M' }, - { minRamGb: 6, maxRamGb: 8, maxParams: 4, quantization: 'Q4_K_M' }, - { minRamGb: 8, maxRamGb: 12, maxParams: 8, quantization: 'Q4_K_M' }, - { minRamGb: 12, maxRamGb: 16, maxParams: 13, quantization: 'Q4_K_M' }, - { minRamGb: 16, maxRamGb: Infinity, maxParams: 30, quantization: 'Q4_K_M' } -] - -export function recommendForRam(ramGb: number): ModelRecommendationTier { - return ( - RECOMMENDATION_TIERS.find((t) => ramGb >= t.minRamGb && ramGb < t.maxRamGb) ?? - RECOMMENDATION_TIERS[RECOMMENDATION_TIERS.length - 1] - ) -} - -const RAW_CATALOG: ModelEntry[] = [ - // --- text (SLMs) — post-Jan-2026 only; the latest small-model challengers, - // quantized for desktop. Dates are the source repo's HF createdAt. --- - { - id: 'unsloth/Qwen3.5-0.8B-GGUF', - name: 'Qwen 3.5 0.8B', - kind: 'text', - org: 'Qwen', - description: 'Tiny, very fast — runs on almost anything', - params: 0.8, - minRamGb: 3, - quant: 'Q4_K_M', - releaseDate: '2026-03-01', - files: [ - { - name: 'Qwen3.5-0.8B-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3.5-0.8B-GGUF', 'Qwen3.5-0.8B-Q4_K_M.gguf'), - sizeBytes: 530000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3.5-0.8B-BF16.gguf', - url: resolve('unsloth/Qwen3.5-0.8B-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 207346528, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3.5-2B-GGUF', - name: 'Qwen 3.5 2B', - kind: 'text', - org: 'Qwen', - description: 'Hybrid thinking + chat, long context', - params: 2, - minRamGb: 4, - quant: 'Q4_K_M', - releaseDate: '2026-02-28', - files: [ - { - name: 'Qwen3.5-2B-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3.5-2B-GGUF', 'Qwen3.5-2B-Q4_K_M.gguf'), - sizeBytes: 1280000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3.5-2B-BF16.gguf', - url: resolve('unsloth/Qwen3.5-2B-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 671372992, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3.5-4B-GGUF', - name: 'Qwen 3.5 4B', - kind: 'text', - org: 'Qwen', - description: 'Strong small general model — hybrid thinking + chat, long context', - params: 4, - minRamGb: 6, - quant: 'Q4_K_M', - tags: ['Challenger'], - releaseDate: '2026-03-02', - files: [ - { - name: 'Qwen3.5-4B-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3.5-4B-GGUF', 'Qwen3.5-4B-Q4_K_M.gguf'), - sizeBytes: 2740000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3.5-4B-BF16.gguf', - url: resolve('unsloth/Qwen3.5-4B-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 675569344, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3.5-9B-GGUF', - name: 'Qwen 3.5 9B', - kind: 'text', - org: 'Qwen', - description: 'Higher-quality general reasoning; needs a bit more RAM', - params: 9, - minRamGb: 8, - quant: 'Q4_K_M', - tags: ['Challenger'], - releaseDate: '2026-02-28', - files: [ - { - name: 'Qwen3.5-9B-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3.5-9B-GGUF', 'Qwen3.5-9B-Q4_K_M.gguf'), - sizeBytes: 5680000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3.5-9B-BF16.gguf', - url: resolve('unsloth/Qwen3.5-9B-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 921705024, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3.5-27B-GGUF', - name: 'Qwen 3.5 27B', - kind: 'text', - org: 'Qwen', - description: 'Top Qwen3.5 quality — large; 24GB+ machines', - params: 27, - minRamGb: 24, - quant: 'Q4_K_M', - releaseDate: '2026-02-24', - files: [ - { - name: 'Qwen3.5-27B-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3.5-27B-GGUF', 'Qwen3.5-27B-Q4_K_M.gguf'), - sizeBytes: 16740000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3.5-27B-BF16.gguf', - url: resolve('unsloth/Qwen3.5-27B-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 931145984, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/gemma-4-E2B-it-GGUF', - name: 'Gemma 4 E2B', - kind: 'vision', - org: 'google', - description: 'Google’s small efficient model — fast, capable, reads images', - params: 2, - minRamGb: 5, - quant: 'Q4_K_M', - releaseDate: '2026-04-01', - files: [ - { - name: 'gemma-4-E2B-it-Q4_K_M.gguf', - url: resolve('unsloth/gemma-4-E2B-it-GGUF', 'gemma-4-E2B-it-Q4_K_M.gguf'), - sizeBytes: 3110000000, - role: 'primary' - }, - { - name: 'mmproj-gemma-4-E2B-it-F16.gguf', - url: resolve('unsloth/gemma-4-E2B-it-GGUF', 'mmproj-F16.gguf'), - sizeBytes: 985654080, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/gemma-4-12b-it-GGUF', - name: 'Gemma 4 12B', - kind: 'text', - org: 'google', - description: 'Strong mid-size Gemma 4 — great general quality', - params: 12, - minRamGb: 12, - quant: 'Q4_K_M', - tags: ['Challenger'], - releaseDate: '2026-05-29', - files: [ - { - name: 'gemma-4-12b-it-Q4_K_M.gguf', - url: resolve('unsloth/gemma-4-12b-it-GGUF', 'gemma-4-12b-it-Q4_K_M.gguf'), - sizeBytes: 7120000000, - role: 'primary' - }, - { - name: 'mmproj-gemma-4-12b-it-BF16.gguf', - url: resolve('unsloth/gemma-4-12b-it-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 175115840, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/gemma-4-26B-A4B-it-GGUF', - name: 'Gemma 4 26B A4B (MoE)', - kind: 'text', - org: 'google', - description: 'MoE — 26B quality at ~4B active speed; needs 20GB+', - params: 26, - minRamGb: 20, - quant: 'Q4_K_M', - tags: ['Challenger'], - releaseDate: '2026-04-01', - files: [ - { - name: 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf', - url: resolve('unsloth/gemma-4-26B-A4B-it-GGUF', 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf'), - sizeBytes: 16950000000, - role: 'primary' - }, - { - name: 'mmproj-gemma-4-26B-A4B-it-BF16.gguf', - url: resolve('unsloth/gemma-4-26B-A4B-it-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 1194828256, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/gemma-4-31B-it-GGUF', - name: 'Gemma 4 31B', - kind: 'text', - org: 'google', - description: 'Largest Gemma 4 dense — top quality; 24GB+ machines', - params: 31, - minRamGb: 24, - quant: 'Q4_K_M', - releaseDate: '2026-04-01', - files: [ - { - name: 'gemma-4-31B-it-Q4_K_M.gguf', - url: resolve('unsloth/gemma-4-31B-it-GGUF', 'gemma-4-31B-it-Q4_K_M.gguf'), - sizeBytes: 18320000000, - role: 'primary' - }, - { - name: 'mmproj-gemma-4-31B-it-BF16.gguf', - url: resolve('unsloth/gemma-4-31B-it-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 1200726496, - role: 'mmproj' - } - ] - }, - // --- vision (multimodal LLM) --- - { - id: 'unsloth/gemma-4-E4B-it-GGUF', - name: 'Gemma 4 E4B', - kind: 'vision', - org: 'google', - description: 'Thinking + vision, MoE', - params: 4, - minRamGb: 6, - quant: 'Q4_K_M', - tags: ['Challenger'], - releaseDate: '2026-04-01', - files: [ - { - name: 'gemma-4-E4B-it-Q4_K_M.gguf', - url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'gemma-4-E4B-it-Q4_K_M.gguf'), - sizeBytes: 4980000000, - role: 'primary' - }, - { - name: 'mmproj-gemma-4-E4B-it-F16.gguf', - url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'mmproj-F16.gguf'), - sizeBytes: 990000000, - role: 'mmproj' - } - ] - }, - { - id: 'ggml-org/SmolVLM2-2.2B-Instruct-GGUF', - name: 'SmolVLM2 2.2B', - kind: 'vision', - org: 'HuggingFaceTB', - description: 'Compact, fast vision-language model — great on modest RAM', - params: 2.2, - minRamGb: 6, - quant: 'Q4_K_M', - releaseDate: '2025-04-21', - files: [ - { - name: 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf', - url: resolve('ggml-org/SmolVLM2-2.2B-Instruct-GGUF', 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf'), - sizeBytes: 1110000000, - role: 'primary' - }, - { - name: 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf', - url: resolve( - 'ggml-org/SmolVLM2-2.2B-Instruct-GGUF', - 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf' - ), - sizeBytes: 870000000, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3-VL-2B-Instruct-GGUF', - name: 'Qwen3-VL 2B', - kind: 'vision', - org: 'Qwen', - description: 'Small vision-language model — fast and capable', - params: 2, - minRamGb: 6, - quant: 'Q4_K_M', - releaseDate: '2025-10-30', - files: [ - { - name: 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf'), - sizeBytes: 1110000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3-VL-2B-Instruct-F16.gguf', - url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 820000000, - role: 'mmproj' - } - ] - }, - { - id: 'unsloth/Qwen3-VL-8B-Instruct-GGUF', - name: 'Qwen3-VL 8B', - kind: 'vision', - org: 'Qwen', - description: 'Stronger VLM — better detail + OCR; needs more RAM', - params: 8, - minRamGb: 10, - quant: 'Q4_K_M', - releaseDate: '2025-10-30', - files: [ - { - name: 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf'), - sizeBytes: 5030000000, - role: 'primary' - }, - { - name: 'mmproj-Qwen3-VL-8B-Instruct-F16.gguf', - url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'mmproj-BF16.gguf'), - sizeBytes: 1160000000, - role: 'mmproj' - } - ] - }, - // --- image generation — 2026 / fast few-step models only (open weight) --- - { - id: 'leejet/Z-Image-Turbo-GGUF', - name: 'Z-Image Turbo (2026)', - kind: 'image', - // NOT tagged 'Fast': despite the "Turbo" name this is a FLUX-class diffusion - // transformer (DiT + Qwen3-4B text encoder + FLUX VAE) — heavy and slow on - // Apple Silicon via ggml, not a few-step SDXL distill. 'Fast' is reserved for - // models verified fast on-device (dreamshaper-turbo, realvis-lightning). - tags: ['Recommended', '2026', 'Top quality'], - org: 'Alibaba Tongyi', - description: - 'Flagship 2026 model — 1024px in ~8 steps, top quality-per-byte, strong bilingual text. Apache-2.0. (diffusion + Qwen3 encoder + VAE)', - minRamGb: 12, - imageModes: ['txt2img'], - files: [ - { - name: 'z_image_turbo-Q4_K.gguf', - url: resolve('leejet/Z-Image-Turbo-GGUF', 'z_image_turbo-Q4_K.gguf'), - role: 'primary', - sizeBytes: 3860000000 - }, - { - name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', - url: resolve('unsloth/Qwen3-4B-Instruct-2507-GGUF', 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf'), - role: 'aux', - sizeBytes: 2500000000 - }, - { - name: 'ae.safetensors', - url: resolve('second-state/FLUX.1-schnell-GGUF', 'ae.safetensors'), - role: 'aux', - sizeBytes: 340000000 - } - ] - }, - // NOTE: MLX/mflux image models are PARKED (2026-06-23) — the only non-gated - // on-device MLX LoRA options are too large to ship (Z-Image ~13GB 8-bit / ~33GB - // bf16; FLUX.1-schnell 4-bit ~10GB). No MLX catalog entry is exposed. The - // dormant runtime plumbing lives in src/main/mflux.ts (re-enable by repopulating - // MFLUX_MODELS + restoring an entry here with runtime:'mflux'). - { - id: 'mzwing/SDXL-Lightning-GGUF', - name: 'SDXL Lightning (4-step)', - kind: 'image', - tags: ['Recommended', 'Fast'], - org: 'ByteDance', - description: - 'Near-SDXL quality at 1024px in 4 steps (~7× faster). ~4GB model. Best balance — recommended.', - minRamGb: 8, - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'sdxl_lightning_4step.q8_0.gguf', - url: resolve('mzwing/SDXL-Lightning-GGUF', 'sdxl_lightning_4step.q8_0.gguf'), - role: 'primary', - sizeBytes: 4099000000 - } - ] - }, - { - id: 'OlegSkutte/sdxl-turbo-GGUF', - name: 'SDXL Turbo (fast drafts)', - kind: 'image', - tags: ['Fastest', 'Drafts'], - org: 'Stability AI', - description: - 'Distilled SDXL — 1-4 steps, ~10s drafts at 512px. Fastest option; lower fidelity.', - minRamGb: 8, - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'sd_xl_turbo_1.0.q8_0.gguf', - url: resolve('OlegSkutte/sdxl-turbo-GGUF', 'sd_xl_turbo_1.0.q8_0.gguf'), - role: 'primary', - sizeBytes: 4100000000 - } - ] - }, - // SDXL finetunes — Off Grid GGUF builds (q8). The community GGUF quants of these - // are mis-exported and won't load in sd.cpp, so we converted the official - // OpenRAIL checkpoints ourselves (offgrid-ai HF org) → correct, ~4GB, on-device. - { - id: 'offgrid-ai/realvisxl-v5.0-GGUF', - name: 'RealVisXL v5.0 (photoreal)', - kind: 'image', - tags: ['High quality', 'Photoreal'], - org: 'RealVis', - description: 'Top photorealism SDXL — Off Grid GGUF build of SG161222/RealVisXL_V5.0.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2024-08-05', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'realvisxl-v5.0-Q8_0.gguf', - url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q8_0.gguf'), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, runs on a 16GB Mac. Tagged 'Light' - // so the RAM-aware default + "Recommended" badge pick it on <= 16GB machines. - id: 'offgrid-ai/realvisxl-v5.0-GGUF-Q4', - name: 'RealVisXL v5.0 (Light)', - kind: 'image', - tags: ['Photoreal', 'Light'], - org: 'RealVis', - description: - 'Top photorealism SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2024-08-05', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'realvisxl-v5.0-Q4_K.gguf', - url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q4_K.gguf'), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - { - id: 'offgrid-ai/realvisxl-v5.0-lightning-GGUF', - name: 'RealVisXL v5.0 Lightning (photoreal)', - kind: 'image', - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ['Photoreal'], - org: 'RealVis', - description: - 'Photoreal SDXL, few-step (fast) — Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2024-09-02', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'realvisxl-v5.0-lightning-Q8_0.gguf', - url: resolve( - 'offgrid-ai/realvisxl-v5.0-lightning-GGUF', - 'realvisxl-v5.0-lightning-Q8_0.gguf' - ), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Light (Q4_K) sibling — few-step photoreal, ~35% less memory, 16GB-friendly. - id: 'offgrid-ai/realvisxl-v5.0-lightning-GGUF-Q4', - name: 'RealVisXL v5.0 Lightning (Light)', - kind: 'image', - tags: ['Fast', 'Photoreal', 'Light'], - org: 'RealVis', - description: - 'Photoreal SDXL, few-step (fast). Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2024-09-02', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'realvisxl-v5.0-lightning-Q4_K.gguf', - url: resolve( - 'offgrid-ai/realvisxl-v5.0-lightning-GGUF', - 'realvisxl-v5.0-lightning-Q4_K.gguf' - ), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - { - id: 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF', - name: 'DreamShaper XL v2 Turbo (versatile)', - kind: 'image', - // Full Q8: few-step, but ~4.2GB pegs a 16GB Mac — 'Fast' is reserved for the - // Light (Q4) sibling that's both few-step AND memory-safe. - tags: ['Versatile'], - org: 'Lykon', - description: - 'The all-rounder — photoreal, art, fantasy, 3D. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo. Full Q8 quant (best quality); best on 24GB+ RAM.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2024-02-07', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'dreamshaper-xl-v2-turbo-Q8_0.gguf', - url: resolve( - 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF', - 'dreamshaper-xl-v2-turbo-Q8_0.gguf' - ), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Lighter Q4_K quant of the same distilled turbo model — ~35% less memory - // (~3.08GB peak vs ~4.7GB), so it runs on a 16GB Mac without pegging unified - // memory. Same repo, distinct id + filename so download/active-tracking treat - // it as a separate installable model. Tagged 'Light' → the RAM-aware default + - // "Recommended" badge pick it on machines with <= 16GB RAM. - id: 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF-Q4', - name: 'DreamShaper XL v2 Turbo (Light)', - kind: 'image', - tags: ['Versatile', 'Fast', 'Light'], - org: 'Lykon', - description: - 'The all-rounder — photoreal, art, fantasy, 3D. Q4 quant: ~35% less memory than the full model, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2024-02-07', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'dreamshaper-xl-v2-turbo-Q4_K.gguf', - url: resolve( - 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF', - 'dreamshaper-xl-v2-turbo-Q4_K.gguf' - ), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - { - id: 'offgrid-ai/juggernaut-xl-v9-GGUF', - name: 'Juggernaut XL v9 (photoreal)', - kind: 'image', - tags: ['High quality', 'Photoreal'], - org: 'RunDiffusion', - description: - 'Versatile photoreal SDXL — cinematic, portraits, landscapes. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2024-02-18', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'juggernaut-xl-v9-Q8_0.gguf', - url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q8_0.gguf'), - role: 'primary', - sizeBytes: 4350000000 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: 'offgrid-ai/juggernaut-xl-v9-GGUF-Q4', - name: 'Juggernaut XL v9 (Light)', - kind: 'image', - tags: ['Photoreal', 'Light'], - org: 'RunDiffusion', - description: - 'Versatile photoreal SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2024-02-18', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'juggernaut-xl-v9-Q4_K.gguf', - url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q4_K.gguf'), - role: 'primary', - sizeBytes: 2900000000 - } - ] - }, - { - id: 'offgrid-ai/animagine-xl-4.0-GGUF', - name: 'Animagine XL 4.0 (anime)', - kind: 'image', - tags: ['High quality', 'Anime'], - org: 'Cagliostro', - description: - 'Leading anime SDXL — strong character knowledge. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2025-01-10', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'animagine-xl-4.0-Q8_0.gguf', - url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q8_0.gguf'), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: 'offgrid-ai/animagine-xl-4.0-GGUF-Q4', - name: 'Animagine XL 4.0 (Light)', - kind: 'image', - tags: ['Anime', 'Light'], - org: 'Cagliostro', - description: - 'Leading anime SDXL — strong character knowledge. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2025-01-10', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'animagine-xl-4.0-Q4_K.gguf', - url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q4_K.gguf'), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - { - id: 'offgrid-ai/illustrious-xl-v2.0-GGUF', - name: 'Illustrious XL v2.0 (anime)', - kind: 'image', - tags: ['High quality', 'Anime'], - org: 'OnomaAI', - description: - 'Top anime / illustration SDXL base. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2025-04-18', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'illustrious-xl-v2.0-Q8_0.gguf', - url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q8_0.gguf'), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: 'offgrid-ai/illustrious-xl-v2.0-GGUF-Q4', - name: 'Illustrious XL v2.0 (Light)', - kind: 'image', - tags: ['Anime', 'Light'], - org: 'OnomaAI', - description: - 'Top anime / illustration SDXL base. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2025-04-18', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'illustrious-xl-v2.0-Q4_K.gguf', - url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q4_K.gguf'), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - { - id: 'offgrid-ai/pony-diffusion-v6-xl-GGUF', - name: 'Pony Diffusion V6 XL (stylized)', - kind: 'image', - tags: ['High quality', 'Stylized'], - org: 'PurpleSmartAI', - description: - 'Dominant SDXL for stylized characters & illustration; highly promptable. Off Grid GGUF build of Pony Diffusion V6 XL.', - minRamGb: 8, - quant: 'Q8_0', - releaseDate: '2024-05-25', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'pony-diffusion-v6-xl-Q8_0.gguf', - url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q8_0.gguf'), - role: 'primary', - sizeBytes: 4180000000 - } - ] - }, - { - // Light (Q4_K) sibling — ~35% less memory, 16GB-friendly. - id: 'offgrid-ai/pony-diffusion-v6-xl-GGUF-Q4', - name: 'Pony Diffusion V6 XL (Light)', - kind: 'image', - tags: ['Stylized', 'Light'], - org: 'PurpleSmartAI', - description: - 'Dominant SDXL for stylized characters & illustration. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Pony Diffusion V6 XL.', - minRamGb: 8, - quant: 'Q4_K', - releaseDate: '2024-05-25', - imageModes: ['txt2img', 'img2img'], - files: [ - { - name: 'pony-diffusion-v6-xl-Q4_K.gguf', - url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q4_K.gguf'), - role: 'primary', - sizeBytes: 2800000000 - } - ] - }, - // --- voice (TTS); open models, ONNX runtime (no Python) --- - { - id: 'onnx-community/Kokoro-82M-v1.0-ONNX', - name: 'Kokoro TTS 82M', - kind: 'voice', - org: 'hexgrad', - description: 'Lightweight, natural text-to-speech (ONNX); great default', - minRamGb: 3, - files: [ - { - name: 'kokoro-82m-v1.0.onnx', - url: resolve('onnx-community/Kokoro-82M-v1.0-ONNX', 'onnx/model_quantized.onnx'), - role: 'primary', - sizeBytes: 92361116 - } - ] - }, - { - id: 'rhasspy/piper-voices/en_US-lessac-medium', - name: 'Piper - Lessac (English)', - kind: 'voice', - org: 'rhasspy', - description: 'Fast multi-voice text-to-speech (ONNX); many languages available', - minRamGb: 2, - files: [ - { - name: 'en_US-lessac-medium.onnx', - url: resolve('rhasspy/piper-voices', 'en/en_US/lessac/medium/en_US-lessac-medium.onnx'), - role: 'primary', - sizeBytes: 63201294 - }, - { - name: 'en_US-lessac-medium.onnx.json', - url: resolve( - 'rhasspy/piper-voices', - 'en/en_US/lessac/medium/en_US-lessac-medium.onnx.json' - ), - role: 'aux' - } - ] - }, - // --- transcription (STT / whisper); all from ggerganov/whisper.cpp (ggml .bin) --- - { - id: 'ggerganov/whisper.cpp/tiny', - name: 'Whisper Tiny', - kind: 'transcription', - org: 'ggerganov', - description: 'Fastest, smallest — lowest accuracy', - minRamGb: 2, - files: [ - { - name: 'ggml-tiny.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-tiny.bin'), - role: 'primary', - sizeBytes: 77700000 - } - ] - }, - { - id: 'ggerganov/whisper.cpp/base', - name: 'Whisper Base', - kind: 'transcription', - org: 'ggerganov', - description: 'Offline speech-to-text (base) — good speed/quality default', - minRamGb: 3, - files: [ - { - name: 'ggml-base.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-base.bin'), - role: 'primary', - sizeBytes: 147951000 - } - ] - }, - { - id: 'ggerganov/whisper.cpp/small', - name: 'Whisper Small', - kind: 'transcription', - org: 'ggerganov', - description: 'Offline speech-to-text (higher accuracy)', - minRamGb: 4, - files: [ - { - name: 'ggml-small.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-small.bin'), - role: 'primary', - sizeBytes: 487601000 - } - ] - }, - { - id: 'ggerganov/whisper.cpp/medium', - name: 'Whisper Medium', - kind: 'transcription', - org: 'ggerganov', - description: 'High accuracy; slower', - minRamGb: 6, - files: [ - { - name: 'ggml-medium.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-medium.bin'), - role: 'primary', - sizeBytes: 1533000000 - } - ] - }, - { - id: 'ggerganov/whisper.cpp/large-v3-turbo', - name: 'Whisper Large v3 Turbo', - kind: 'transcription', - org: 'ggerganov', - description: 'Near-large accuracy, much faster — recommended', - minRamGb: 6, - files: [ - { - name: 'ggml-large-v3-turbo.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3-turbo.bin'), - role: 'primary', - sizeBytes: 1624000000 - } - ] - }, - { - id: 'ggerganov/whisper.cpp/large-v3', - name: 'Whisper Large v3', - kind: 'transcription', - org: 'ggerganov', - description: 'Highest accuracy (large); needs more RAM', - minRamGb: 8, - files: [ - { - name: 'ggml-large-v3.bin', - url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3.bin'), - role: 'primary', - sizeBytes: 3095000000 - } - ] - }, - // --- transcription (Parakeet, NVIDIA NeMo) — sherpa-onnx offline transducer (ONNX). - // A model is 4 files (encoder/decoder/joiner/tokens); on-disk names are slug-prefixed - // so multiple Parakeet models coexist in the flat models dir without colliding. Higher - // accuracy than whisper; served by the bundled sherpa-onnx CLI (engine: 'parakeet'). --- - { - id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', - name: 'Parakeet TDT 0.6B v2', - kind: 'transcription', - engine: 'parakeet', - org: 'nvidia', - description: 'High-accuracy English STT (int8) - tops the open ASR leaderboard', - minRamGb: 4, - tags: ['Accurate', 'English'], - files: [ - { - name: 'parakeet-v2.encoder.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'encoder.int8.onnx'), - role: 'primary', - sizeBytes: 652000000 - }, - { - name: 'parakeet-v2.decoder.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'decoder.int8.onnx'), - role: 'aux', - sizeBytes: 7260000 - }, - { - name: 'parakeet-v2.joiner.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'joiner.int8.onnx'), - role: 'aux', - sizeBytes: 1740000 - }, - { - name: 'parakeet-v2.tokens.txt', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'tokens.txt'), - role: 'tokenizer', - sizeBytes: 9600 - } - ] - }, - { - id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', - name: 'Parakeet TDT 0.6B v3', - kind: 'transcription', - engine: 'parakeet', - org: 'nvidia', - description: 'Multilingual STT (int8) - 25 European languages', - minRamGb: 4, - isNew: true, - tags: ['Accurate', 'Multilingual'], - files: [ - { - name: 'parakeet-v3.encoder.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'encoder.int8.onnx'), - role: 'primary', - sizeBytes: 652000000 - }, - { - name: 'parakeet-v3.decoder.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'decoder.int8.onnx'), - role: 'aux', - sizeBytes: 7260000 - }, - { - name: 'parakeet-v3.joiner.int8.onnx', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'joiner.int8.onnx'), - role: 'aux', - sizeBytes: 1740000 - }, - { - name: 'parakeet-v3.tokens.txt', - url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'tokens.txt'), - role: 'tokenizer', - sizeBytes: 9600 - } - ] - } -] - -// Normalize every entry through the data-derived capability rule: an entry that lists -// a projector is vision, whatever its hand-typed `kind` said. So a future entry can't -// ship a projector while mislabeled text-only (the Gemma 4 E2B bug), and `kind` is -// always consistent with the files for every consumer. -export const CATALOG: ModelEntry[] = RAW_CATALOG.map((e) => ({ - ...e, - kind: deriveKind(e.files, e.kind) -})) - -export function modelsByKind(kind: ModelKind): ModelEntry[] { - return CATALOG.filter((m) => m.kind === kind) -} - -export const MODEL_KINDS: ModelKind[] = ['text', 'vision', 'image', 'voice', 'transcription'] diff --git a/packages/models/src/credibility.ts b/packages/models/src/credibility.ts deleted file mode 100644 index abfdd757..00000000 --- a/packages/models/src/credibility.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Model source credibility. Classifies a HF author as Official creator / -// Verified quantizer / Community, with labels + colors for badges. Shared so -// desktop and mobile rank sources identically. - -export type Credibility = 'offgrid' | 'official' | 'verified-quantizer' | 'community' - -// HF authors published/curated by Off Grid (our own converted + verified models). -export const OFFGRID_AUTHORS = ['offgrid-ai', 'offgrid'] - -export const OFFICIAL_MODEL_AUTHORS: Record = { - 'meta-llama': 'Meta', - microsoft: 'Microsoft', - google: 'Google', - Qwen: 'Alibaba', - mistralai: 'Mistral AI', - HuggingFaceTB: 'Hugging Face', - HuggingFaceH4: 'Hugging Face', - bigscience: 'BigScience', - EleutherAI: 'EleutherAI', - tiiuae: 'TII UAE', - stabilityai: 'Stability AI', - databricks: 'Databricks', - THUDM: 'Tsinghua University', - 'baichuan-inc': 'Baichuan', - internlm: 'InternLM', - '01-ai': '01.AI', - 'deepseek-ai': 'DeepSeek', - CohereForAI: 'Cohere', - allenai: 'Allen AI', - nvidia: 'NVIDIA', - apple: 'Apple' -} - -export const VERIFIED_QUANTIZERS: Record = { - TheBloke: 'TheBloke', - bartowski: 'bartowski', - QuantFactory: 'QuantFactory', - mradermacher: 'mradermacher', - 'second-state': 'Second State', - MaziyarPanahi: 'Maziyar Panahi', - Triangle104: 'Triangle104', - unsloth: 'Unsloth', - 'ggml-org': 'GGML (HuggingFace)', - ggerganov: 'Georgi Gerganov', - // Strong community quantizers (formerly badged separately) — trusted GGUFs. - 'lmstudio-community': 'Community GGUF', - 'lmstudio-ai': 'Community GGUF' -} - -export const CREDIBILITY_LABELS: Record< - Credibility, - { label: string; description: string; color: string } -> = { - offgrid: { - label: 'Off Grid', - description: 'Curated & converted by Off Grid — verified to run on-device', - color: '#34D399' - }, - official: { label: 'Official', description: 'From the original model creator', color: '#22C55E' }, - 'verified-quantizer': { - label: 'Verified', - description: 'From a trusted quantization provider', - color: '#A78BFA' - }, - community: { label: 'Community', description: 'Community contributed model', color: '#64748B' } -} - -/** Classify a HF author into a credibility tier. */ -export function determineCredibility(author: string): Credibility { - if (OFFGRID_AUTHORS.includes(author)) return 'offgrid' - if (author in OFFICIAL_MODEL_AUTHORS) return 'official' - if (author in VERIFIED_QUANTIZERS) return 'verified-quantizer' - return 'community' -} diff --git a/packages/models/src/download.ts b/packages/models/src/download.ts deleted file mode 100644 index f7b10fc6..00000000 --- a/packages/models/src/download.ts +++ /dev/null @@ -1,88 +0,0 @@ -// ModelDownloader: downloads all files of a ModelEntry through a platform -// DownloadBridge, with aggregate progress, cancel, and install tracking via a -// ModelStore. Platform-agnostic; the bridge does the actual file IO. - -import type { DownloadBridge, DownloadProgress, ModelEntry, ModelStore } from './types' - -export class ModelDownloader { - private aborts = new Map() - private listeners = new Set<(p: DownloadProgress) => void>() - - constructor( - private readonly bridge: DownloadBridge, - private readonly store: ModelStore - ) {} - - onProgress(cb: (p: DownloadProgress) => void): () => void { - this.listeners.add(cb) - return () => this.listeners.delete(cb) - } - - isInstalled(modelId: string): boolean { - return this.store.isInstalled(modelId) - } - - cancel(modelId: string): void { - this.aborts.get(modelId)?.abort() - } - - private emit(p: DownloadProgress): void { - for (const l of this.listeners) l(p) - } - - async download(entry: ModelEntry): Promise { - const controller = new AbortController() - this.aborts.set(entry.id, controller) - const totalKnown = entry.files.reduce((n, f) => n + (f.sizeBytes ?? 0), 0) - let basePrev = 0 - - try { - for (const file of entry.files) { - const dest = this.bridge.pathFor(file.name) - if (await this.bridge.exists(dest, file.sizeBytes)) { - basePrev += file.sizeBytes ?? 0 - continue - } - await this.bridge.download(file.url, dest, { - signal: controller.signal, - onProgress: (written, total) => { - const totalBytes = totalKnown || basePrev + total - const bytesDownloaded = basePrev + written - this.emit({ - modelId: entry.id, - status: 'downloading', - bytesDownloaded, - totalBytes, - progress: totalBytes ? Math.min(1, bytesDownloaded / totalBytes) : 0, - currentFile: file.name - }) - } - }) - basePrev += file.sizeBytes ?? 0 - } - - this.store.markInstalled(entry) - this.emit({ - modelId: entry.id, - status: 'completed', - progress: 1, - bytesDownloaded: totalKnown, - totalBytes: totalKnown - }) - return true - } catch (err) { - const aborted = controller.signal.aborted - this.emit({ - modelId: entry.id, - status: aborted ? 'paused' : 'failed', - progress: 0, - bytesDownloaded: 0, - totalBytes: totalKnown, - error: aborted ? undefined : err instanceof Error ? err.message : String(err) - }) - return false - } finally { - this.aborts.delete(entry.id) - } - } -} diff --git a/packages/models/src/filters.ts b/packages/models/src/filters.ts deleted file mode 100644 index 5b2acba7..00000000 --- a/packages/models/src/filters.ts +++ /dev/null @@ -1,185 +0,0 @@ -// Model filtering + sorting, ported from Off Grid Mobile so desktop has the same -// filters (org / type / source / size / quant) and sorts (recommended / best-fit -// / downloads / size / recency). Pure logic over a normalized FilterableModel; -// the UI renders the option lists and feeds the FilterState. - -import type { Credibility } from './credibility' - -export type ModelTypeFilter = 'all' | 'text' | 'vision' | 'code' | 'image-gen' -export type CredibilityFilter = 'all' | Credibility -export type SizeFilter = 'all' | 'tiny' | 'small' | 'medium' | 'large' -export type SortOption = 'recommended' | 'bestfit' | 'size' | 'downloads' | 'recency' - -export interface FilterState { - orgs: string[] - type: ModelTypeFilter - source: CredibilityFilter - size: SizeFilter - quant: string // 'all' or a quant label - sort: SortOption -} - -export const initialFilterState: FilterState = { - orgs: [], - type: 'all', - source: 'all', - size: 'all', - quant: 'all', - sort: 'recommended' -} - -/** Normalized model the filters/sorts operate on (map HF results into this). */ -export interface FilterableModel { - id: string - name: string - org: string - credibility?: Credibility - params?: number | null - tags?: string[] - downloads?: number - likes?: number - lastModified?: string - minRamGb?: number - files?: { sizeBytes?: number; quant?: string }[] -} - -export const SIZE_OPTIONS = [ - { key: 'tiny', label: 'Tiny (<2B)', min: 0, max: 2 }, - { key: 'small', label: 'Small (2-5B)', min: 2, max: 5 }, - { key: 'medium', label: 'Medium (5-15B)', min: 5, max: 15 }, - { key: 'large', label: 'Large (15B+)', min: 15, max: Infinity } -] as const - -export const MODEL_TYPE_OPTIONS = [ - { key: 'text', label: 'Text' }, - { key: 'vision', label: 'Vision' }, - { key: 'code', label: 'Code' }, - { key: 'image-gen', label: 'Image' } -] as const - -export const CREDIBILITY_OPTIONS = [ - { key: 'offgrid', label: 'Off Grid' }, - { key: 'official', label: 'Official' }, - { key: 'verified-quantizer', label: 'Verified' }, - { key: 'community', label: 'Community' } -] as const - -export const SORT_OPTIONS = [ - { key: 'recommended', label: 'Recommended' }, - { key: 'bestfit', label: 'Best fit' }, - { key: 'downloads', label: 'Downloads' }, - { key: 'size', label: 'Size' }, - { key: 'recency', label: 'Recent' } -] as const - -// Matches a parameter count with a B (billions) or M (millions) unit, e.g. -// "2.2B", "500M", "256m". M is normalized to billions (500M -> 0.5). -const PARAM_RE = /\b(\d+(?:\.\d+)?)\s?([BbMm])\b/ - -/** Parse a billions-of-parameters count from a model name/id, in billions - * ("Qwen3.5-2B" -> 2, "SmolVLM2-500M" -> 0.5). Returns null if none found. */ -export function parseParamCount(nameOrId: string): number | null { - const m = PARAM_RE.exec(nameOrId) - if (!m) return null - const n = Number.parseFloat(m[1]) - return /[Mm]/.test(m[2]) ? n / 1000 : n -} - -/** Detect a model's type from its name + tags. */ -export function getModelType(name: string, tags: string[] = []): ModelTypeFilter { - const n = name.toLowerCase() - const t = tags.map((x) => x.toLowerCase()) - if ( - t.some( - (x) => - x.includes('diffusion') || - x.includes('text-to-image') || - x.includes('image-generation') || - x.includes('diffusers') - ) || - n.includes('stable-diffusion') || - n.includes('sd-') || - n.includes('sdxl') || - n.includes('flux') - ) - return 'image-gen' - if ( - t.some((x) => x.includes('vision') || x.includes('multimodal') || x.includes('image-text')) || - n.includes('vision') || - n.includes('vlm') || - n.includes('-vl') || - n.includes('llava') - ) - return 'vision' - if (t.some((x) => x.includes('code')) || n.includes('code') || n.includes('coder')) return 'code' - return 'text' -} - -/** Lower is better. Ideal model uses ~40% of RAM; penalize >75% (too slow). */ -export function bestFitScore(m: FilterableModel, ramGb: number): number { - const params = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) ?? 0 - const minRam = m.minRamGb ?? params * 0.75 - const ratio = ramGb ? minRam / ramGb : 0 - const penalty = ratio > 0.75 ? (ratio - 0.75) * 4 : 0 - return Math.abs(ratio - 0.4) + penalty -} - -export function hasActiveFilters(state: FilterState): boolean { - return ( - state.orgs.length > 0 || - state.type !== 'all' || - state.source !== 'all' || - state.size !== 'all' || - state.quant !== 'all' - ) -} - -export function applyFilters(models: T[], state: FilterState): T[] { - return models.filter((m) => { - if (state.source !== 'all' && m.credibility !== state.source) return false - if (state.type !== 'all' && getModelType(m.name, m.tags) !== state.type) return false - if (state.orgs.length > 0 && !state.orgs.includes(m.org)) return false - if (state.size !== 'all') { - const p = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) - const opt = SIZE_OPTIONS.find((s) => s.key === state.size) - // Exclude when out of range — and when the size is unknowable, since the - // user explicitly asked for a size band (don't leak unsized models in). - if (opt && (p == null || p < opt.min || p >= opt.max)) return false - } - if (state.quant !== 'all' && m.files && m.files.length > 0) { - if (!m.files.some((f) => f.quant === state.quant)) return false - } - return true - }) -} - -export function applySort( - models: T[], - sort: SortOption, - ramGb = 0 -): T[] { - if (sort === 'recommended') return models - const arr = [...models] - const p = (m: FilterableModel): number => m.params ?? parseParamCount(m.name) ?? 0 - switch (sort) { - case 'bestfit': - return arr.sort((a, b) => bestFitScore(a, ramGb) - bestFitScore(b, ramGb)) - case 'size': - return arr.sort((a, b) => p(a) - p(b)) - case 'downloads': - return arr.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) - case 'recency': - return arr.sort((a, b) => (b.lastModified ?? '').localeCompare(a.lastModified ?? '')) - default: - return arr - } -} - -/** Apply filters then sort in one pass. */ -export function filterAndSort( - models: T[], - state: FilterState, - ramGb = 0 -): T[] { - return applySort(applyFilters(models, state), state.sort, ramGb) -} diff --git a/packages/models/src/hf.ts b/packages/models/src/hf.ts deleted file mode 100644 index 9a98f86c..00000000 --- a/packages/models/src/hf.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Hugging Face integration: search the GGUF model hub and resolve a repo into a -// downloadable ModelEntry (pick a Q4_K_M weight + matching mmproj for vision). -// fetch is injectable so this is unit-testable without the network. - -import type { ModelEntry, ModelFile, ModelKind } from './types' -import { QUANTIZATION_INFO, extractQuantization, isMMProjFile } from './quant' -import { deriveKind } from './capabilities' -import { determineCredibility, type Credibility } from './credibility' -import { getModelType } from './filters' - -// HF pipeline_tag per Off Grid modality — so a tab's search only returns models -// of that kind (text-gen, VLM, diffusion, ASR, TTS) instead of everything. -const KIND_PIPELINE: Record = { - text: 'text-generation', - vision: 'image-text-to-text', - image: 'text-to-image', - voice: 'text-to-speech', - transcription: 'automatic-speech-recognition' -} -// Runtimes that consume GGUF (llama.cpp text/vision, sd.cpp image). Whisper (STT) -// is ggml .bin and Kokoro (TTS) is onnx, so we don't constrain those to gguf. -const GGUF_KINDS: ReadonlySet = new Set(['text', 'vision', 'image']) - -const HF = 'https://huggingface.co' -const HF_API = 'https://huggingface.co/api' - -type FetchLike = ( - url: string, - init?: { headers?: Record } -) => Promise<{ - ok: boolean - status: number - json: () => Promise -}> - -const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as ReturnType - -export interface HFSearchResult { - id: string - name: string - org: string - downloads?: number - likes?: number - lastModified?: string - credibility: Credibility -} - -/** A selectable quantization variant within a HF repo (for the file picker). */ -export interface ModelFileVariant { - fileName: string - quant: string - quality: string - recommended: boolean - sizeBytes: number - downloadUrl: string - /** Matched vision projector for this weight, when the repo is multimodal. */ - mmproj?: { fileName: string; url: string; sizeBytes?: number } -} - -interface HFModel { - id?: string - modelId?: string - downloads?: number - likes?: number - lastModified?: string - siblings?: { rfilename: string; size?: number }[] -} - -const isMmproj = (name: string): boolean => /mmproj|clip/i.test(name) -const baseName = (p: string): string => p.split('/').pop() ?? p - -/** Search the HF hub for models, scoped to a modality (kind) when given so each - * tab only surfaces models it can actually use. */ -export async function searchHuggingFace( - query: string, - opts: { limit?: number; sort?: string; kind?: ModelKind; fetchImpl?: FetchLike } = {} -): Promise { - const fetchImpl = opts.fetchImpl ?? defaultFetch - const kind = opts.kind - const params = new URLSearchParams({ - sort: opts.sort ?? 'downloads', - direction: '-1', - // Over-fetch so post-filtering by detected type still leaves a full page. - limit: String((opts.limit ?? 30) * 2) - }) - // GGUF kinds (text/vision/image): constrain to gguf and scope by the NAME - // heuristic below — HF's pipeline_tag is unreliable on gguf repos (it tags - // plain text models as image-text-to-text). Non-gguf kinds (ASR/TTS) have no - // name signal, so lean on HF's pipeline_tag, which is accurate for them. - if (!kind || GGUF_KINDS.has(kind)) params.set('filter', 'gguf') - else if (kind) params.set('pipeline_tag', KIND_PIPELINE[kind]) - if (query) params.set('search', query) - const res = await fetchImpl(`${HF_API}/models?${params.toString()}`, { - headers: { Accept: 'application/json' } - }) - if (!res.ok) throw new Error(`Hugging Face search failed: HTTP ${res.status}`) - const data = (await res.json()) as HFModel[] - let out = data.map((m) => { - const id = m.id ?? m.modelId ?? '' - const org = id.split('/')[0] ?? '' - return { - id, - name: baseName(id), - org, - downloads: m.downloads, - likes: m.likes, - lastModified: m.lastModified, - credibility: determineCredibility(org) - } - }) - // HF's pipeline tags are inconsistent for gguf repos, so refine text/vision/image - // by the name heuristic too (e.g. keep VLMs off the Text tab and vice versa). - if (kind === 'text') - out = out.filter((m) => { - const t = getModelType(m.name) - return t === 'text' || t === 'code' - }) - else if (kind === 'vision') out = out.filter((m) => getModelType(m.name) === 'vision') - else if (kind === 'image') out = out.filter((m) => getModelType(m.name) === 'image-gen') - return out.slice(0, opts.limit ?? 30) -} - -/** List a repo's GGUF quantization variants (with matched mmproj), for a file - * picker. Sorted recommended-first, then smallest. */ -export async function getModelFiles( - repoId: string, - opts: { fetchImpl?: FetchLike } = {} -): Promise { - const fetchImpl = opts.fetchImpl ?? defaultFetch - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: 'application/json' } - }) - if (!res.ok) return [] - const data = (await res.json()) as HFModel - const gguf = (data.siblings ?? []).filter((f) => f.rfilename.endsWith('.gguf')) - const mmprojFiles = gguf.filter((f) => isMMProjFile(f.rfilename)) - const weights = gguf.filter((f) => !isMMProjFile(f.rfilename)) - const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}` - - const matchMmproj = (weightName: string): ModelFileVariant['mmproj'] | undefined => { - if (mmprojFiles.length === 0) return undefined - const wq = extractQuantization(weightName) - const exact = - wq !== 'Unknown' - ? mmprojFiles.find((f) => extractQuantization(f.rfilename) === wq) - : undefined - const f16 = mmprojFiles.find((f) => { - const l = f.rfilename.toLowerCase() - return (l.includes('f16') || l.includes('fp16')) && !l.includes('bf16') - }) - const pick = exact ?? f16 ?? mmprojFiles[0] - return { fileName: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size } - } - - return weights - .map((f) => { - const quant = extractQuantization(f.rfilename) - const info = QUANTIZATION_INFO[quant] - return { - fileName: baseName(f.rfilename), - quant, - quality: info?.quality ?? 'Unknown', - recommended: info?.recommended ?? false, - sizeBytes: f.size ?? 0, - downloadUrl: url(f.rfilename), - mmproj: matchMmproj(f.rfilename) - } - }) - .sort((a, b) => Number(b.recommended) - Number(a.recommended) || a.sizeBytes - b.sizeBytes) -} - -/** - * Resolve a HF repo into a downloadable ModelEntry: a primary GGUF (preferring - * Q4_K_M) plus a matching mmproj when the repo is multimodal. Returns null if no - * usable GGUF is found. - */ -export async function resolveHuggingFaceModel( - repoId: string, - opts: { kind?: ModelKind; fetchImpl?: FetchLike } = {} -): Promise { - const fetchImpl = opts.fetchImpl ?? defaultFetch - const res = await fetchImpl(`${HF_API}/models/${repoId}`, { - headers: { Accept: 'application/json' } - }) - if (!res.ok) return null - const data = (await res.json()) as HFModel - const siblings = data.siblings ?? [] - const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}` - const org = repoId.split('/')[0] - - // Non-GGUF runtimes our pipeline already supports: whisper transcription reads - // ggml `.bin`; Kokoro/Piper TTS read `.onnx`. Detect these first so a search → - // download works for them, not just llama.cpp GGUF models. - const ggml = siblings.filter((f) => /ggml.*\.bin$/i.test(f.rfilename)) - if (ggml.length > 0) { - // Prefer the multilingual base (good speed/quality default), else smallest. - const pick = - ggml.find((f) => /ggml-base\.bin$/i.test(f.rfilename)) ?? - [...ggml].sort((a, b) => (a.size ?? 0) - (b.size ?? 0))[0] - return { - id: repoId, - name: baseName(repoId), - kind: 'transcription', - org, - files: [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: 'primary' - } - ] - } - } - const onnx = siblings.filter((f) => /\.onnx$/i.test(f.rfilename)) - if (onnx.length > 0 && siblings.every((f) => !f.rfilename.endsWith('.gguf'))) { - const pick = onnx.find((f) => /quant/i.test(f.rfilename)) ?? onnx[0] - const files: ModelFile[] = [ - { - name: baseName(pick.rfilename), - url: url(pick.rfilename), - sizeBytes: pick.size, - role: 'primary' - } - ] - // Piper voices ship a sidecar .onnx.json the runtime needs. - const cfg = siblings.find((f) => f.rfilename === `${pick.rfilename}.json`) - if (cfg) - files.push({ - name: baseName(cfg.rfilename), - url: url(cfg.rfilename), - sizeBytes: cfg.size, - role: 'aux' - }) - return { id: repoId, name: baseName(repoId), kind: 'voice', org, files } - } - - const gguf = siblings.filter((f) => f.rfilename.endsWith('.gguf')) - if (gguf.length === 0) return null - - const weights = gguf.filter((f) => !isMmproj(f.rfilename)) - const mmprojFiles = gguf.filter((f) => isMmproj(f.rfilename)) - const primary = weights.find((f) => /q4_k_m/i.test(f.rfilename)) ?? weights[0] ?? gguf[0] - if (!primary) return null - - const files: ModelFile[] = [ - { - name: baseName(primary.rfilename), - url: url(primary.rfilename), - sizeBytes: primary.size, - role: 'primary' - } - ] - if (mmprojFiles[0]) { - files.push({ - name: baseName(mmprojFiles[0].rfilename), - url: url(mmprojFiles[0].rfilename), - sizeBytes: mmprojFiles[0].size, - role: 'mmproj' - }) - } - - return { - id: repoId, - name: baseName(repoId), - // Same data-derived rule as the curated catalog: a projector ⇒ vision. - kind: deriveKind(files, opts.kind ?? 'text'), - org, - files - } -} diff --git a/packages/models/src/imagegen.ts b/packages/models/src/imagegen.ts deleted file mode 100644 index 71a13aba..00000000 --- a/packages/models/src/imagegen.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Image generation contract: text-to-image AND image-to-image. The actual -// diffusion runtime (stable-diffusion.cpp / CoreML on desktop, MNN/QNN on -// mobile) is a platform adapter implementing ImageGenProvider; this package -// defines the shared request/result shape + capability so UI and orchestration -// are identical across platforms. - -export type ImageGenMode = 'txt2img' | 'img2img' - -export interface ImageGenRequest { - prompt: string - mode: ImageGenMode - negativePrompt?: string - /** Input image for img2img (base64 data URL or local path). */ - initImage?: string - /** img2img denoising strength, 0..1 (how much to change the input). */ - strength?: number - width?: number - height?: number - steps?: number - seed?: number - signal?: AbortSignal -} - -export interface ImageGenResult { - /** Output image (base64 data URL or local path). */ - image: string - seed?: number -} - -/** A platform diffusion runtime. Implemented per-platform, used the same way. */ -export interface ImageGenProvider { - readonly id: string - /** Modes this provider/model supports (e.g. ['txt2img','img2img']). */ - readonly modes: ImageGenMode[] - generate(req: ImageGenRequest): Promise -} - -export function supportsMode(provider: ImageGenProvider, mode: ImageGenMode): boolean { - return provider.modes.includes(mode) -} - -/** Validate a request against a provider's capabilities before running it. */ -export function validateImageGenRequest( - provider: ImageGenProvider, - req: ImageGenRequest -): string | null { - if (!supportsMode(provider, req.mode)) return `provider does not support ${req.mode}` - if (req.mode === 'img2img' && !req.initImage) return 'img2img requires an initImage' - if (!req.prompt.trim()) return 'prompt is required' - return null -} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts deleted file mode 100644 index 5dd92f34..00000000 --- a/packages/models/src/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @offgrid/models - cross-platform model catalog + recommendation + download -// orchestration. Replaces a single fixed LLM with a full, multi-kind model -// manager (text, vision, image, voice, transcription; more soon). The actual -// file IO is a platform DownloadBridge (see ./node for desktop/Electron). - -export * from './types' -export { - CATALOG, - MODEL_KINDS, - RECOMMENDATION_TIERS, - recommendForRam, - modelsByKind -} from './catalog' -export { hasVisionProjector, deriveKind } from './capabilities' -export { ModelDownloader } from './download' -export { searchHuggingFace, resolveHuggingFaceModel, getModelFiles } from './hf' -export type { HFSearchResult, ModelFileVariant } from './hf' -export { QUANTIZATION_INFO, extractQuantization, isMMProjFile, formatFileSize } from './quant' -export type { QuantInfo } from './quant' -export { - determineCredibility, - CREDIBILITY_LABELS, - OFFICIAL_MODEL_AUTHORS, - VERIFIED_QUANTIZERS -} from './credibility' -export type { Credibility } from './credibility' -export * from './providers' -export * from './filters' -export { supportsMode, validateImageGenRequest } from './imagegen' -export type { ImageGenMode, ImageGenRequest, ImageGenResult, ImageGenProvider } from './imagegen' -export { recommendedImageModelId, LIGHT_MODEL_RAM_CEILING_GB } from './recommend-image' -export type { RecommendableModel } from './recommend-image' diff --git a/packages/models/src/providers.ts b/packages/models/src/providers.ts deleted file mode 100644 index 48be173d..00000000 --- a/packages/models/src/providers.ts +++ /dev/null @@ -1,214 +0,0 @@ -// Inference provider abstraction: one interface for local AND remote LLM -// execution. In-package HTTP clients cover OpenAI-compatible servers (the -// desktop's local llama-server on 127.0.0.1, plus LM Studio / LocalAI / OpenAI) -// and Ollama. Mobile's in-process runtime (llama.rn) implements the same -// InferenceProvider interface directly. All shared; platforms inject nothing -// beyond an endpoint (or, for in-process, their own provider). - -export type ChatRole = 'system' | 'user' | 'assistant' -export interface ChatMessage { - role: ChatRole - content: string -} -export interface ChatOptions { - model?: string - temperature?: number - maxTokens?: number - signal?: AbortSignal -} -export interface ProviderModel { - id: string - name: string -} - -/** Local or remote LLM. chat() streams text chunks. */ -export interface InferenceProvider { - readonly id: string - readonly name: string - listModels(): Promise - chat(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable -} - -export type RemoteServerKind = 'openai' | 'ollama' -export interface RemoteServerConfig { - id: string - name: string - kind: RemoteServerKind - /** Base URL. OpenAI-compatible includes the /v1 suffix; Ollama is the host root. */ - endpoint: string - apiKey?: string -} - -interface FetchResponse { - ok: boolean - status: number - json(): Promise - body: ReadableStream | null -} -export type FetchLike = ( - url: string, - init?: { - method?: string - headers?: Record - body?: string - signal?: AbortSignal - } -) => Promise - -const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as Promise - -function authHeaders(apiKey?: string): Record { - return apiKey ? { Authorization: `Bearer ${apiKey}` } : {} -} - -async function* lines(body: ReadableStream): AsyncGenerator { - const reader = body.getReader() - const decoder = new TextDecoder() - let buf = '' - for (;;) { - const { done, value } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - const parts = buf.split('\n') - buf = parts.pop() ?? '' - for (const line of parts) yield line - } - if (buf.trim()) yield buf -} - -/** OpenAI-compatible provider: local llama-server, LM Studio, LocalAI, OpenAI. */ -export function openAICompatibleProvider(cfg: { - id: string - name: string - endpoint: string - apiKey?: string - fetchImpl?: FetchLike -}): InferenceProvider { - const f = cfg.fetchImpl ?? defaultFetch - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/models`, { - headers: { Accept: 'application/json', ...authHeaders(cfg.apiKey) } - }) - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`) - const data = (await res.json()) as { data?: { id: string }[] } - return (data.data ?? []).map((m) => ({ id: m.id, name: m.id })) - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders(cfg.apiKey) }, - body: JSON.stringify({ - model: opts?.model, - messages, - stream: true, - temperature: opts?.temperature, - max_tokens: opts?.maxTokens - }), - signal: opts?.signal - }) - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`) - for await (const line of lines(res.body)) { - const t = line.trim() - if (!t.startsWith('data:')) continue - const data = t.slice(5).trim() - if (data === '[DONE]') return - try { - const j = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] } - const c = j.choices?.[0]?.delta?.content - if (c) yield c - } catch { - // ignore keep-alives / malformed lines - } - } - } - } -} - -/** Ollama provider (/api/tags, /api/chat NDJSON). */ -export function ollamaProvider(cfg: { - id: string - name: string - endpoint: string - fetchImpl?: FetchLike -}): InferenceProvider { - const f = cfg.fetchImpl ?? defaultFetch - return { - id: cfg.id, - name: cfg.name, - async listModels() { - const res = await f(`${cfg.endpoint}/api/tags`, { headers: { Accept: 'application/json' } }) - if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`) - const data = (await res.json()) as { models?: { name: string }[] } - return (data.models ?? []).map((m) => ({ id: m.name, name: m.name })) - }, - async *chat(messages, opts) { - const res = await f(`${cfg.endpoint}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: opts?.model, messages, stream: true }), - signal: opts?.signal - }) - if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`) - for await (const line of lines(res.body)) { - const t = line.trim() - if (!t) continue - try { - const j = JSON.parse(t) as { message?: { content?: string }; done?: boolean } - if (j.message?.content) yield j.message.content - if (j.done) return - } catch { - // ignore - } - } - } - } -} - -/** Build a provider from a remote server config. */ -export function createProvider( - server: RemoteServerConfig, - fetchImpl?: FetchLike -): InferenceProvider { - if (server.kind === 'ollama') { - return ollamaProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - fetchImpl - }) - } - return openAICompatibleProvider({ - id: server.id, - name: server.name, - endpoint: server.endpoint, - apiKey: server.apiKey, - fetchImpl - }) -} - -/** Registry of available providers (local + remote) with an active selection. */ -export class ProviderRegistry { - private providers = new Map() - private activeId: string | null = null - - register(provider: InferenceProvider): void { - this.providers.set(provider.id, provider) - if (!this.activeId) this.activeId = provider.id - } - unregister(id: string): void { - this.providers.delete(id) - if (this.activeId === id) this.activeId = this.providers.keys().next().value ?? null - } - list(): InferenceProvider[] { - return [...this.providers.values()] - } - setActive(id: string): void { - if (this.providers.has(id)) this.activeId = id - } - active(): InferenceProvider | null { - return this.activeId ? (this.providers.get(this.activeId) ?? null) : null - } -} diff --git a/packages/models/src/quant.ts b/packages/models/src/quant.ts deleted file mode 100644 index a3fbfb1b..00000000 --- a/packages/models/src/quant.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Quantization metadata + extraction, ported from Off Grid Mobile so desktop -// and mobile share one definition of quant quality/size/recommendation. - -export interface QuantInfo { - bitsPerWeight: number - quality: string - description: string - recommended: boolean -} - -export const QUANTIZATION_INFO: Record = { - Q2_K: { - bitsPerWeight: 2.625, - quality: 'Low', - description: 'Extreme compression, noticeable quality loss', - recommended: false - }, - Q3_K_S: { - bitsPerWeight: 3.4375, - quality: 'Low-Medium', - description: 'High compression, some quality loss', - recommended: false - }, - Q3_K_M: { - bitsPerWeight: 3.4375, - quality: 'Medium', - description: 'Good compression with acceptable quality', - recommended: false - }, - Q4_0: { - bitsPerWeight: 4, - quality: 'Medium', - description: 'Basic 4-bit quantization', - recommended: false - }, - Q4_K_S: { - bitsPerWeight: 4.5, - quality: 'Medium-Good', - description: 'Good balance of size and quality', - recommended: true - }, - Q4_K_M: { - bitsPerWeight: 4.5, - quality: 'Good', - description: 'Optimal balance - best for most devices', - recommended: true - }, - Q5_K_S: { - bitsPerWeight: 5.5, - quality: 'Good-High', - description: 'Higher quality, larger size', - recommended: false - }, - Q5_K_M: { - bitsPerWeight: 5.5, - quality: 'High', - description: 'Near original quality', - recommended: false - }, - Q6_K: { - bitsPerWeight: 6.5, - quality: 'Very High', - description: 'Minimal quality loss', - recommended: false - }, - Q8_0: { - bitsPerWeight: 8, - quality: 'Excellent', - description: 'Best quality, largest size', - recommended: false - } -} - -/** Extract a quantization label from a GGUF filename. */ -export function extractQuantization(fileName: string): string { - const upper = fileName.toUpperCase() - for (const quant of Object.keys(QUANTIZATION_INFO)) { - if (upper.includes(quant.replace('_', '')) || upper.includes(quant)) return quant - } - const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/) - return match ? match[0].toUpperCase() : 'Unknown' -} - -export function isMMProjFile(fileName: string): boolean { - const lower = fileName.toLowerCase() - return ( - lower.includes('mmproj') || - lower.includes('projector') || - (lower.includes('clip') && lower.endsWith('.gguf')) - ) -} - -export function formatFileSize(bytes: number): string { - if (!bytes) return 'Unknown' - const units = ['B', 'KB', 'MB', 'GB'] - let n = bytes - let i = 0 - while (n >= 1024 && i < units.length - 1) { - n /= 1024 - i++ - } - return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}` -} diff --git a/packages/models/src/recommend-image.ts b/packages/models/src/recommend-image.ts deleted file mode 100644 index f1b88375..00000000 --- a/packages/models/src/recommend-image.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Pure "recommended image model for this machine" decision. Extracted so the -// rule is defined ONCE and unit-tested WITHOUT any UI/Electron. No side effects. -// -// The rule keys off the 'Light' tag (a smaller/lower-memory quant) + kind === -// 'image', NOT a model name — adding another paired full/light quant needs zero -// changes here. On a memory-constrained Mac (<= threshold) recommend the Light -// variant of a model that also ships a full variant; above the threshold prefer -// the full (non-Light) variant of that same family. - -/** The minimal shape the recommendation reads — id, kind, tags. Structural so - * callers can pass either the package `ModelEntry` or a renderer-local model type - * (whose `kind` is a plain string) without a cast. */ -export interface RecommendableModel { - id: string - kind: string - tags?: string[] -} - -/** RAM (GB) at or below which the lighter (Light-tagged) quant is recommended. - * 16GB is the ceiling: verified that the full Q8 DreamShaper pegs memory (~4.7GB - * peak) and can freeze a 16GB Mac, while the Q4 (~3.08GB peak) does not. */ -export const LIGHT_MODEL_RAM_CEILING_GB = 16 - -const hasLightTag = (m: RecommendableModel): boolean => - (m.tags ?? []).some((t) => /^light$/i.test(t)) - -const isVersatile = (m: RecommendableModel): boolean => - (m.tags ?? []).some((t) => /^versatile$/i.test(t)) - -/** Prefer the 'Versatile' all-rounder (DreamShaper) when several models qualify, - * so the badge is stable no matter how many Light variants the catalog lists / - * their order. Falls back to the first candidate. */ -const pickVersatileFirst = (candidates: RecommendableModel[]): RecommendableModel | undefined => - candidates.find(isVersatile) ?? candidates[0] - -/** Family key for pairing a full quant with its Light sibling: the id with any - * trailing quant suffix (e.g. "-Q4") stripped, so both DreamShaper entries map - * to the same family. */ -const familyKey = (m: RecommendableModel): string => m.id.replace(/-Q\d[\w]*$/i, '') - -/** - * The image model id best suited to a machine with `ramGb` RAM, or null when no - * image model qualifies. General over the 'Light' tag: - * - ramGb <= LIGHT_MODEL_RAM_CEILING_GB → prefer a Light-tagged image model; - * - ramGb > ceiling → prefer the full (non-Light) sibling - * of a family that HAS a Light variant. - * The "has a Light sibling" constraint keeps the badge on the versatile default - * family (DreamShaper) rather than an unrelated heavy model. Falls back to any - * Light model when only that exists (small machine) / the family's full entry. - */ -export function recommendedImageModelId( - models: RecommendableModel[], - ramGb: number | null | undefined -): string | null { - if (!ramGb || !Number.isFinite(ramGb)) return null - const images = models.filter((m) => m.kind === 'image') - if (!images.length) return null - - const light = images.filter(hasLightTag) - // Families that ship a Light variant — those are the ones we recommend within. - const lightFamilies = new Set(light.map(familyKey)) - const fullOfLightFamily = images.filter((m) => !hasLightTag(m) && lightFamilies.has(familyKey(m))) - - if (ramGb <= LIGHT_MODEL_RAM_CEILING_GB) { - return (pickVersatileFirst(light) ?? images[0]).id - } - // Above the ceiling: the full sibling of a Light family, else any non-Light image model. - return (pickVersatileFirst(fullOfLightFamily) ?? images.find((m) => !hasLightTag(m)) ?? images[0]) - .id -} diff --git a/packages/models/src/types.ts b/packages/models/src/types.ts deleted file mode 100644 index 5b879680..00000000 --- a/packages/models/src/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Model management types, cross-platform. -// -// Off Grid supports several model KINDS (and more soon): a text LLM, a -// vision/multimodal LLM, image generation, voice (TTS), and transcription (STT). -// The catalog and downloader are kind-agnostic; the host runtime knows how to -// load each kind. - -import type { ImageGenMode } from './imagegen' - -export type ModelKind = 'text' | 'vision' | 'image' | 'voice' | 'transcription' - -export interface ModelFile { - /** Filename on disk, e.g. "Qwen3.5-2B-Q4_K_M.gguf". */ - name: string - /** Download URL (often a Hugging Face resolve URL). */ - url: string - /** Size in bytes when known (for progress + RAM/disk checks). */ - sizeBytes?: number - /** Expected SHA-256 (HuggingFace lfs oid) when known — the download is verified - * against it before being promoted to installed. Absent = no checksum check. */ - sha256?: string - /** Marks an auxiliary file (e.g. a vision mmproj) vs the primary weights. */ - role?: 'primary' | 'mmproj' | 'tokenizer' | 'aux' -} - -export interface ModelEntry { - /** Stable id, usually the HF repo id, e.g. "unsloth/Qwen3.5-2B-GGUF". */ - id: string - name: string - kind: ModelKind - /** Provider/org, e.g. "google", "Qwen", "openai-whisper". */ - org?: string - description?: string - /** Billions of parameters (LLMs); omitted for non-LLM kinds. */ - params?: number - /** Minimum device RAM (GB) recommended. */ - minRamGb?: number - /** Quantization label, e.g. "Q4_K_M". */ - quant?: string - /** Files to download for this model. */ - files: ModelFile[] - /** For image models: which generation modes it supports (txt2img/img2img). */ - imageModes?: ImageGenMode[] - isNew?: boolean - /** Short capability labels shown as chips, e.g. ['Recommended','Fast','Photoreal']. */ - tags?: string[] - /** Which on-device runtime serves this model. Default 'sd-cli' (stable-diffusion.cpp). - * 'mflux' = the bundled MLX runtime (Apple-Silicon-only; fetches its own weights). */ - runtime?: 'sd-cli' | 'mflux' - /** For transcription models: which STT engine serves it. Default 'whisper' when - * omitted. 'parakeet' models are multi-file ONNX sets run by the sherpa-onnx CLI. */ - engine?: 'whisper' | 'parakeet' - /** Release date (ISO yyyy-mm-dd), from the source repo's createdAt. Shown on the - * card and used to surface only recent ("post-Jan-2026") small models. */ - releaseDate?: string -} - -/** RAM tier -> max model size + quant, for recommending a default model. */ -export interface ModelRecommendationTier { - minRamGb: number - maxRamGb: number - maxParams: number - quantization: string -} - -export type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' - -export interface DownloadProgress { - modelId: string - status: DownloadStatus - /** 0..1 across all of the model's files. */ - progress: number - bytesDownloaded: number - totalBytes: number - currentFile?: string - speedBytesPerSec?: number - error?: string -} - -/** Platform file download (Node/Electron streams to disk; RN background downloader). */ -export interface DownloadBridge { - /** Download `url` to `destPath`. Resume if a partial file exists. Returns the - * bytes written. Call onProgress(bytesWritten, totalBytes) as it streams. */ - download( - url: string, - destPath: string, - opts: { onProgress?: (written: number, total: number) => void; signal?: AbortSignal } - ): Promise - /** Whether a fully-downloaded file already exists (size match). */ - exists(destPath: string, expectedBytes?: number): Promise - /** Join the models directory with a filename. */ - pathFor(fileName: string): string -} - -/** Records which models are installed (a memory entity or local store). */ -export interface ModelStore { - markInstalled(entry: ModelEntry): void - isInstalled(modelId: string): boolean - installed(): ModelEntry[] - remove(modelId: string): void -} diff --git a/packages/models/tsconfig.json b/packages/models/tsconfig.json deleted file mode 100644 index 3e9a43f7..00000000 --- a/packages/models/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM"], - "types": ["node"], - "skipLibCheck": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pro b/pro new file mode 160000 index 00000000..89870379 --- /dev/null +++ b/pro @@ -0,0 +1 @@ +Subproject commit 89870379dc57ee588998996c870e256f18323ccc diff --git a/pro/main/licensing/__tests__/device-fingerprint.test.ts b/pro/main/licensing/__tests__/device-fingerprint.test.ts deleted file mode 100644 index def1ac49..00000000 --- a/pro/main/licensing/__tests__/device-fingerprint.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Device fingerprint — the stable per-install id used to claim a Keygen machine - * slot, and the platform tag stored on that machine. - * - * Electron's app.getPath is mocked to a real temp dir (like vault-service.test.ts) - * so persistence is exercised against actual files; node:crypto runs for real. - * process.platform is stubbed per-case for getPlatformTag. The module is reset - * between cases so its in-memory cache doesn't leak across tests. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'fs' -import * as os from 'os' -import * as path from 'path' - -let fakeUserData = '' -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => fakeUserData) } -})) - -async function freshModule(): Promise { - vi.resetModules() - return import('../device-fingerprint') -} - -beforeEach(() => { - fakeUserData = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-test-')) -}) - -afterEach(() => { - fs.rmSync(fakeUserData, { recursive: true, force: true }) -}) - -describe('getPlatformTag', () => { - const realPlatform = process.platform - - function setPlatform(p: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { value: p, configurable: true }) - } - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) - }) - - it('returns "macos" on darwin', async () => { - setPlatform('darwin') - const { getPlatformTag } = await freshModule() - expect(getPlatformTag()).toBe('macos') - }) - - it('returns "windows" on win32', async () => { - setPlatform('win32') - const { getPlatformTag } = await freshModule() - expect(getPlatformTag()).toBe('windows') - }) - - it('returns "linux" on linux', async () => { - setPlatform('linux') - const { getPlatformTag } = await freshModule() - expect(getPlatformTag()).toBe('linux') - }) - - it('passes an unknown platform through unchanged', async () => { - setPlatform('freebsd' as NodeJS.Platform) - const { getPlatformTag } = await freshModule() - expect(getPlatformTag()).toBe('freebsd') - }) -}) - -describe('getDeviceFingerprint', () => { - it('generates a 32-hex-char fingerprint (16 random bytes)', async () => { - const { getDeviceFingerprint } = await freshModule() - const fp = await getDeviceFingerprint() - expect(fp).toMatch(/^[0-9a-f]{32}$/) - }) - - it('is stable across two calls within one process (in-memory cache)', async () => { - const { getDeviceFingerprint } = await freshModule() - const a = await getDeviceFingerprint() - const b = await getDeviceFingerprint() - expect(b).toBe(a) - }) - - it('persists to userData so a reinstall/reboot reuses the same id', async () => { - const first = await freshModule() - const fp1 = await first.getDeviceFingerprint() - - // Simulate a fresh process: reset the module (clears the in-memory cache) but - // keep the same userData dir. The persisted file must be read back verbatim. - const second = await freshModule() - const fp2 = await second.getDeviceFingerprint() - expect(fp2).toBe(fp1) - - const onDisk = fs.readFileSync(path.join(fakeUserData, 'device-fingerprint'), 'utf8').trim() - expect(onDisk).toBe(fp1) - }) - - it('regenerates a different fingerprint for a different install (new userData dir)', async () => { - const first = await freshModule() - const fp1 = await first.getDeviceFingerprint() - - // New "install": different userData dir + cleared module cache. - fakeUserData = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-test-2-')) - const second = await freshModule() - const fp2 = await second.getDeviceFingerprint() - expect(fp2).not.toBe(fp1) - fs.rmSync(fakeUserData, { recursive: true, force: true }) - }) - - it('ignores an empty persisted file and generates a fresh id', async () => { - fs.writeFileSync(path.join(fakeUserData, 'device-fingerprint'), ' ') - const { getDeviceFingerprint } = await freshModule() - const fp = await getDeviceFingerprint() - expect(fp).toMatch(/^[0-9a-f]{32}$/) - }) - - it('writes the fingerprint file with 0600 perms', async () => { - const { getDeviceFingerprint } = await freshModule() - await getDeviceFingerprint() - const mode = fs.statSync(path.join(fakeUserData, 'device-fingerprint')).mode & 0o777 - expect(mode).toBe(0o600) - }) -}) diff --git a/pro/main/licensing/__tests__/keygen-parse.test.ts b/pro/main/licensing/__tests__/keygen-parse.test.ts deleted file mode 100644 index 6835c083..00000000 --- a/pro/main/licensing/__tests__/keygen-parse.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Keygen JSON:API response parsers — the pure functions that turn Keygen's - * validate-key / machine-activate / list-machines wire bodies into our internal - * shapes. The fetch/transport layer is untested shell; these feed it real - * JSON:API fixture objects (no network) and assert the mapping + the branch that - * detects the device-cap (422 MACHINE_LIMIT_EXCEEDED). - */ -import { describe, it, expect } from 'vitest' -import { - toLicense, - parseValidateResult, - parseActivateResult, - parseMachines -} from '../keygen-client' - -describe('toLicense', () => { - it('maps a JSON:API license resource to our KeygenLicense', () => { - const data = { - id: 'lic-1', - attributes: { expiry: '2030-01-01T00:00:00Z', metadata: { plan: 'monthly' }, name: 'Ada' } - } - expect(toLicense(data)).toEqual({ - id: 'lic-1', - expiry: '2030-01-01T00:00:00Z', - metadata: { plan: 'monthly' }, - name: 'Ada' - }) - }) - - it('defaults a lifetime (null-expiry) license and missing fields', () => { - expect(toLicense({ id: 'lic-2' })).toEqual({ - id: 'lic-2', - expiry: null, - metadata: {}, - name: null - }) - }) - - it('returns null for missing data or a resource without an id', () => { - expect(toLicense(undefined)).toBeNull() - expect(toLicense(null)).toBeNull() - expect(toLicense({ attributes: {} })).toBeNull() - }) -}) - -describe('parseValidateResult', () => { - it('parses a VALID validate response (valid=true, license present)', () => { - const body = { - meta: { valid: true, code: 'VALID' }, - data: { id: 'lic-9', attributes: { expiry: null } } - } - const r = parseValidateResult(body) - expect(r.valid).toBe(true) - expect(r.code).toBe('VALID') - expect(r.license?.id).toBe('lic-9') - expect(r.license?.expiry).toBeNull() - }) - - it('parses an EXPIRED response (valid=false, code carried, license still present)', () => { - const body = { - meta: { valid: false, code: 'EXPIRED' }, - data: { id: 'lic-9', attributes: { expiry: '2020-01-01T00:00:00Z' } } - } - const r = parseValidateResult(body) - expect(r.valid).toBe(false) - expect(r.code).toBe('EXPIRED') - expect(r.license?.expiry).toBe('2020-01-01T00:00:00Z') - }) - - it('parses a NO_MACHINE (needs-activation) response with a license to reclaim', () => { - const body = { - meta: { valid: false, code: 'NO_MACHINE' }, - data: { id: 'lic-3', attributes: {} } - } - const r = parseValidateResult(body) - expect(r.code).toBe('NO_MACHINE') - expect(r.license?.id).toBe('lic-3') - }) - - it('falls back to UNKNOWN code, valid=false, null license on a malformed/empty body', () => { - expect(parseValidateResult({})).toEqual({ valid: false, code: 'UNKNOWN', license: null }) - expect(parseValidateResult(undefined)).toEqual({ valid: false, code: 'UNKNOWN', license: null }) - }) -}) - -describe('parseActivateResult', () => { - it('reports ok on a 201 Created', () => { - expect(parseActivateResult(201, {})).toEqual({ ok: true, limitReached: false }) - }) - - it('detects the device cap: 422 with an errors[].code containing LIMIT', () => { - const body = { errors: [{ title: 'Unprocessable', code: 'MACHINE_LIMIT_EXCEEDED' }] } - expect(parseActivateResult(422, body)).toEqual({ ok: false, limitReached: true }) - }) - - it('detects the device cap: 422 with a "machine limit" detail (case-insensitive)', () => { - const body = { errors: [{ detail: 'machine LIMIT has been exceeded for this license' }] } - expect(parseActivateResult(422, body)).toEqual({ ok: false, limitReached: true }) - }) - - it('a 422 that is NOT a limit error is a plain failure, not limitReached', () => { - const body = { errors: [{ code: 'FINGERPRINT_TAKEN', detail: 'already taken' }] } - expect(parseActivateResult(422, body)).toEqual({ ok: false, limitReached: false }) - }) - - it('a non-201 non-422 (e.g. 403) is a plain failure', () => { - expect(parseActivateResult(403, { errors: [{ code: 'FORBIDDEN' }] })).toEqual({ - ok: false, - limitReached: false - }) - }) - - it('handles a 422 with no errors array without throwing', () => { - expect(parseActivateResult(422, {})).toEqual({ ok: false, limitReached: false }) - }) -}) - -describe('parseMachines', () => { - it('maps a machines list, preferring lastHeartbeat for lastSeen', () => { - const body = { - data: [ - { - id: 'm1', - attributes: { - fingerprint: 'fp-1', - platform: 'macos', - name: 'Ada MBP', - lastHeartbeat: '2026-01-01T00:00:00Z', - created: '2025-01-01T00:00:00Z' - } - } - ] - } - // The raw machine, not a UI projection: the licensed-devices list does its own mapping, including - // the heartbeat-then-created fallback for last seen (license-service.listLicensedDevicesForUi). - expect(parseMachines(body)).toEqual([ - { - id: 'm1', - fingerprint: 'fp-1', - hostname: null, - platform: 'macos', - name: 'Ada MBP', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: null, - lastActiveAt: '2026-01-01T00:00:00Z' - } - ]) - }) - - it('drops a machine with no fingerprint, because nothing can be matched to a device', () => { - const body = { data: [{ id: 'm2', attributes: { created: '2025-06-01T00:00:00Z' } }] } - expect(parseMachines(body)).toEqual([]) - }) - - it('returns [] for an empty or malformed body', () => { - expect(parseMachines({})).toEqual([]) - expect(parseMachines(undefined)).toEqual([]) - expect(parseMachines({ data: [] })).toEqual([]) - }) -}) diff --git a/pro/main/licensing/__tests__/keygen-validate.integration.test.ts b/pro/main/licensing/__tests__/keygen-validate.integration.test.ts deleted file mode 100644 index 7ea9ed0e..00000000 --- a/pro/main/licensing/__tests__/keygen-validate.integration.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { validateKey } from '../keygen-client' - -describe('Keygen validation service boundary', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('maps an unknown third-party validation code to UNKNOWN', async () => { - const fetchBoundary = vi.fn(async () => - Response.json({ - meta: { valid: false, code: 'FUTURE_KEYGEN_CODE' }, - data: { id: 'license-1', attributes: { expiry: null } } - }) - ) - vi.stubGlobal('fetch', fetchBoundary) - - const result = await validateKey('test-license-key', 'test-device-fingerprint') - - expect(fetchBoundary).toHaveBeenCalledOnce() - expect(result).toEqual({ - valid: false, - code: 'UNKNOWN', - license: { id: 'license-1', expiry: null, metadata: {}, name: null } - }) - }) -}) diff --git a/pro/main/licensing/__tests__/license-cache.test.ts b/pro/main/licensing/__tests__/license-cache.test.ts deleted file mode 100644 index 8e1b4161..00000000 --- a/pro/main/licensing/__tests__/license-cache.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { decodeLicenseCache, encodeLicenseCache, type ProLicense } from '../license-cache' - -const LICENSE: ProLicense = { - isPro: true, - key: 'KEY', - licenseId: 'license-id', - expiry: null, - verifiedAt: 123 -} - -const plaintext = (): string => JSON.stringify({ enc: false, data: JSON.stringify(LICENSE) }) - -describe('license cache trust policy', () => { - it('rejects an unsigned plaintext entitlement in a packaged build', () => { - expect(() => - decodeLicenseCache(plaintext(), { - packaged: true, - decrypt: () => { - throw new Error('must not decrypt plaintext') - } - }) - ).toThrow('packaged builds reject plaintext license caches') - }) - - it('allows the development-only plaintext fallback outside a packaged build', () => { - expect( - decodeLicenseCache(plaintext(), { - packaged: false, - decrypt: () => { - throw new Error('must not decrypt plaintext') - } - }) - ).toEqual(LICENSE) - }) - - it('round-trips an encrypted cache without trusting the wrapper as entitlement', () => { - const wrapper = encodeLicenseCache(LICENSE, { - packaged: true, - encryptionAvailable: true, - encrypt: (value) => Buffer.from(`sealed:${value}`) - }) - expect(wrapper?.enc).toBe(true) - expect( - decodeLicenseCache(JSON.stringify(wrapper), { - packaged: true, - decrypt: (value) => value.toString().replace(/^sealed:/, '') - }) - ).toEqual(LICENSE) - }) - - it('refuses to persist packaged entitlement when OS encryption is unavailable', () => { - expect( - encodeLicenseCache(LICENSE, { - packaged: true, - encryptionAvailable: false, - encrypt: () => { - throw new Error('must not encrypt') - } - }) - ).toBeNull() - }) - - it('rejects malformed entitlement fields instead of coercing them', () => { - const malformed = JSON.stringify({ - enc: false, - data: JSON.stringify({ ...LICENSE, isPro: 'true' }) - }) - expect(() => decodeLicenseCache(malformed, { packaged: false, decrypt: () => '' })).toThrow( - 'license cache isPro is malformed' - ) - }) -}) diff --git a/pro/main/licensing/__tests__/license-logic.test.ts b/pro/main/licensing/__tests__/license-logic.test.ts deleted file mode 100644 index abbcd99b..00000000 --- a/pro/main/licensing/__tests__/license-logic.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Pro-gate licensing logic — the pure entitlement decisions that drive the whole - * app's pro gate (isProEntitled → isProActive) and the Settings status UI (toInfo). - * - * High blast radius: a wrong `isProActive` either locks out a paying user or hands - * Pro to a lapsed/revoked one. These exercise the real exported functions against - * hand-built license shapes — no Electron, no disk, no network. Time-relative cases - * are computed from Date.now() so they stay correct regardless of when they run. - * - * The revoked/needs-activation code lists are imported from the source (single - * source of truth) rather than re-hardcoded here. - */ -import { describe, it, expect } from 'vitest' -import { PRO_PURCHASE_URL } from '@offgrid/core/shared/product-links' -import { - isProActive, - toInfo, - REVOKED_CODES, - NEEDS_ACTIVATION, - PRO_PAY_PAGE_URL, - type ProLicense -} from '../license-service' - -const HOUR = 3600_000 - -function lic(over: Partial = {}): ProLicense { - return { isPro: true, key: 'K', licenseId: 'L', expiry: null, verifiedAt: 123, ...over } -} - -describe('isProActive', () => { - it('grants Pro for a lifetime key (isPro, null expiry)', () => { - expect(isProActive(lic({ expiry: null }))).toBe(true) - }) - - it('grants Pro for an active monthly key (expiry in the future)', () => { - const future = new Date(Date.now() + 24 * HOUR).toISOString() - expect(isProActive(lic({ expiry: future }))).toBe(true) - }) - - it('denies Pro for an expired monthly key (expiry in the past)', () => { - const past = new Date(Date.now() - HOUR).toISOString() - expect(isProActive(lic({ expiry: past }))).toBe(false) - }) - - it('denies Pro when the expiry is exactly now (<= boundary)', () => { - // isProActive uses `<= Date.now()`, so an instant that has just passed is denied. - const past = new Date(Date.now() - 1).toISOString() - expect(isProActive(lic({ expiry: past }))).toBe(false) - }) - - it('denies Pro when isPro is false even with a future expiry', () => { - const future = new Date(Date.now() + 24 * HOUR).toISOString() - expect(isProActive(lic({ isPro: false, expiry: future }))).toBe(false) - }) - - it('denies Pro when isPro is false and expiry is null', () => { - expect(isProActive(lic({ isPro: false, expiry: null }))).toBe(false) - }) - - it('denies Pro for the EMPTY-style license (no key, not pro)', () => { - const empty: ProLicense = { - isPro: false, - key: null, - licenseId: null, - expiry: null, - verifiedAt: 0 - } - expect(isProActive(empty)).toBe(false) - }) - - it('denies Pro for a revoked license the service marks isPro=false but keeps expiry', () => { - // Mirrors revalidatePro's REVOKED branch: isPro flipped false, stale future expiry left in place. - const future = new Date(Date.now() + 30 * 24 * HOUR).toISOString() - expect(isProActive(lic({ isPro: false, expiry: future }))).toBe(false) - }) - - it('denies Pro when the cached expiry is unparseable', () => { - expect(isProActive(lic({ expiry: 'not-a-date' }))).toBe(false) - }) - - it('denies a claimed entitlement without a key and license id', () => { - expect(isProActive(lic({ key: null }))).toBe(false) - expect(isProActive(lic({ licenseId: null }))).toBe(false) - }) -}) - -describe('toInfo', () => { - it('reports tier=lifetime for an active key with null expiry', () => { - expect(toInfo(lic({ expiry: null }))).toEqual({ - isPro: true, - tier: 'lifetime', - expiry: null, - verifiedAt: 123 - }) - }) - - it('reports tier=monthly for an active key with a future expiry', () => { - const future = new Date(Date.now() + 24 * HOUR).toISOString() - expect(toInfo(lic({ expiry: future }))).toEqual({ - isPro: true, - tier: 'monthly', - expiry: future, - verifiedAt: 123 - }) - }) - - it('reports isPro=false and tier=null for an expired key (still echoes the expiry)', () => { - const past = new Date(Date.now() - HOUR).toISOString() - expect(toInfo(lic({ expiry: past }))).toEqual({ - isPro: false, - tier: null, - expiry: past, - verifiedAt: 123 - }) - }) - - it('reports isPro=false and tier=null when not entitled', () => { - const info = toInfo(lic({ isPro: false })) - expect(info.isPro).toBe(false) - expect(info.tier).toBeNull() - }) - - it('carries verifiedAt through unchanged', () => { - expect(toInfo(lic({ verifiedAt: 987654 })).verifiedAt).toBe(987654) - }) -}) - -describe('validation-code classifiers (single source of truth)', () => { - it('REVOKED_CODES cover the lock-out states', () => { - expect(REVOKED_CODES).toEqual(['EXPIRED', 'SUSPENDED', 'BANNED', 'OVERDUE', 'NOT_FOUND']) - }) - - it('NEEDS_ACTIVATION cover the reclaim-slot states', () => { - expect(NEEDS_ACTIVATION).toEqual(['NO_MACHINE', 'NO_MACHINES', 'FINGERPRINT_SCOPE_MISMATCH']) - }) - - it('the two lists are disjoint — no code both revokes and reactivates', () => { - const overlap = REVOKED_CODES.filter((c) => NEEDS_ACTIVATION.includes(c)) - expect(overlap).toEqual([]) - }) -}) - -describe('purchase destination', () => { - it('uses the canonical shared Pro purchase URL', () => { - expect(PRO_PAY_PAGE_URL).toBe(PRO_PURCHASE_URL) - }) -}) diff --git a/pro/main/licensing/__tests__/license-seat-replacement.integration.test.ts b/pro/main/licensing/__tests__/license-seat-replacement.integration.test.ts deleted file mode 100644 index fadb6bd2..00000000 --- a/pro/main/licensing/__tests__/license-seat-replacement.integration.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * License activation through the real service and Keygen client. Only the third-party HTTP - * boundary and Electron's OS storage boundary are replaced. - */ -import fs from 'node:fs' -import path from 'node:path' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const h = vi.hoisted(() => ({ - userData: `/tmp/offgrid-license-seat-${process.pid}-${process.env.VITEST_POOL_ID ?? '0'}`, - fingerprint: 'current-device-fingerprint' -})) - -vi.mock('electron', () => ({ - app: { - getPath: () => h.userData, - isPackaged: false - }, - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: (value: Buffer) => value.toString() - } -})) - -import { - activateProByKey, - getProLicenseInfo, - setDirectEntitlementActivationOwner -} from '../license-service' -import { installEntitlementActivationFake } from '../../__tests__/helpers/entitlementActivationFake' - -const machine = (id: string, fingerprint: string, lastSeen: string): Record => ({ - type: 'machines', - id, - attributes: { - fingerprint, - platform: 'macos', - name: id, - lastHeartbeat: lastSeen - } -}) - -beforeAll(() => { - fs.mkdirSync(h.userData, { recursive: true }) - fs.writeFileSync(path.join(h.userData, 'device-fingerprint'), h.fingerprint) -}) - -// Activation goes through the personal-mesh registry owner the sync layer registers in production. -// Without it every activation waits for an owner that never arrives and reports network_unavailable. -let activation: ReturnType - -beforeEach(() => { - activation = installEntitlementActivationFake(setDirectEntitlementActivationOwner) -}) - -afterEach(() => { - activation.stop() - vi.unstubAllGlobals() -}) - -afterAll(() => { - fs.rmSync(h.userData, { recursive: true, force: true }) -}) - -describe('Pro activation at the device limit', () => { - it('activates through the registry owner when the licence reports its machine cap', async () => { - const requests: Array<{ method: string; path: string }> = [] - const fetchBoundary = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : input.toString()) - const method = init?.method ?? (input instanceof Request ? input.method : 'GET') - requests.push({ method, path: url.pathname }) - - if (url.pathname.endsWith('/licenses/actions/validate-key')) { - return Response.json({ - meta: { valid: false, code: 'TOO_MANY_MACHINES' }, - data: { - type: 'licenses', - id: 'license-1', - attributes: { expiry: null, metadata: {}, name: 'Pro' } - } - }) - } - if (url.pathname.endsWith('/licenses/license-1/machines')) { - return Response.json({ - data: [ - machine('machine-current', h.fingerprint, '2020-01-01T00:00:00Z'), - machine('machine-oldest', 'oldest-device', '2024-01-01T00:00:00Z'), - machine('machine-newer-1', 'newer-1', '2025-01-01T00:00:00Z'), - machine('machine-newer-2', 'newer-2', '2025-02-01T00:00:00Z'), - machine('machine-newest', 'newest', '2025-03-01T00:00:00Z') - ] - }) - } - if (url.pathname.endsWith('/machines/machine-oldest') && method === 'DELETE') { - return new Response(null, { status: 204 }) - } - if (url.pathname.endsWith('/machines') && method === 'POST') { - return new Response(null, { status: 201 }) - } - return new Response(null, { status: 500 }) - }) - vi.stubGlobal('fetch', fetchBoundary) - - await expect(activateProByKey('license-key')).resolves.toEqual({ ok: true }) - - // A licence at its cap still activates - and which device gives up its seat is the personal-mesh - // registry's decision, not a DELETE this service issues. So the licence service asks Keygen one - // question, then hands the activation to the owner as a transaction. - expect( - requests.map(({ method, path: requestPath }) => ({ - method, - path: requestPath.replace(/^\/v1\/accounts\/[^/]+/, '') - })) - ).toEqual([{ method: 'POST', path: '/licenses/actions/validate-key' }]) - expect(activation.prepared).toEqual([ - { - key: 'license-key', - licenseId: 'license-1', - expiresAt: null, - fingerprint: h.fingerprint, - platform: expect.any(String) - } - ]) - expect(activation.committed).toHaveLength(1) - expect(getProLicenseInfo()).toMatchObject({ isPro: true, tier: 'lifetime' }) - }) -}) diff --git a/resources/bin/llama/libggml-base.0.15.3.dylib b/resources/bin/llama/libggml-base.0.15.3.dylib deleted file mode 100755 index 25b42383..00000000 --- a/resources/bin/llama/libggml-base.0.15.3.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:932dd14d3d436d6d3fb770f873b5dd5d3e29c8bc72b324f1cf5fd91c34e567f1 -size 710040 diff --git a/resources/bin/llama/libggml-base.0.19.0.dylib b/resources/bin/llama/libggml-base.0.19.0.dylib new file mode 100755 index 00000000..69429df2 --- /dev/null +++ b/resources/bin/llama/libggml-base.0.19.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c809715f9cbd9a519b6c498862cc0102152c560aaa9d4695bc18b51ccda69583 +size 729960 diff --git a/resources/bin/llama/libggml-base.0.dylib b/resources/bin/llama/libggml-base.0.dylib index 25b42383..69429df2 100755 --- a/resources/bin/llama/libggml-base.0.dylib +++ b/resources/bin/llama/libggml-base.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:932dd14d3d436d6d3fb770f873b5dd5d3e29c8bc72b324f1cf5fd91c34e567f1 -size 710040 +oid sha256:c809715f9cbd9a519b6c498862cc0102152c560aaa9d4695bc18b51ccda69583 +size 729960 diff --git a/resources/bin/llama/libggml-base.dylib b/resources/bin/llama/libggml-base.dylib index 25b42383..69429df2 100755 --- a/resources/bin/llama/libggml-base.dylib +++ b/resources/bin/llama/libggml-base.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:932dd14d3d436d6d3fb770f873b5dd5d3e29c8bc72b324f1cf5fd91c34e567f1 -size 710040 +oid sha256:c809715f9cbd9a519b6c498862cc0102152c560aaa9d4695bc18b51ccda69583 +size 729960 diff --git a/resources/bin/llama/libggml-blas.0.15.3.dylib b/resources/bin/llama/libggml-blas.0.15.3.dylib deleted file mode 100755 index 7f281658..00000000 --- a/resources/bin/llama/libggml-blas.0.15.3.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d221b928248dad74318ac8f64819bee9a4cab3dc12b7ee12761086591b205023 -size 58776 diff --git a/resources/bin/llama/libggml-blas.0.19.0.dylib b/resources/bin/llama/libggml-blas.0.19.0.dylib new file mode 100755 index 00000000..ca9641af --- /dev/null +++ b/resources/bin/llama/libggml-blas.0.19.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db1752e0b33568dd6d476b1f32da338990149008eb7e32022a67a76235276fac +size 58776 diff --git a/resources/bin/llama/libggml-blas.0.dylib b/resources/bin/llama/libggml-blas.0.dylib index 7f281658..ca9641af 100755 --- a/resources/bin/llama/libggml-blas.0.dylib +++ b/resources/bin/llama/libggml-blas.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d221b928248dad74318ac8f64819bee9a4cab3dc12b7ee12761086591b205023 +oid sha256:db1752e0b33568dd6d476b1f32da338990149008eb7e32022a67a76235276fac size 58776 diff --git a/resources/bin/llama/libggml-blas.dylib b/resources/bin/llama/libggml-blas.dylib index 7f281658..ca9641af 100755 --- a/resources/bin/llama/libggml-blas.dylib +++ b/resources/bin/llama/libggml-blas.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d221b928248dad74318ac8f64819bee9a4cab3dc12b7ee12761086591b205023 +oid sha256:db1752e0b33568dd6d476b1f32da338990149008eb7e32022a67a76235276fac size 58776 diff --git a/resources/bin/llama/libggml-cpu.0.15.3.dylib b/resources/bin/llama/libggml-cpu.0.15.3.dylib deleted file mode 100755 index 79fbf738..00000000 --- a/resources/bin/llama/libggml-cpu.0.15.3.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7a2056510e7ee0ac98a2d3aa12db4d98e43fef759d6522befa99c368e8d26098 -size 933936 diff --git a/resources/bin/llama/libggml-cpu.0.19.0.dylib b/resources/bin/llama/libggml-cpu.0.19.0.dylib new file mode 100755 index 00000000..1951e7f9 --- /dev/null +++ b/resources/bin/llama/libggml-cpu.0.19.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b031a5b1ec65ee088c5687471ae2f8e3fdbcfd2f76416785436d51a165606fd +size 951088 diff --git a/resources/bin/llama/libggml-cpu.0.dylib b/resources/bin/llama/libggml-cpu.0.dylib index 79fbf738..1951e7f9 100755 --- a/resources/bin/llama/libggml-cpu.0.dylib +++ b/resources/bin/llama/libggml-cpu.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a2056510e7ee0ac98a2d3aa12db4d98e43fef759d6522befa99c368e8d26098 -size 933936 +oid sha256:1b031a5b1ec65ee088c5687471ae2f8e3fdbcfd2f76416785436d51a165606fd +size 951088 diff --git a/resources/bin/llama/libggml-cpu.dylib b/resources/bin/llama/libggml-cpu.dylib index 79fbf738..1951e7f9 100755 --- a/resources/bin/llama/libggml-cpu.dylib +++ b/resources/bin/llama/libggml-cpu.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a2056510e7ee0ac98a2d3aa12db4d98e43fef759d6522befa99c368e8d26098 -size 933936 +oid sha256:1b031a5b1ec65ee088c5687471ae2f8e3fdbcfd2f76416785436d51a165606fd +size 951088 diff --git a/resources/bin/llama/libggml-metal.0.15.3.dylib b/resources/bin/llama/libggml-metal.0.15.3.dylib deleted file mode 100755 index 02c89d9f..00000000 --- a/resources/bin/llama/libggml-metal.0.15.3.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:12fb3cf4a8b30d9523257a0b38b5e4bba12e2f1120ca8b7e192dd8bb54ed53a1 -size 833848 diff --git a/resources/bin/llama/libggml-metal.0.19.0.dylib b/resources/bin/llama/libggml-metal.0.19.0.dylib new file mode 100755 index 00000000..760dacbc --- /dev/null +++ b/resources/bin/llama/libggml-metal.0.19.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92649c2b21869720f95588e5445f114c543e58fe5629352e2c58f9ebcee2deca +size 884456 diff --git a/resources/bin/llama/libggml-metal.0.dylib b/resources/bin/llama/libggml-metal.0.dylib index 02c89d9f..760dacbc 100755 --- a/resources/bin/llama/libggml-metal.0.dylib +++ b/resources/bin/llama/libggml-metal.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12fb3cf4a8b30d9523257a0b38b5e4bba12e2f1120ca8b7e192dd8bb54ed53a1 -size 833848 +oid sha256:92649c2b21869720f95588e5445f114c543e58fe5629352e2c58f9ebcee2deca +size 884456 diff --git a/resources/bin/llama/libggml-metal.dylib b/resources/bin/llama/libggml-metal.dylib index 02c89d9f..760dacbc 100755 --- a/resources/bin/llama/libggml-metal.dylib +++ b/resources/bin/llama/libggml-metal.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12fb3cf4a8b30d9523257a0b38b5e4bba12e2f1120ca8b7e192dd8bb54ed53a1 -size 833848 +oid sha256:92649c2b21869720f95588e5445f114c543e58fe5629352e2c58f9ebcee2deca +size 884456 diff --git a/resources/bin/llama/libggml.0.15.3.dylib b/resources/bin/llama/libggml.0.15.3.dylib deleted file mode 100755 index b92082aa..00000000 --- a/resources/bin/llama/libggml.0.15.3.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fb4ff2535f7dd994029cf6d28c34ef0201d43e3d60fa28f90aad181dfea2e797 -size 59808 diff --git a/resources/bin/llama/libggml.0.19.0.dylib b/resources/bin/llama/libggml.0.19.0.dylib new file mode 100755 index 00000000..1496019c --- /dev/null +++ b/resources/bin/llama/libggml.0.19.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c68f15ba708d920e7c90e10cae21f135ff7207557d7cf8ed09fa41002b13fa83 +size 59808 diff --git a/resources/bin/llama/libggml.0.dylib b/resources/bin/llama/libggml.0.dylib index b92082aa..1496019c 100755 --- a/resources/bin/llama/libggml.0.dylib +++ b/resources/bin/llama/libggml.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb4ff2535f7dd994029cf6d28c34ef0201d43e3d60fa28f90aad181dfea2e797 +oid sha256:c68f15ba708d920e7c90e10cae21f135ff7207557d7cf8ed09fa41002b13fa83 size 59808 diff --git a/resources/bin/llama/libggml.dylib b/resources/bin/llama/libggml.dylib index b92082aa..1496019c 100755 --- a/resources/bin/llama/libggml.dylib +++ b/resources/bin/llama/libggml.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb4ff2535f7dd994029cf6d28c34ef0201d43e3d60fa28f90aad181dfea2e797 +oid sha256:c68f15ba708d920e7c90e10cae21f135ff7207557d7cf8ed09fa41002b13fa83 size 59808 diff --git a/resources/bin/llama/libllama-common.0.0.1.dylib b/resources/bin/llama/libllama-common.0.0.1.dylib index 488c0597..e20e43f7 100755 --- a/resources/bin/llama/libllama-common.0.0.1.dylib +++ b/resources/bin/llama/libllama-common.0.0.1.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9539cdbb5756f95c24001a5a194fd62d349c84d397bc3773184f6d036041f993 -size 4940584 +oid sha256:e1a0127f839607263403391b747de77e157781f4475eb961ed5524c59de11142 +size 5216968 diff --git a/resources/bin/llama/libllama-common.0.dylib b/resources/bin/llama/libllama-common.0.dylib index 488c0597..e20e43f7 100755 --- a/resources/bin/llama/libllama-common.0.dylib +++ b/resources/bin/llama/libllama-common.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9539cdbb5756f95c24001a5a194fd62d349c84d397bc3773184f6d036041f993 -size 4940584 +oid sha256:e1a0127f839607263403391b747de77e157781f4475eb961ed5524c59de11142 +size 5216968 diff --git a/resources/bin/llama/libllama-common.dylib b/resources/bin/llama/libllama-common.dylib index 488c0597..e20e43f7 100755 --- a/resources/bin/llama/libllama-common.dylib +++ b/resources/bin/llama/libllama-common.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9539cdbb5756f95c24001a5a194fd62d349c84d397bc3773184f6d036041f993 -size 4940584 +oid sha256:e1a0127f839607263403391b747de77e157781f4475eb961ed5524c59de11142 +size 5216968 diff --git a/resources/bin/llama/libllama-server-impl.dylib b/resources/bin/llama/libllama-server-impl.dylib index f5f4b16d..e8f4962f 100755 --- a/resources/bin/llama/libllama-server-impl.dylib +++ b/resources/bin/llama/libllama-server-impl.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da14e554b41604b832b0d79ca63a798b114466a8a22a4d96bb0ed28d690b57e4 -size 6236456 +oid sha256:49a976134e45d30c532591183cefb1739221686dea58125b18e16f4e42a74b11 +size 6875592 diff --git a/resources/bin/llama/libllama.0.0.1.dylib b/resources/bin/llama/libllama.0.0.1.dylib index 96a5b534..9addfe0d 100755 --- a/resources/bin/llama/libllama.0.0.1.dylib +++ b/resources/bin/llama/libllama.0.0.1.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0323474612c8c0268b6071c1ec7f354f1ea4814356e02410cf20b7ca38b706d8 -size 2505056 +oid sha256:de7eed8a73cd01ba251a329c78655b5fc05dcbb228c0587f708b7a5fb948697e +size 2852928 diff --git a/resources/bin/llama/libllama.0.dylib b/resources/bin/llama/libllama.0.dylib index 96a5b534..9addfe0d 100755 --- a/resources/bin/llama/libllama.0.dylib +++ b/resources/bin/llama/libllama.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0323474612c8c0268b6071c1ec7f354f1ea4814356e02410cf20b7ca38b706d8 -size 2505056 +oid sha256:de7eed8a73cd01ba251a329c78655b5fc05dcbb228c0587f708b7a5fb948697e +size 2852928 diff --git a/resources/bin/llama/libllama.dylib b/resources/bin/llama/libllama.dylib index 96a5b534..9addfe0d 100755 --- a/resources/bin/llama/libllama.dylib +++ b/resources/bin/llama/libllama.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0323474612c8c0268b6071c1ec7f354f1ea4814356e02410cf20b7ca38b706d8 -size 2505056 +oid sha256:de7eed8a73cd01ba251a329c78655b5fc05dcbb228c0587f708b7a5fb948697e +size 2852928 diff --git a/resources/bin/llama/libmtmd.0.0.1.dylib b/resources/bin/llama/libmtmd.0.0.1.dylib index 039657a1..baee5eee 100755 --- a/resources/bin/llama/libmtmd.0.0.1.dylib +++ b/resources/bin/llama/libmtmd.0.0.1.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9ff3ab399007d64d55dc073da9490c2e4a2b99583460e5be49330033f33b954 -size 1030288 +oid sha256:1300e7e760032bcfcab2411de6d73ed5631083deb555a068fb99a0dd7d17b958 +size 1319696 diff --git a/resources/bin/llama/libmtmd.0.dylib b/resources/bin/llama/libmtmd.0.dylib index 039657a1..baee5eee 100755 --- a/resources/bin/llama/libmtmd.0.dylib +++ b/resources/bin/llama/libmtmd.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9ff3ab399007d64d55dc073da9490c2e4a2b99583460e5be49330033f33b954 -size 1030288 +oid sha256:1300e7e760032bcfcab2411de6d73ed5631083deb555a068fb99a0dd7d17b958 +size 1319696 diff --git a/resources/bin/llama/libmtmd.dylib b/resources/bin/llama/libmtmd.dylib index 039657a1..baee5eee 100755 --- a/resources/bin/llama/libmtmd.dylib +++ b/resources/bin/llama/libmtmd.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9ff3ab399007d64d55dc073da9490c2e4a2b99583460e5be49330033f33b954 -size 1030288 +oid sha256:1300e7e760032bcfcab2411de6d73ed5631083deb555a068fb99a0dd7d17b958 +size 1319696 diff --git a/resources/bin/llama/llama-server b/resources/bin/llama/llama-server index 545cbf3f..ebe8448c 100755 --- a/resources/bin/llama/llama-server +++ b/resources/bin/llama/llama-server @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:39b092a6c2b2f6751a4acc522fded6078843a3ca1f8cc50c8b51b35400cf24c8 +oid sha256:8f6f1ec0c74caf51c7c378ee3faa22a5a14966acd404ce39a97e7edc0d59b8df size 33472 diff --git a/resources/style-thumbs/3D_render.png b/resources/style-thumbs/3D_render.png new file mode 100644 index 00000000..97141870 Binary files /dev/null and b/resources/style-thumbs/3D_render.png differ diff --git a/resources/style-thumbs/Anime.png b/resources/style-thumbs/Anime.png new file mode 100644 index 00000000..85d38af6 Binary files /dev/null and b/resources/style-thumbs/Anime.png differ diff --git a/resources/style-thumbs/Cinematic.png b/resources/style-thumbs/Cinematic.png new file mode 100644 index 00000000..55c27b08 Binary files /dev/null and b/resources/style-thumbs/Cinematic.png differ diff --git a/resources/style-thumbs/Fantasy_art.png b/resources/style-thumbs/Fantasy_art.png new file mode 100644 index 00000000..1b9c1abe Binary files /dev/null and b/resources/style-thumbs/Fantasy_art.png differ diff --git a/resources/style-thumbs/Minimal.png b/resources/style-thumbs/Minimal.png new file mode 100644 index 00000000..db686cac Binary files /dev/null and b/resources/style-thumbs/Minimal.png differ diff --git a/resources/style-thumbs/Monochrome.png b/resources/style-thumbs/Monochrome.png new file mode 100644 index 00000000..92f31488 Binary files /dev/null and b/resources/style-thumbs/Monochrome.png differ diff --git a/resources/style-thumbs/Neon.png b/resources/style-thumbs/Neon.png new file mode 100644 index 00000000..58164ead Binary files /dev/null and b/resources/style-thumbs/Neon.png differ diff --git a/resources/style-thumbs/Oil_painting.png b/resources/style-thumbs/Oil_painting.png new file mode 100644 index 00000000..f3595422 Binary files /dev/null and b/resources/style-thumbs/Oil_painting.png differ diff --git a/resources/style-thumbs/Photoreal.png b/resources/style-thumbs/Photoreal.png new file mode 100644 index 00000000..017f3f8d Binary files /dev/null and b/resources/style-thumbs/Photoreal.png differ diff --git a/resources/style-thumbs/Risograph.png b/resources/style-thumbs/Risograph.png new file mode 100644 index 00000000..ba999087 Binary files /dev/null and b/resources/style-thumbs/Risograph.png differ diff --git a/resources/style-thumbs/Sketch.png b/resources/style-thumbs/Sketch.png new file mode 100644 index 00000000..8fd89d53 Binary files /dev/null and b/resources/style-thumbs/Sketch.png differ diff --git a/resources/style-thumbs/Steampunk.png b/resources/style-thumbs/Steampunk.png new file mode 100644 index 00000000..67a7c0bf Binary files /dev/null and b/resources/style-thumbs/Steampunk.png differ diff --git a/resources/style-thumbs/Studio_portrait.png b/resources/style-thumbs/Studio_portrait.png new file mode 100644 index 00000000..c2f22d63 Binary files /dev/null and b/resources/style-thumbs/Studio_portrait.png differ diff --git a/resources/style-thumbs/Surreal.png b/resources/style-thumbs/Surreal.png new file mode 100644 index 00000000..5dcc2442 Binary files /dev/null and b/resources/style-thumbs/Surreal.png differ diff --git a/resources/style-thumbs/Vintage_film.png b/resources/style-thumbs/Vintage_film.png new file mode 100644 index 00000000..58fe79ae Binary files /dev/null and b/resources/style-thumbs/Vintage_film.png differ diff --git a/resources/style-thumbs/Watercolor.png b/resources/style-thumbs/Watercolor.png new file mode 100644 index 00000000..f29c70d4 Binary files /dev/null and b/resources/style-thumbs/Watercolor.png differ diff --git a/scripts/build-keychain-bootstrap.sh b/scripts/build-keychain-bootstrap.sh new file mode 100755 index 00000000..12290b3e --- /dev/null +++ b/scripts/build-keychain-bootstrap.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="$ROOT_DIR/native/keychain-bootstrap/main.cc" +OUTPUT_DIR="${1:-$ROOT_DIR/resources/bin}" +OUTPUT="$OUTPUT_DIR/keychain-bootstrap.node" +NATIVE_ARCH="${OFFGRID_NATIVE_ARCH:-$(node -p 'process.arch')}" + +mkdir -p "$OUTPUT_DIR" + +case "$NATIVE_ARCH" in + arm64) TARGET="arm64-apple-macos13.0" ;; + x86_64) TARGET="x86_64-apple-macos13.0" ;; + *) echo "unsupported macOS architecture: $NATIVE_ARCH" >&2; exit 1 ;; +esac + +xcrun clang++ \ + -O2 \ + -std=c++17 \ + -Wno-deprecated-declarations \ + -target "$TARGET" \ + -bundle \ + -undefined dynamic_lookup \ + -framework CoreFoundation \ + -framework Security \ + "$SOURCE" \ + -o "$OUTPUT" +chmod +x "$OUTPUT" +echo "built $OUTPUT" diff --git a/scripts/build-llama.sh b/scripts/build-llama.sh index 46e2e7f2..f07185fd 100755 --- a/scripts/build-llama.sh +++ b/scripts/build-llama.sh @@ -11,9 +11,13 @@ set -euo pipefail # official llama.cpp release binaries are now minos 26. The only reliable fix is # to build it ourselves with the target pinned. Run in CI before packaging. # -# LLAMA_REF=b9838 MACOS_DEPLOYMENT_TARGET=13.0 scripts/build-llama.sh +# LLAMA_REF=b10369 MACOS_DEPLOYMENT_TARGET=13.0 scripts/build-llama.sh (override; default is package.json offgrid.llamaRef) -LLAMA_REF="${LLAMA_REF:-b9838}" # gemma4/qwen35-capable build +# ONE owner for the version: package.json, where every other version in this repo already lives. It was +# hardcoded here AND in fetch-win-binaries.ps1 AND passed again by two callers - one fact with four homes. +# The macOS source build and the Windows binary fetch must be the same llama.cpp, or grammar and native +# tool-call handling differ between the platforms of a single release. +LLAMA_REF="${LLAMA_REF:-$(node -p "require('$(cd "$(dirname "$0")/.." && pwd)/package.json').offgrid.llamaRef")}" TARGET="${MACOS_DEPLOYMENT_TARGET:-13.0}" # runs on macOS 13+ ROOT="${OFFGRID_BUILD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" DEST="$ROOT/resources/bin/llama" diff --git a/scripts/build-mac-local.sh b/scripts/build-mac-local.sh index dc3e71a9..780cde89 100755 --- a/scripts/build-mac-local.sh +++ b/scripts/build-mac-local.sh @@ -52,7 +52,7 @@ stage_native_helpers() { echo "!! cmake is required to rebuild llama-server and Whisper. Install it before continuing." exit 1 } - MACOS_DEPLOYMENT_TARGET=13.0 LLAMA_REF=b9838 bash scripts/build-llama.sh + MACOS_DEPLOYMENT_TARGET=13.0 bash scripts/build-llama.sh MACOS_DEPLOYMENT_TARGET=13.0 WHISPER_REF=v1.7.4 bash scripts/build-whisper-cli.sh bash scripts/build-meeting-recorder.sh bash scripts/build-dictation-hotkey.sh diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index 9feaed80..8d06de41 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -14,7 +14,7 @@ # 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 +# package.json offgrid.llamaRef) 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 @@ -41,7 +41,7 @@ if ($token) { $ghHeaders['Authorization'] = "Bearer $token" } # Find the download URL of a release asset whose name matches $pattern. With no # $tag it uses the project's LATEST release; with $tag it pins to that exact -# release (e.g. llama.cpp b9838, to match the macOS source build). +# release (package.json offgrid.llamaRef, 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" } @@ -86,7 +86,10 @@ function Copy-Runtime($srcDir, $destName) { # 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' } +# The version has ONE owner: package.json's offgrid.llamaRef, shared with build-llama.sh. Hardcoding it in +# both is how the macOS build and the Windows binaries drift apart within a single release. +$PackageJson = Join-Path (Split-Path $PSScriptRoot -Parent) 'package.json' +$LlamaRef = if ($env:LLAMA_REF) { $env:LLAMA_REF } else { (Get-Content $PackageJson -Raw | ConvertFrom-Json).offgrid.llamaRef } Write-Host "== llama.cpp (pinned $LlamaRef): vulkan primary + cpu fallback ==" try { $x = Expand-Asset 'ggml-org/llama.cpp' 'bin-win-vulkan-x64\.zip$' $LlamaRef diff --git a/scripts/gen-style-thumbs.sh b/scripts/gen-style-thumbs.sh index fbec619e..c9f75d54 100755 --- a/scripts/gen-style-thumbs.sh +++ b/scripts/gen-style-thumbs.sh @@ -1,58 +1,80 @@ #!/usr/bin/env bash -# One-time batch: generate on-device style-preset thumbnails with SDXL-Lightning. -# Runs sd-cli once per style (model reloads each time, but no LLM is resident so -# it's freeze-safe on 16GB). No --vae-tiling (that forced a 69s tiled VAE decode). -set -u -ROOT="/Users/user/wednesday/off-grid-ai/desktop" -MODELS="$HOME/Library/Application Support/Off Grid AI Desktop/models" -THUMBS="$HOME/Library/Application Support/Off Grid AI Desktop/style-thumbs" -SD="$ROOT/resources/bin/sd/sd-cli" -MODEL="$MODELS/sdxl_lightning_4step.q8_0.gguf" -NEG="lowres, blurry, deformed, watermark, text, low quality" -mkdir -p "$THUMBS" - -# Keep the LLM dead so the image model has the RAM to itself. -pkill -f "sd/sd-server" 2>/dev/null -pkill -f "llama/llama-server" 2>/dev/null -sleep 2 - -# key|prompt (key matches the renderer's styleKey sanitization) -# Each style gets a DIFFERENT subject (landscape / animal / object / vehicle / -# architecture) so the grid showcases the *style*, not a gallery of faces. +# Generate bundled style previews through the running Off Grid Desktop gateway. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GATEWAY="${OFFGRID_GATEWAY_URL:-http://127.0.0.1:7878}" +OUT="${STYLE_THUMB_DIR:-$ROOT/resources/style-thumbs}" +NEGATIVE="people, person, human, woman, women, girl, girls, man, men, boy, boys, face, portrait, hands, text, letters, logo, watermark, signature, low quality, blurry, distorted, deformed" + +mkdir -p "$OUT" +curl --fail --silent --max-time 5 "$GATEWAY/health" >/dev/null || { + echo "Off Grid Desktop gateway is not available at $GATEWAY." >&2 + exit 1 +} + +# key|seed|prompt. Every scene is people-free by design. STYLES=( - "Photoreal|a red fox standing in a misty forest, photorealistic, sharp focus, high detail, 50mm photo" - "Cinematic|a lone car on a coastal highway at sunset, cinematic film still, dramatic lighting, shallow depth of field, color graded" - "Anime|a bustling futuristic city street with cherry blossoms, anime illustration, clean lineart, vibrant colors" - "Sketch|an old european cathedral, detailed pencil sketch on paper, monochrome line art" - "Watercolor|a serene mountain lake with pine trees, watercolor painting, soft washes, paper texture" - "Oil_painting|a still life of fruit and a wine bottle on a table, oil painting, visible brushstrokes, classical, rich color" - "Monochrome|a rainy city street with umbrellas, black and white, high contrast, monochrome" - "Neon|a rain-soaked alley in a cyberpunk city, neon-lit, glowing lights, night, moody" - "3D_render|a cute friendly robot character, 3D render, octane, soft studio lighting, subsurface detail" - "Steampunk|a flying steampunk airship above the clouds, brass and gears, victorian, intricate" - "Surreal|floating islands with waterfalls in a dreamlike sky, surreal, imaginative composition" - "Vintage_film|a vintage convertible car on a desert road, vintage film photograph, faded colors, grain, 1970s" - "Minimal|a single sailboat on calm water, minimal flat design, clean, simple shapes, lots of negative space" - "Risograph|a bicycle leaning against a wall, risograph print, halftone texture, limited palette" - "Fantasy_art|a majestic dragon perched on a mountain peak, epic fantasy concept art, dramatic, highly detailed" - "Studio_portrait|a golden retriever dog, studio portrait, soft key light, bokeh background" + "Photoreal|4101|A premium editorial product photograph of a precision mechanical wristwatch on dark slate, empty studio, dramatic side light, realistic metal and glass, crisp micro-detail, restrained neutral palette, professional commercial photography" + "Cinematic|4102|An empty classic sports car on a rain-dark coastal road at blue hour, cinematic film still, anamorphic light, deep contrast, atmospheric mist, sophisticated color grade, no driver, no people" + "Anime|4103|A quiet futuristic botanical research station above the clouds, detailed anime background art, clean linework, luminous color, cinematic composition, no characters, no people" + "Sketch|4104|A grand stone museum interior with a sweeping staircase, architectural graphite sketch, precise perspective, fine cross-hatching, archival paper texture, no people" + "Watercolor|4105|A serene alpine lake with pine forest and distant snow peaks, refined watercolor painting, translucent washes, elegant pigment blooms, cold-pressed paper, no buildings, no people" + "Oil_painting|4106|A museum-quality still life of pears, ceramic vessels, and folded linen on a dark table, classical oil painting, rich glazing, confident brushwork, controlled light, no people" + "Monochrome|4107|An empty modern train platform after rain, fine-art black-and-white photography, graphic geometry, luminous reflections, deep tonal range, no people" + "Neon|4108|A high-end electric motorcycle parked in an empty rain-soaked city alley at night, cyan and magenta neon reflections, precise industrial design, cinematic atmosphere, no rider, no people" + "3D_render|4109|A premium modular desktop speaker on a sculpted pedestal, polished 3D product render, physically based materials, soft studio lighting, subtle shadows, clean art direction, no text, no people" + "Steampunk|4110|An intricate brass astronomical observatory above the clouds, gears, copper pipes, glass lenses, Victorian engineering, dramatic warm light, detailed concept art, no people" + "Surreal|4111|A monumental marble doorway floating above a silent ocean, impossible reflections, dreamlike scale, refined surrealist composition, soft dawn light, no people" + "Vintage_film|4112|An empty mid-century roadside motel and parked convertible at dusk, vintage 1970s film photograph, authentic grain, faded color, understated composition, no people" + "Minimal|4113|A single black ceramic vase and one green branch on an off-white surface, premium minimalist editorial composition, soft natural shadow, ample negative space, no text, no people" + "Risograph|4114|A geometric arrangement of a bicycle, leaves, and sun shapes, professional risograph poster, limited emerald and coral inks, tactile halftone texture, precise registration, no text, no people" + "Fantasy_art|4115|An ancient crystalline fortress on a remote mountain ridge beneath an aurora, epic fantasy environment art, intricate scale, dramatic atmosphere, sophisticated color, no people" + "Studio_portrait|4116|A dignified black Labrador sitting against a charcoal studio backdrop, professional animal portrait, soft key light, detailed fur, natural expression, shallow depth of field, no people" ) -n=0; total=${#STYLES[@]} -for entry in "${STYLES[@]}"; do - n=$((n+1)) - key="${entry%%|*}" - style="${entry#*|}" - out="$THUMBS/$key.png" - echo "[$n/$total] $key -> $out" - DYLD_LIBRARY_PATH="$ROOT/resources/bin/sd" "$SD" \ - -M img_gen \ - -m "$MODEL" \ - -p "$style" \ - -n "$NEG" \ - -o "$out" \ - -W 768 -H 768 --steps 4 --cfg-scale 1.0 --sampling-method euler \ - --diffusion-fa -t 6 -s 42 > "/tmp/thumb-$key.log" 2>&1 - if [ -f "$out" ]; then echo " ok ($(wc -c < "$out") bytes)"; else echo " FAILED (see /tmp/thumb-$key.log)"; fi +TOTAL="${#STYLES[@]}" +INDEX=0 +for ENTRY in "${STYLES[@]}"; do + INDEX=$((INDEX + 1)) + KEY="${ENTRY%%|*}" + REST="${ENTRY#*|}" + SEED="${REST%%|*}" + PROMPT="${REST#*|}" + DEST="$OUT/$KEY.png" + + if [[ -f "$DEST" && "${FORCE_STYLE_THUMBS:-0}" != "1" ]]; then + echo "[$INDEX/$TOTAL] $KEY already exists." + continue + fi + + echo "[$INDEX/$TOTAL] Generating $KEY..." + PAYLOAD="$(node -e ' + const [prompt, negative, seed] = process.argv.slice(1) + process.stdout.write(JSON.stringify({ + prompt, negative_prompt: negative, width: 512, height: 512, + steps: 10, cfg_scale: 2, seed: Number(seed), response_format: "url" + })) + ' "$PROMPT" "$NEGATIVE" "$SEED")" + + RESPONSE="$(curl --fail --silent --show-error --max-time 300 \ + "$GATEWAY/v1/images/generations" -H 'Content-Type: application/json' \ + --data-binary "$PAYLOAD")" + SOURCE="$(node -e ' + const { fileURLToPath } = require("node:url") + let body = "" + process.stdin.setEncoding("utf8") + process.stdin.on("data", chunk => { body += chunk }) + process.stdin.on("end", () => { + const value = JSON.parse(body).data?.[0]?.url + if (typeof value !== "string" || !value.startsWith("file://")) process.exit(1) + process.stdout.write(fileURLToPath(value)) + }) + ' <<<"$RESPONSE")" + + cp "$SOURCE" "$DEST" + rm -f "$SOURCE" + echo "[$INDEX/$TOTAL] Saved $DEST" done -echo "=== DONE: $(ls "$THUMBS"/*.png 2>/dev/null | wc -l) thumbnails ===" + +echo "Generated $(find "$OUT" -maxdepth 1 -type f -name '*.png' | wc -l | tr -d ' ') style previews." diff --git a/scripts/stage-native.mjs b/scripts/stage-native.mjs new file mode 100755 index 00000000..4bc044b6 --- /dev/null +++ b/scripts/stage-native.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +if (process.platform !== 'darwin') process.exit(0) + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const stageDir = path.join(root, 'resources', 'bin') +const moduleCache = path.join(process.env.TMPDIR || '/tmp', 'offgrid-swift-module-cache') +fs.mkdirSync(moduleCache, { recursive: true }) + +function run(script, cwd) { + if (!fs.existsSync(script)) return + const result = spawnSync('bash', [script, stageDir], { + cwd, + stdio: 'inherit', + env: { + ...process.env, + CLANG_MODULE_CACHE_PATH: moduleCache, + SWIFT_MODULECACHE_PATH: moduleCache + } + }) + if (result.error) throw result.error + if (result.status !== 0) process.exit(result.status ?? 1) +} + +run(path.join(root, 'scripts', 'build-keychain-bootstrap.sh'), root) +run(path.join(root, 'pro', 'scripts', 'build-proximity-helper.sh'), path.join(root, 'pro')) diff --git a/scripts/stage-pro-native.mjs b/scripts/stage-pro-native.mjs deleted file mode 100644 index d48f13ea..00000000 --- a/scripts/stage-pro-native.mjs +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs' -import path from 'node:path' -import { spawnSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' - -if (process.platform !== 'darwin') process.exit(0) - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const buildScript = path.join(root, 'pro', 'scripts', 'build-proximity-helper.sh') -if (!fs.existsSync(buildScript)) process.exit(0) - -const stageDir = path.join(root, 'resources', 'bin') -const result = spawnSync('bash', [buildScript, stageDir], { - cwd: path.join(root, 'pro'), - stdio: 'inherit' -}) -if (result.error) throw result.error -process.exit(result.status ?? 1) diff --git a/src/main/__tests__/chat-stream-state.test.ts b/src/main/__tests__/chat-stream-state.test.ts index 6366dce3..fb626a49 100644 --- a/src/main/__tests__/chat-stream-state.test.ts +++ b/src/main/__tests__/chat-stream-state.test.ts @@ -1,8 +1,16 @@ import { beforeEach, describe, expect, it } from 'vitest' import { registerHook, HOOKS } from '../bootstrap/hookRegistry' import { + beginChatImageStream, bindChatStream, + continueChatStreamWithImage, + currentChatStreamMessageId, + endChatStreamForConversation, noteChatStreamDelta, + noteChatStreamImageProgress, + noteChatStreamToolCompleted, + noteChatStreamToolStarted, + takeChatStreamMessageId, endChatStream } from '../chat-stream-state' @@ -17,7 +25,21 @@ import { * Nothing is faked. The hook registry is the app's own, and the only collaborator. */ -type Snapshot = { conversationId: string; content: string; reasoning: string } | null +type Snapshot = { + conversationId: string + content?: string + reasoning?: string + phase?: 'waiting' | 'thinking' | 'answering' | 'loading_image_model' | 'generating_image' + progress?: { current: number; total: number } + tools?: Array<{ + name: string + status: 'running' | 'completed' + result?: string + }> + /** Minted when the turn is bound, so the record that follows keeps the id its frames carried. */ + messageId?: string + completion?: 'record_pending' | 'discarded' +} | null describe('the reply being generated, as published to anything that follows it', () => { let published: Snapshot[] @@ -38,7 +60,15 @@ describe('the reply being generated, as published to anything that follows it', // Empty content, not absent: a consumer can show "generating" immediately, rather than waiting for // the first token to learn a turn exists at all. - expect(published).toEqual([{ conversationId: 'conversation-1', content: '', reasoning: '' }]) + expect(published).toEqual([ + { + conversationId: 'conversation-1', + content: '', + reasoning: '', + phase: 'waiting', + messageId: expect.any(String) + } + ]) }) it('publishes the reply so far, not the delta, so a late consumer is never behind', () => { @@ -51,7 +81,9 @@ describe('the reply being generated, as published to anything that follows it', expect(published.at(-1)).toEqual({ conversationId: 'conversation-1', content: 'Hello world', - reasoning: '' + reasoning: '', + phase: 'answering', + messageId: expect.any(String) }) }) @@ -66,11 +98,13 @@ describe('the reply being generated, as published to anything that follows it', expect(published.at(-1)).toEqual({ conversationId: 'conversation-1', content: 'the answer', - reasoning: 'let me think — more thought' + reasoning: 'let me think — more thought', + phase: 'thinking', + messageId: expect.any(String) }) }) - it('ends with a null snapshot rather than falling silent', () => { + it('ends with an explicit record-pending terminal rather than falling silent', () => { bindChatStream('stream-a', 'conversation-1') noteChatStreamDelta('stream-a', 'done', 'content') @@ -78,7 +112,100 @@ describe('the reply being generated, as published to anything that follows it', // The end IS an event. A consumer that had to infer it from silence would need a timeout, and would // show a phone "still generating" forever whenever a turn failed. - expect(published.at(-1)).toBeNull() + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + completion: 'record_pending' + }) + }) + + it('keeps one reply alive while its deferred image is generated', () => { + bindChatStream('stream-a', 'conversation-1') + noteChatStreamDelta('stream-a', 'I should use the image tool.', 'reasoning') + noteChatStreamDelta('stream-a', 'I will make that image.', 'content') + const messageId = published.at(-1)?.messageId + + expect(continueChatStreamWithImage('stream-a')).toBe(true) + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + content: 'I will make that image.', + reasoning: 'I should use the image tool.', + phase: 'loading_image_model', + messageId + }) + + expect(noteChatStreamImageProgress('conversation-1', 0, 42)).toBe(true) + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + content: 'I will make that image.', + reasoning: 'I should use the image tool.', + phase: 'loading_image_model', + messageId + }) + + expect(noteChatStreamImageProgress('conversation-1', 7, 42)).toBe(true) + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + content: 'I will make that image.', + reasoning: 'I should use the image tool.', + phase: 'generating_image', + progress: { current: 7, total: 42 }, + messageId + }) + + expect(endChatStreamForConversation('conversation-1')).toBe(true) + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + completion: 'record_pending' + }) + }) + + it('reserves one new durable identity when the text row was claimed before its image starts', () => { + bindChatStream('stream-a', 'conversation-1') + noteChatStreamDelta('stream-a', 'I made both images.', 'content') + noteChatStreamToolStarted('stream-a', 'generate_image') + const textMessageId = takeChatStreamMessageId('conversation-1') + expect(textMessageId).toBe(published.at(-1)?.messageId) + + expect(continueChatStreamWithImage('stream-a')).toBe(true) + expect(beginChatImageStream('conversation-1')).toBe(true) + const firstImageMessageId = currentChatStreamMessageId('conversation-1') + expect(firstImageMessageId).toEqual(expect.any(String)) + expect(firstImageMessageId).not.toBe(textMessageId) + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + content: '', + reasoning: '', + phase: 'loading_image_model', + messageId: firstImageMessageId + }) + + // This is what rag:add-message claims for the first separate image row. + expect(takeChatStreamMessageId('conversation-1')).toBe(firstImageMessageId) + + // If another generated image starts before the active stream is retired, it still cannot reuse + // the first image row's claimed identity. + expect(beginChatImageStream('conversation-1')).toBe(true) + const secondImageMessageId = currentChatStreamMessageId('conversation-1') + expect(secondImageMessageId).toEqual(expect.any(String)) + expect(secondImageMessageId).not.toBe(firstImageMessageId) + expect(takeChatStreamMessageId('conversation-1')).toBe(secondImageMessageId) + }) + + it('publishes a tool when it starts, then completes the same row', () => { + bindChatStream('stream-a', 'conversation-1') + noteChatStreamDelta('stream-a', 'I will make that image.', 'content') + + noteChatStreamToolStarted('stream-a', 'generate_image') + expect(published.at(-1)?.tools).toEqual([{ name: 'generate_image', status: 'running' }]) + + noteChatStreamToolCompleted('stream-a', 'generate_image', 'Image generation started') + expect(published.at(-1)?.tools).toEqual([ + { + name: 'generate_image', + status: 'completed', + result: 'Image generation started' + } + ]) }) it('says nothing more once a turn has ended, even if a late delta arrives', () => { @@ -93,6 +220,20 @@ describe('the reply being generated, as published to anything that follows it', expect(published).toHaveLength(afterEnd) }) + it('discards a stopped image stream and releases the unused durable id', () => { + bindChatStream('stream-a', 'conversation-1') + expect(beginChatImageStream('conversation-1')).toBe(true) + expect(currentChatStreamMessageId('conversation-1')).toEqual(expect.any(String)) + + endChatStreamForConversation('conversation-1', 'discarded') + + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-1', + completion: 'discarded' + }) + expect(currentChatStreamMessageId('conversation-1')).toBeUndefined() + }) + it('ends only once, so a repeated end cannot look like a second turn', () => { bindChatStream('stream-a', 'conversation-1') endChatStream('stream-a') @@ -116,22 +257,31 @@ describe('the reply being generated, as published to anything that follows it', expect(published.at(-2)).toEqual({ conversationId: 'conversation-1', content: 'first', - reasoning: '' + reasoning: '', + phase: 'answering', + messageId: expect.any(String) }) expect(published.at(-1)).toEqual({ conversationId: 'conversation-2', content: 'second', - reasoning: '' + reasoning: '', + phase: 'answering', + messageId: expect.any(String) }) endChatStream('stream-b') - expect(published.at(-1)).toBeNull() + expect(published.at(-1)).toEqual({ + conversationId: 'conversation-2', + completion: 'record_pending' + }) // Ending one leaves the other running: its next delta still publishes, with its own text. noteChatStreamDelta('stream-a', ' more', 'content') expect(published.at(-1)).toEqual({ conversationId: 'conversation-1', content: 'first more', - reasoning: '' + reasoning: '', + phase: 'answering', + messageId: expect.any(String) }) }) diff --git a/src/main/__tests__/files-image-probe.test.ts b/src/main/__tests__/files-image-probe.test.ts new file mode 100644 index 00000000..6b7153b0 --- /dev/null +++ b/src/main/__tests__/files-image-probe.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { verifyImageDecodable, type ImageProbe } from '../files-image-probe' + +describe('image decode verification', () => { + it('distinguishes a valid image, damaged bytes, and an unavailable native checker', async () => { + const valid: ImageProbe = () => ({ metadata: async () => ({ width: 16, height: 16 }) }) + const damaged: ImageProbe = () => ({ + metadata: async () => { + throw new Error('invalid image header') + } + }) + + await expect(verifyImageDecodable('/image.png', async () => valid)).resolves.toBe('decodable') + await expect(verifyImageDecodable('/broken.png', async () => damaged)).resolves.toBe( + 'undecodable' + ) + await expect(verifyImageDecodable('/unchecked.png', async () => null)).resolves.toBe( + 'unchecked' + ) + }) +}) diff --git a/src/main/__tests__/image-generation-job-owner.integration.test.ts b/src/main/__tests__/image-generation-job-owner.integration.test.ts index 4d423fae..4490276b 100644 --- a/src/main/__tests__/image-generation-job-owner.integration.test.ts +++ b/src/main/__tests__/image-generation-job-owner.integration.test.ts @@ -12,7 +12,10 @@ import { type ImageGenerationJobRequest, type ImageGenerationRuntime } from '../imagegen/job-service' -import type { ImageGenerationProgressContract } from '../../shared/image-generation-contract' +import type { + ImageGenerationPipelineUpdateContract, + ImageGenerationProgressContract +} from '../../shared/image-generation-contract' import type { ImageGenOutput } from '../imagegen' import type { GeneratedImageSidecar } from '../imagegen/gallery-sidecar' import { generatedImageMetadataJson, type ChatHome } from '@offgrid/sync' @@ -36,12 +39,16 @@ const generatedFile = (name: string): string => { afterAll(() => fs.rmSync(workspace, { recursive: true, force: true })) interface ControlledGeneration { + update(update: ImageGenerationPipelineUpdateContract): void progress(progress: ImageGenerationProgressContract): void succeed(output: ImageGenOutput): void fail(error: unknown): void } -function controlledRuntime(cancelResult = true, saveScopeError?: Error): { +function controlledRuntime( + cancelResult = true, + saveScopeError?: Error +): { runtime: ImageGenerationRuntime generation(): ControlledGeneration savedScopes: { path: string; scope: GeneratedImageSidecar }[] @@ -51,7 +58,7 @@ function controlledRuntime(cancelResult = true, saveScopeError?: Error): { } { let resolveGeneration: ((output: ImageGenOutput) => void) | null = null let rejectGeneration: ((error: unknown) => void) | null = null - let reportProgress: ((progress: ImageGenerationProgressContract) => void) | null = null + let reportUpdate: ((update: ImageGenerationPipelineUpdateContract) => void) | null = null const savedScopes: { path: string; scope: GeneratedImageSidecar }[] = [] const sharedPaths: string[] = [] const notedMessages: { path: string; shownIn: ChatHome }[] = [] @@ -59,8 +66,8 @@ function controlledRuntime(cancelResult = true, saveScopeError?: Error): { return { runtime: { - generate: (_request, onProgress) => { - reportProgress = onProgress + generate: (_request, onUpdate) => { + reportUpdate = onUpdate return new Promise((resolve, reject) => { resolveGeneration = resolve rejectGeneration = reject @@ -87,7 +94,12 @@ function controlledRuntime(cancelResult = true, saveScopeError?: Error): { } }, generation: () => ({ - progress: (progress) => reportProgress?.(progress), + update: (update) => reportUpdate?.(update), + progress: (progress) => + reportUpdate?.({ + stage: progress.phase === 'decoding' ? 'decoding' : 'generating', + progress + }), succeed: (output) => resolveGeneration?.(output), fail: (error) => rejectGeneration?.(error) }), @@ -102,6 +114,7 @@ const request: ImageGenerationJobRequest = { prompt: 'A green cabin rendered while navigating', model: 'local-image-model', conversationId: 'conversation-navigation', + messageId: 'message-navigation', projectId: 'project-navigation', seed: 91, width: 512, @@ -125,7 +138,17 @@ describe('main-owned image generation job journeys', () => { }) await expect(jobs.start(request)).rejects.toThrow('already generating') + boundary.generation().update({ + stage: 'enhancing', + enhancedPrompt: 'A quiet observatory under' + }) + expect(jobs.status()).toMatchObject({ + stage: 'enhancing', + enhancedPrompt: 'A quiet observatory under' + }) + boundary.generation().progress({ step: 2, total: 4, secPerStep: 0.5, phase: 'sampling' }) + expect(jobs.status().stage).toBe('generating') const progress = jobs.status().progress expect(progress).toEqual({ step: 2, total: 4, secPerStep: 0.5, phase: 'sampling' }) if (progress) progress.step = 99 @@ -137,7 +160,8 @@ describe('main-owned image generation job journeys', () => { dataUrl: 'data:image/png;base64,aW1hZ2U=', path: generatedFile('image.png'), seed: 91, - model: 'Local image model' + model: 'Local image model', + prompt: 'A detailed emerald cabin beneath a star-filled sky' } boundary.generation().succeed(output) await expect(generation).resolves.toEqual({ ...output, syncId: jobs.status().id }) @@ -158,6 +182,7 @@ describe('main-owned image generation job journeys', () => { scope: { syncId: jobs.status().id, conversationId: request.conversationId, + messageId: request.messageId, projectId: request.projectId, createdAt: expect.any(String), width: request.width, @@ -166,7 +191,7 @@ describe('main-owned image generation job journeys', () => { // wire's field names drift on one platform without a single assertion noticing - which is // exactly what happened: the Mac wrote `model` and the phone only ever read `modelId`. metadataJson: generatedImageMetadataJson({ - prompt: request.prompt, + prompt: output.prompt, steps: request.steps, seed: output.seed, modelId: output.model @@ -241,7 +266,8 @@ describe('main-owned image generation job journeys', () => { dataUrl: 'data:image/png;base64,aW1hZ2U=', path: generatedFile('image-without-scope.png'), seed: 91, - model: 'Local image model' + model: 'Local image model', + prompt: request.prompt } boundary.generation().succeed(output) @@ -264,7 +290,8 @@ describe('main-owned image generation job journeys', () => { dataUrl: 'data:image/png;base64,aW1hZ2U=', path: '', seed: -1, - model: 'Local image model' + model: 'Local image model', + prompt: 'An unscoped image' } boundary.generation().succeed(output) diff --git a/src/main/__tests__/image-runtime-reliability.integration.dbtest.ts b/src/main/__tests__/image-runtime-reliability.integration.dbtest.ts index affa767a..ceb5fb7b 100644 --- a/src/main/__tests__/image-runtime-reliability.integration.dbtest.ts +++ b/src/main/__tests__/image-runtime-reliability.integration.dbtest.ts @@ -11,7 +11,6 @@ import http from 'node:http' import os from 'node:os' import path from 'node:path' import type { AddressInfo } from 'node:net' -import { LLAMA_SERVER_PORT } from '../../shared/ports' import { createOfflineFetchBoundary, type OfflineFetchBoundary } from './harness/offline-fetch' const hostFetch = globalThis.fetch.bind(globalThis) @@ -258,10 +257,11 @@ beforeAll(async () => { afterAll(async () => { const ownedProcessIds = startedProcessIds() + const runtimePort = llm.getPort() stopModelServer() llm.stop() await waitFor( - async () => (await portIsAvailable(gatewayPort)) && (await portIsAvailable(LLAMA_SERVER_PORT)), + async () => (await portIsAvailable(gatewayPort)) && (await portIsAvailable(runtimePort)), 'owned model ports to be released' ) await waitFor( @@ -338,7 +338,7 @@ describe('multimodal runtime reliability', () => { }, 20_000) it('keeps local chat usable when external network reachability is unavailable', async () => { - startModelServer(gatewayPort) + await startModelServer(gatewayPort) await expect(fetch('https://example.invalid/health')).rejects.toThrow( 'network unavailable in offline integration fixture: https://example.invalid' ) @@ -437,7 +437,7 @@ describe('multimodal runtime reliability', () => { expect(llm.isReady()).toBe(true) expect(lineCount(fixture.llamaLog) - startsBefore).toBe(1) - startModelServer(gatewayPort) + await startModelServer(gatewayPort) const health = await fetch(`http://127.0.0.1:${String(gatewayPort)}/v1`) expect(health.status).toBe(200) }) @@ -447,7 +447,7 @@ describe('multimodal runtime reliability', () => { expect(initialAnswer).toBe('chat recovered') const startsBefore = lineCount(fixture.llamaLog) - const crash = await fetch(`http://127.0.0.1:${String(LLAMA_SERVER_PORT)}/test/crash`, { + const crash = await fetch(`http://127.0.0.1:${String(llm.getPort())}/test/crash`, { method: 'POST' }) expect(crash.status).toBe(200) diff --git a/src/main/__tests__/media-server.integration.test.ts b/src/main/__tests__/media-server.integration.test.ts index 2969c0ca..a288cdb8 100644 --- a/src/main/__tests__/media-server.integration.test.ts +++ b/src/main/__tests__/media-server.integration.test.ts @@ -10,6 +10,7 @@ import { LoopbackMediaServer } from '../media-server' import { localMediaRoots } from '../media-roots' const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-media-server-')) +const resources = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-media-resources-')) const fixtures = { image: { dir: 'captures', @@ -27,7 +28,7 @@ const fixtures = { } as const const server = new LoopbackMediaServer({ - roots: localMediaRoots(profile), + roots: localMediaRoots(profile, [resources]), port: 0, token: 'integration' }) @@ -43,11 +44,14 @@ beforeAll(() => { fs.mkdirSync(path.join(profile, fixture.dir), { recursive: true }) fs.writeFileSync(fixturePath(key), fixture.bytes) } + fs.mkdirSync(path.join(resources, 'style-thumbs'), { recursive: true }) + fs.writeFileSync(path.join(resources, 'style-thumbs', 'Photoreal.png'), fixtures.image.bytes) }) afterAll(async () => { await server.close() fs.rmSync(profile, { recursive: true, force: true }) + fs.rmSync(resources, { recursive: true, force: true }) }) describe('loopback media server integration', () => { @@ -82,6 +86,16 @@ describe('loopback media server integration', () => { expect(Buffer.from(await response.arrayBuffer())).toEqual(fixtures.audio.bytes.subarray(-4)) }) + it('serves a bundled style preview through the same admitted media path', async () => { + const preview = path.join(resources, 'style-thumbs', 'Photoreal.png') + const url = await server.urlFor(preview) + const response = await fetch(url!) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('image/png') + expect(Buffer.from(await response.arrayBuffer())).toEqual(fixtures.image.bytes) + }) + it('does not issue URLs outside the media roots or for missing files', async () => { const outside = path.join(profile, 'memories.db') fs.writeFileSync(outside, 'private') diff --git a/src/main/__tests__/mime.test.ts b/src/main/__tests__/mime.test.ts index d27f47e5..ba2449a2 100644 --- a/src/main/__tests__/mime.test.ts +++ b/src/main/__tests__/mime.test.ts @@ -59,16 +59,31 @@ describe('mimeForExt — single source of truth for ext -> MIME', () => { }) }) -// tools.ts is a coverage-excluded I/O shell (agentic loop). Guard its webp fix by -// reading the source (§D contract guard): it must route attachments through the -// shared map, never re-inline the old `endsWith('.png') ? png : jpeg` guess that -// mislabelled webp. Fails-before (the inline was present) / passes-after. -describe('tools.ts attachment MIME — no re-inlined png/jpeg guess', () => { - const src = readFileSync(join(__dirname, '../tools.ts'), 'utf8') - it('imports the shared ext->MIME resolver', () => { - expect(src).toContain("import { mimeFromExt } from './model-server/data-url'") +// tools.ts is a coverage-excluded I/O shell (agentic loop). Guard the webp fix by reading the +// source (§D contract guard): an attachment's MIME must come from the shared map, never from a +// re-inlined `endsWith('.png') ? png : jpeg` guess that mislabelled webp. +// +// The guard moved with the code. tools.ts no longer resolves MIME at all - reading image bytes is +// owned by llm/read-images.ts, which BOTH the agentic path and the plain chat path now call, so +// there is one decoder instead of two that could disagree. So the assertion is now "tools delegates +// and does not decode", plus "the one decoder uses the shared resolver". +describe('attachment MIME — one decoder, no re-inlined png/jpeg guess', () => { + const toolsSrc = readFileSync(join(__dirname, '../tools.ts'), 'utf8') + const readImagesSrc = readFileSync(join(__dirname, '../llm/read-images.ts'), 'utf8') + + it('tools.ts delegates image decoding instead of doing its own', () => { + expect(toolsSrc).toContain("import { readImages } from './llm/read-images'") + // No second decoder: the inline readFileSync/base64 loop it used to carry is gone. + expect(toolsSrc).not.toMatch(/readFileSync\([^)]*\)\.toString\('base64'\)/) + }) + + it('the one decoder resolves MIME through the shared map', () => { + expect(readImagesSrc).toContain('imageMime') }) - it('does not inline the .png-or-jpeg ternary that mislabelled webp', () => { - expect(src).not.toMatch(/endsWith\('\.png'\)\s*\?\s*'image\/png'\s*:\s*'image\/jpeg'/) + + it('neither inlines the .png-or-jpeg ternary that mislabelled webp', () => { + const ternary = /endsWith\('\.png'\)\s*\?\s*'image\/png'\s*:\s*'image\/jpeg'/ + expect(toolsSrc).not.toMatch(ternary) + expect(readImagesSrc).not.toMatch(ternary) }) }) diff --git a/src/main/__tests__/model-server-image.integration.dbtest.ts b/src/main/__tests__/model-server-image.integration.dbtest.ts index 00b4790d..09290876 100644 --- a/src/main/__tests__/model-server-image.integration.dbtest.ts +++ b/src/main/__tests__/model-server-image.integration.dbtest.ts @@ -135,7 +135,14 @@ describe('model gateway image generation', () => { expect(response.headers.get('content-type')).toContain('application/json') expect(response.headers.get('x-request-id')).toBeTruthy() expect(body).toMatchObject({ - data: [{ b64_json: PNG_BASE64, seed: 314, model: MODEL_NAME }], + data: [ + { + b64_json: PNG_BASE64, + revised_prompt: 'A green cabin under stars', + seed: 314, + model: MODEL_NAME + } + ], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }) diff --git a/src/main/__tests__/ogcapture-path.test.ts b/src/main/__tests__/ogcapture-path.test.ts new file mode 100644 index 00000000..df145bef --- /dev/null +++ b/src/main/__tests__/ogcapture-path.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { capturePathFromUrl } from '../ogcapture-serve' + +/** + * The Windows preview defect, held down where it can be proved without a protocol handler. + * + * An image generated on Windows, the file was on disk, download worked, and only the preview broke. The + * handler sliced the scheme off the URL string - but a URL is parsed, not cut. The authority comes before + * the path, so a drive letter lands in the host and loses its colon. + */ +describe('capturePathFromUrl', () => { + it('keeps a Windows drive letter, colon and all', () => { + // What the old slice produced: 'C/Users/oga/AppData/Roaming/Off Grid AI Desktop/generated-images/a.png' + expect( + capturePathFromUrl( + 'ogcapture://C:/Users/oga/AppData/Roaming/Off Grid AI Desktop/generated-images/a.png' + ) + ).toBe('C:/Users/oga/AppData/Roaming/Off Grid AI Desktop/generated-images/a.png') + }) + + it('leaves a POSIX path exactly as it was, which is why macOS never saw the fault', () => { + expect(capturePathFromUrl('ogcapture:///Users/user/Library/generated-images/a.png')).toBe( + '/Users/user/Library/generated-images/a.png' + ) + }) + + it('decodes the escaping a real path needs', () => { + expect(capturePathFromUrl('ogcapture:///Users/user/Off%20Grid/a%20b.png')).toBe( + '/Users/user/Off Grid/a b.png' + ) + expect(capturePathFromUrl('ogcapture://C%3A/Users/oga/a%20b.png')).toBe( + 'C:/Users/oga/a b.png' + ) + }) + + it('treats a longer authority as path, since this scheme has no host', () => { + // Never invents a drive out of something that is not one letter. + expect(capturePathFromUrl('ogcapture://relative/a.png')).toBe('relative/a.png') + }) +}) diff --git a/src/main/__tests__/release-packaging.integration.test.ts b/src/main/__tests__/release-packaging.integration.test.ts index 86c99ac2..6cb55f1f 100644 --- a/src/main/__tests__/release-packaging.integration.test.ts +++ b/src/main/__tests__/release-packaging.integration.test.ts @@ -236,7 +236,12 @@ describe.sequential('release packaging integration', () => { 'src/renderer/src/bootstrap/loadProFeaturesRenderer.ts', 'src/renderer/src/components/pro/UpgradeScreen.tsx', 'pro/main/index.ts', - 'pro/renderer/index.tsx' + // The renderer entry is named by what it DELIVERS, not by the file that forwards it. + // `pro/renderer/index.tsx` is nothing but re-exports, so Rollup resolves them straight to + // their targets and the shell never appears in a sourcemap - an artifact that carries the + // entire pro renderer read as one that carried none of it. + 'pro/renderer/activate-renderer.ts', + 'pro/renderer/screens/ClipboardPopup.tsx' ]) ) expect(pro).toContain('[pro] main activated') diff --git a/src/main/__tests__/secure-storage-bootstrap.test.ts b/src/main/__tests__/secure-storage-bootstrap.test.ts new file mode 100644 index 00000000..798e24b6 --- /dev/null +++ b/src/main/__tests__/secure-storage-bootstrap.test.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { repairMissingDefaultKeychainAtBootstrap } from '../secure-storage-bootstrap' + +afterEach(() => vi.restoreAllMocks()) + +describe('secure storage bootstrap', () => { + it('does nothing outside macOS and reports a missing native bridge', () => { + const exists = vi.spyOn(fs, 'existsSync').mockReturnValue(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + expect(repairMissingDefaultKeychainAtBootstrap('linux', false)).toBeNull() + expect(exists).not.toHaveBeenCalled() + expect(repairMissingDefaultKeychainAtBootstrap('darwin', false)).toBeNull() + expect(error).toHaveBeenCalledWith('[secure-storage] Keychain recovery bridge is missing') + }) + + it.each([ + [0, 'healthy', 'The user default Keychain is available.'], + [1, 'repaired', 'The existing login Keychain was restored for Off Grid secure storage.'], + [2, 'unavailable', 'The login Keychain is unavailable and was not changed.'], + [3, 'failed', 'macOS refused to restore the existing login Keychain.'] + ] as const)('maps native status %s to %s', (nativeStatus, status, detail) => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + vi.spyOn(process, 'dlopen').mockImplementation((nativeModule) => { + ;(nativeModule as NodeModule).exports = { status: nativeStatus, osStatus: -25300 } + }) + + expect(repairMissingDefaultKeychainAtBootstrap('darwin', false)).toEqual({ + status, + detail, + osStatus: -25300 + }) + }) + + it('uses the packaged helper and rejects an invalid native response', () => { + const originalResourcesPath = process.resourcesPath + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: '/private/tmp/offgrid-packaged' + }) + const exists = vi.spyOn(fs, 'existsSync').mockReturnValue(true) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(process, 'dlopen').mockImplementation((nativeModule) => { + ;(nativeModule as NodeModule).exports = { status: 99, osStatus: 'not-a-number' } + }) + + expect(repairMissingDefaultKeychainAtBootstrap('darwin', true)).toBeNull() + expect(exists).toHaveBeenCalledWith(expect.stringContaining('/bin/keychain-bootstrap.node')) + expect(error).toHaveBeenCalledWith( + '[secure-storage] Keychain recovery returned an invalid response' + ) + if (originalResourcesPath === undefined) Reflect.deleteProperty(process, 'resourcesPath') + else Object.defineProperty(process, 'resourcesPath', { value: originalResourcesPath }) + }) + + it('contains a native bridge failure and keeps bootstrap available', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + const failure = new Error('native bridge unavailable') + vi.spyOn(process, 'dlopen').mockImplementation(() => { + throw failure + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + expect(repairMissingDefaultKeychainAtBootstrap('darwin', false)).toBeNull() + expect(error).toHaveBeenCalledWith('[secure-storage] Keychain recovery failed', failure) + }) +}) diff --git a/src/main/__tests__/tool-content.test.ts b/src/main/__tests__/tool-content.test.ts deleted file mode 100644 index 15d2389a..00000000 --- a/src/main/__tests__/tool-content.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { buildUserContent } from '../tool-content' - -describe('buildUserContent', () => { - it('returns a plain string when there are no images', () => { - expect(buildUserContent('explain this', [])).toBe('explain this') - expect(buildUserContent('hi')).toBe('hi') - }) - - it('returns a multimodal array (text + image_url) when images are attached', () => { - // Regression: in tools/connectors mode the chat dropped image attachments — - // the user turn was a plain string, so the vision model never saw the image. - const out = buildUserContent('explain the image', [ - 'data:image/png;base64,AAAA', - 'data:image/jpeg;base64,BBBB' - ]) - expect(Array.isArray(out)).toBe(true) - expect(out).toEqual([ - { type: 'text', text: 'explain the image' }, - { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, - { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,BBBB' } } - ]) - }) -}) diff --git a/src/main/__tests__/tools-loop.dbtest.ts b/src/main/__tests__/tools-loop.dbtest.ts index 349cc22e..ac319808 100644 --- a/src/main/__tests__/tools-loop.dbtest.ts +++ b/src/main/__tests__/tools-loop.dbtest.ts @@ -258,7 +258,7 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam expect(r.answer).toBe('Here is your image.') }) - it('last generate_image call wins when the model requests more than one', async () => { + it('keeps every generate_image request in tool-call order', async () => { fake.enqueue( { toolCalls: [ @@ -269,7 +269,7 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam { content: 'done' } ) const r = await toolChat('two pictures', [], { imageAvailable: true }) - expect(r.imageRequest).toEqual({ prompt: 'second' }) + expect(r.imageRequests).toEqual([{ prompt: 'first' }, { prompt: 'second' }]) }) it('does not record an imageRequest when the prompt is empty', async () => { diff --git a/src/main/__tests__/transferred-model-lifecycle.dbtest.ts b/src/main/__tests__/transferred-model-lifecycle.dbtest.ts new file mode 100644 index 00000000..a910cd0a --- /dev/null +++ b/src/main/__tests__/transferred-model-lifecycle.dbtest.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const originalDataDir = process.env.OFFGRID_DATA_DIR +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-transferred-lifecycle-')) +const modelsDir = path.join(dataDir, 'models') + +function validGguf(marker: number): Buffer { + return Buffer.concat([Buffer.from('GGUF', 'ascii'), Buffer.alloc(2_048, marker)]) +} + +beforeAll(() => { + process.env.OFFGRID_DATA_DIR = dataDir + fs.mkdirSync(modelsDir, { recursive: true }) +}) + +afterAll(() => { + if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR + else process.env.OFFGRID_DATA_DIR = originalDataDir + fs.rmSync(dataDir, { recursive: true, force: true }) +}) + +describe('transferred model lifecycle', () => { + it('registers, activates, offers, protects, and deletes one exact vision package', async () => { + const manager = await import('../models-manager') + const familyId = 'off-grid/db-vision-family' + const primary = 'db-vision-q4.gguf' + const projector = 'mmproj-db-vision-f16.gguf' + const primaryBytes = validGguf(1) + const projectorBytes = validGguf(2) + fs.writeFileSync(path.join(modelsDir, primary), primaryBytes) + fs.writeFileSync(path.join(modelsDir, projector), projectorBytes) + + const registered = await manager.registerTransferredModel({ + id: familyId, + name: 'DB vision package', + kind: 'text', + source: 'downloaded', + files: [ + { name: primary, sizeBytes: primaryBytes.length, role: 'primary' }, + { name: projector, sizeBytes: projectorBytes.length, role: 'projector' } + ] + }) + + expect(registered).toMatchObject({ success: true }) + expect(registered.id).toMatch(/^model-package-v1:[0-9a-f]{64}$/) + const packageId = registered.id! + expect(await manager.listInstalled()).toContain(packageId) + expect((await manager.getCatalog()).models).toEqual( + expect.arrayContaining([expect.objectContaining({ id: packageId, kind: 'vision' })]) + ) + expect(await manager.getVisionStatuses()).toMatchObject({ + [packageId]: { supportsVision: true, projectorInstalled: true } + }) + expect(await manager.getTransferableModel(familyId)).toMatchObject({ + id: packageId, + familyId, + packageIdentity: packageId, + kind: 'vision', + files: [ + { name: primary, sizeBytes: primaryBytes.length }, + { name: projector, sizeBytes: projectorBytes.length } + ] + }) + expect((await manager.getStorageInfo()).orphans).toEqual([]) + + expect(await manager.setActiveModel(familyId)).toEqual({ success: true }) + expect(manager.getActiveModalities().text).toBe(packageId) + expect(await manager.reconcileActiveModelProjector()).toBe(false) + + expect(await manager.deleteModel(familyId)).toEqual({ success: true, freedFiles: 2 }) + expect(manager.getActiveModalities().text).toBeNull() + expect(await manager.listInstalled()).not.toContain(packageId) + expect(fs.existsSync(path.join(modelsDir, primary))).toBe(false) + expect(fs.existsSync(path.join(modelsDir, projector))).toBe(false) + }) +}) diff --git a/src/main/__tests__/whisper-cli-build.integration.test.ts b/src/main/__tests__/whisper-cli-build.integration.test.ts index d63168b9..d6872086 100644 --- a/src/main/__tests__/whisper-cli-build.integration.test.ts +++ b/src/main/__tests__/whisper-cli-build.integration.test.ts @@ -80,6 +80,11 @@ fi set -euo pipefail if [ "${'$'}1" = "-l" ]; then printf 'Load command 1\n cmd LC_BUILD_VERSION\n minos ${minos}\n' + # The staged binary carries the @loader_path rpath install_name_tool adds, because the script + # GATES on it: without one the dylibs sitting right beside whisper-cli are unreachable to dyld, + # which is the "nothing happened" voice-note bug. A fake that never reports a load command the + # script checks makes the script fail for a reason the test is not about. + printf 'Load command 2\n cmd LC_RPATH\n cmdsize 32\n path @loader_path (offset 12)\n' exit 0 fi file="${'$'}2" @@ -191,7 +196,10 @@ describe('pinned Whisper CLI build and staging', () => { it('keeps release and local builds on the same pinned native-engine scripts', () => { const release = fs.readFileSync(path.join(REPO_ROOT, '.github/workflows/release.yml'), 'utf8') const local = fs.readFileSync(path.join(REPO_ROOT, 'scripts/build-mac-local.sh'), 'utf8') - const llamaBuild = 'MACOS_DEPLOYMENT_TARGET=13.0 LLAMA_REF=b9838 bash scripts/build-llama.sh' + // No LLAMA_REF here on purpose. The version has one owner - package.json `offgrid.llamaRef` - and a caller that + // passes its own would silently build a different engine than the Windows fetch pulls, which is the + // drift this test exists to catch. + const llamaBuild = 'MACOS_DEPLOYMENT_TARGET=13.0 bash scripts/build-llama.sh' const whisperBuild = 'MACOS_DEPLOYMENT_TARGET=13.0 WHISPER_REF=v1.7.4 bash scripts/build-whisper-cli.sh' @@ -199,6 +207,9 @@ describe('pinned Whisper CLI build and staging', () => { expect(source).toContain(llamaBuild) expect(source).toContain(whisperBuild) } + for (const source of [release, local]) { + expect(source).not.toMatch(/LLAMA_REF=/) + } expect(local).toContain('bash scripts/fetch-parakeet.sh') expect(local).toContain('node scripts/probe-packaged-helpers.mjs "$app_dir"') expect(local.match(/^\s+verify_packaged_helpers$/gm)).toHaveLength(2) diff --git a/src/main/chat-stream-state.ts b/src/main/chat-stream-state.ts index 6bd293c7..306a9c61 100644 --- a/src/main/chat-stream-state.ts +++ b/src/main/chat-stream-state.ts @@ -11,28 +11,111 @@ // because ending IS a snapshot. import { callHook, HOOKS } from './bootstrap/hookRegistry' +import { + completeChatStreamTool, + startChatStreamTool, + type ChatStreamCompletion, + type ChatStreamPhase, + type ChatStreamProgress, + type ChatStreamTool +} from '@offgrid/sync' +import type { ActiveChatStreamContract } from '../shared/ipc-contracts' interface ActiveStream { conversationId: string content: string reasoning: string + phase: ChatStreamPhase + progress?: ChatStreamProgress + tools?: ChatStreamTool[] + /** + * The id this reply will be STORED under, when the caller named it before the first token. + * + * Published with every snapshot, so a paired device's live preview and the durable record that + * follows share one identity - which is what lets the preview be retired the moment the record + * lands instead of standing beside it until it times out. + */ + messageId?: string } const active = new Map() +/** Current cumulative streams. Main owns them, so renderer navigation cannot erase them. */ +export function activeChatStreamSnapshots(): ActiveChatStreamContract[] { + return [...active.entries()].map(([streamId, stream]) => ({ + streamId, + conversationId: stream.conversationId, + messageId: stream.messageId, + content: stream.content, + reasoning: stream.reasoning, + phase: stream.phase, + ...(stream.tools?.length ? { tools: stream.tools.map((tool) => ({ ...tool })) } : {}) + })) +} + +/** + * The identity minted for a conversation's current reply, until its stored record claims it. + * + * Deliberately OUTLIVES the stream. The turn ends in this process the moment the model stops, while + * the record is written afterwards by the renderer over IPC - so an identity discarded on `end` is + * always gone before the thing that needs it asks. Keyed by conversation because chat generation is + * serialised through one queue: a conversation has at most one reply forming at a time. + */ +const pendingMessageIds = new Map() + /** - * Attach a conversation to a stream id, before any delta arrives. + * Attach a conversation to a stream id, before any delta arrives, and name the reply it will become. * * Deltas are keyed by stream id because that is all the streaming transport knows; the conversation * is known only by the handler that started the turn. A stream that is never bound simply publishes * nothing - an unattributed reply has no conversation to appear in. + * + * The id is minted HERE, in the one place that already knows a reply has started, rather than being + * passed in by each caller that persists one. A caller that has to remember to thread an id is a + * caller that can forget, and every site that forgot would silently go back to being drawn twice on + * a paired device. */ -export function bindChatStream(streamId: string | undefined, conversationId?: string): void { +export function bindChatStream( + streamId: string | undefined, + conversationId?: string, + phase: ChatStreamPhase = 'waiting' +): void { if (!streamId || !conversationId) return - active.set(streamId, { conversationId, content: '', reasoning: '' }) + // A new reply supersedes any identity still unclaimed for this conversation - the previous turn was + // cancelled or failed before it ever became a record, so nothing is going to claim it. + const messageId = crypto.randomUUID() + pendingMessageIds.set(conversationId, messageId) + active.set(streamId, { conversationId, content: '', reasoning: '', phase, messageId }) publish(streamId) } +/** + * Claim the identity minted for this conversation's reply, so its record keeps the id its live + * frames already carried on every paired device. + * + * Take-once: the first record to conclude the turn claims it, and anything written afterwards gets a + * fresh id of its own. Returns undefined when nothing was streamed - a message typed into a + * conversation with no generation behind it is simply new, and mints its own id as it always did. + */ +export function takeChatStreamMessageId(conversationId: string): string | undefined { + const messageId = pendingMessageIds.get(conversationId) + if (messageId !== undefined) pendingMessageIds.delete(conversationId) + return messageId +} + +/** + * Read the identity of the reply that is still forming, without claiming it. + * + * Image generation needs this before it publishes the generated file. The later message write still + * claims the same id through `takeChatStreamMessageId`, so the live preview, file control and durable + * message all name one reply from the start. + */ +export function currentChatStreamMessageId( + conversationId: string | null | undefined +): string | undefined { + return conversationId ? pendingMessageIds.get(conversationId) : undefined +} + /** Fold one delta into the reply so far and publish the result. */ export function noteChatStreamDelta( streamId: string | undefined, @@ -42,19 +125,158 @@ export function noteChatStreamDelta( if (!streamId) return const stream = active.get(streamId) if (!stream) return - if (kind === 'reasoning') stream.reasoning += text - else stream.content += text + if (kind === 'reasoning') { + stream.reasoning += text + stream.phase = 'thinking' + } else { + stream.content += text + stream.phase = 'answering' + } + delete stream.progress + publish(streamId) +} + +/** Publish a tool row as soon as the model starts it, before its result exists. */ +export function noteChatStreamToolStarted(streamId: string | undefined, name: string): void { + if (!streamId || !name) return + const stream = active.get(streamId) + if (!stream) return + stream.tools = startChatStreamTool(stream.tools, name) + publish(streamId) +} + +/** Complete the same tool row without changing its position below the reply. */ +export function noteChatStreamToolCompleted( + streamId: string | undefined, + name: string, + result: string +): void { + if (!streamId || !name) return + const stream = active.get(streamId) + if (!stream) return + stream.tools = completeChatStreamTool(stream.tools, name, result) publish(streamId) } /** - * The turn ended, however it ended - completed, cancelled, or failed. + * Start or continue the image phase for one conversation. * - * Always publishes null, so a consumer never has to infer the end from silence. + * Direct image mode has no text stream to bind first. Tool and classifier paths do. This function + * hides that difference and preserves the same durable message identity in both cases. */ -export function endChatStream(streamId: string | undefined): void { - if (!streamId || !active.delete(streamId)) return - callHook(HOOKS.syncStreamingState, null) +export function beginChatImageStream(conversationId: string | null | undefined): boolean { + if (!conversationId) return false + const existing = [...active.entries()].find( + ([, stream]) => stream.conversationId === conversationId + ) + if (existing) { + const [streamId, stream] = existing + // The text answer can already be durable while this stream remains alive for its deferred image + // phase. `rag:add-message` takes the pending id once; reusing the ActiveStream's old id after that + // would publish the image file with the text row's id, then persist the image under a different + // one. Rotate at the start of the image job, before its sidecar/file control are written. + if (!pendingMessageIds.has(conversationId)) { + const messageId = crypto.randomUUID() + pendingMessageIds.set(conversationId, messageId) + stream.messageId = messageId + // The completed answer now has its own durable row. The new identity is for one image row, so + // its live preview contains lifecycle state only and does not repeat the answer or tool chips. + stream.content = '' + stream.reasoning = '' + delete stream.tools + } + stream.phase = 'loading_image_model' + delete stream.progress + publish(streamId) + return true + } + + const streamId = `image:${conversationId}` + const messageId = pendingMessageIds.get(conversationId) ?? crypto.randomUUID() + pendingMessageIds.set(conversationId, messageId) + active.set(streamId, { + conversationId, + content: '', + reasoning: '', + phase: 'loading_image_model', + messageId + }) + publish(streamId) + return true +} + +/** + * Keep a tool-using turn open while its deferred image job runs. + * + * The text model and image model are two phases of ONE reply. Ending the stream between them makes + * paired devices remove the first placeholder before the image record exists. This transition keeps + * the original durable message id and changes only the live activity shown by peers. + */ +export function continueChatStreamWithImage(streamId: string | undefined): boolean { + if (!streamId) return false + const stream = active.get(streamId) + if (!stream) return false + stream.phase = 'loading_image_model' + delete stream.progress + publish(streamId) + return true +} + +/** Refresh the same live image activity as native generation advances. */ +export function noteChatStreamImageProgress( + conversationId: string | null | undefined, + step?: number, + total?: number +): boolean { + if (!conversationId) return false + const entry = [...active.entries()].find( + ([, stream]) => + stream.conversationId === conversationId && + (stream.phase === 'loading_image_model' || stream.phase === 'generating_image') + ) + if (!entry) return false + const [streamId, stream] = entry + if (step !== undefined && step > 0 && total !== undefined && total > 0) { + stream.phase = 'generating_image' + stream.progress = { current: Math.min(Math.max(step, 0), total), total } + } else { + stream.phase = 'loading_image_model' + delete stream.progress + } + publish(streamId) + return true +} + +/** End the deferred image phase when its native job succeeds, fails, or is cancelled. */ +export function endChatStreamForConversation( + conversationId: string | null | undefined, + completion: ChatStreamCompletion = 'record_pending' +): boolean { + if (!conversationId) return false + const streamId = [...active.entries()].find( + ([, stream]) => stream.conversationId === conversationId + )?.[0] + if (!streamId) return false + endChatStream(streamId, completion) + return true +} + +/** + * The turn ended. A successful reply remains until its durable record arrives. Cancelled or failed + * work is discarded now because no record is coming to replace it. + */ +export function endChatStream( + streamId: string | undefined, + completion: ChatStreamCompletion = 'record_pending' +): void { + if (!streamId) return + const stream = active.get(streamId) + if (!stream || !active.delete(streamId)) return + if (completion === 'discarded') pendingMessageIds.delete(stream.conversationId) + callHook(HOOKS.syncStreamingState, { + conversationId: stream.conversationId, + completion + }) } function publish(streamId: string): void { @@ -63,6 +285,10 @@ function publish(streamId: string): void { callHook(HOOKS.syncStreamingState, { conversationId: stream.conversationId, content: stream.content, - reasoning: stream.reasoning + reasoning: stream.reasoning, + phase: stream.phase, + ...(stream.progress ? { progress: stream.progress } : {}), + ...(stream.tools?.length ? { tools: stream.tools } : {}), + messageId: stream.messageId }) } diff --git a/src/main/database.ts b/src/main/database.ts index e54c72b6..e22d5b25 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -1256,11 +1256,19 @@ export function addRagMessage( conversationId: string, role: 'user' | 'assistant', content: string, - context?: unknown + context?: unknown, + /** + * The identity this message ALREADY has, when something named it before it was stored. + * + * A streamed reply is named at its first token, so the frames a paired device renders live carry + * the same id as the record that follows. Minting a fresh one here instead is what left the peer + * unable to match the two, so it drew the answer twice until its preview timed out. + */ + knownUuid?: string ): AddedRagMessage { const db = getDB() const contextJson = context ? JSON.stringify(context) : null - const uuid = crypto.randomUUID() + const uuid = knownUuid ?? crypto.randomUUID() // uuid is the cross-device identity for sync (the autoincrement id is device-local). const info = db diff --git a/src/main/downloaded-models.ts b/src/main/downloaded-models.ts index f6434d74..71d2252f 100644 --- a/src/main/downloaded-models.ts +++ b/src/main/downloaded-models.ts @@ -2,19 +2,25 @@ // opposed to catalog entries and locally-imported .gguf files. Without this, a // downloaded HF model (e.g. MiniCPM-V) has its files on disk but nothing records // it as installed, so it's flagged as "unused" and never offered as a usable -// option (the bug). We key entries by the HF REPO ID so the rest of the app's -// `CATALOG.find(id) ?? resolveHuggingFaceModel(id)` lookups re-resolve them for -// activate/delete with zero extra branching. +// option (the bug). Direct downloads keep their HF repo id. Device-transferred variants use the +// shared exact package identity and keep the HF/catalog family id as separate provenance. This lets +// Q4_0 and Q4_K_M from one repository coexist without either one replacing the other. // // Pure/IO-only + parameterized by the models dir, so it's testable against a real // temp directory with real files (no Electron, no network, no mocks). import fs from 'fs' import path from 'path' +import { modelPackageIdentity } from '@offgrid/sync' +import { isProjectorFileName } from './models/catalog-logic' export interface DownloadedModel { - /** The Hugging Face repo id (e.g. "openbmb/MiniCPM-V-2_6-gguf"). */ + /** Exact installed variant key. Legacy entries use the Hugging Face family id. */ id: string + /** Human/catalog family identity used for display and upstream repair. */ + familyId?: string + /** Deterministic runnable-package identity for device-transferred variants. */ + packageIdentity?: string name: string kind: string /** On-disk filenames this model comprises (primary + any mmproj/companions). */ @@ -42,6 +48,80 @@ function writeDownloaded(dir: string, list: DownloadedModel[]): void { } } +export interface DownloadedRegistryCatalogEntry { + id: string + files: Array<{ name: string }> +} + +/** + * Migrate the old catalog-family alias used by transferred variants. + * + * Older receivers stored an alternate quant/projector package under its catalog family id. That + * made activation resolve the catalog files and made the UI project both rows. The migration gives + * an alternate package its exact shared identity and keeps the family separately. An entry whose + * files exactly match the catalog is redundant and is removed from the registry only; model files + * are never deleted here. + */ +export function reconcileDownloadedModelRegistry( + dir: string, + catalog: readonly DownloadedRegistryCatalogEntry[] +): DownloadedModel[] { + const current = readDownloaded(dir) + let changed = false + const migrated: DownloadedModel[] = [] + for (const model of current) { + if (model.familyId || model.packageIdentity) { + migrated.push(model) + continue + } + const family = catalog.find((entry) => entry.id === model.id) + if (!family) { + migrated.push(model) + continue + } + const expected = new Set(family.files.map((file) => file.name)) + if (expected.size === model.files.length && model.files.every((name) => expected.has(name))) { + changed = true + continue + } + const files = model.files.map((name) => { + let sizeBytes = 0 + try { + sizeBytes = fs.statSync(path.join(dir, name)).size + } catch { + /* keep the legacy row until its package is complete */ + } + return { + name, + sizeBytes, + role: isProjectorFileName(name) ? ('projector' as const) : ('primary' as const) + } + }) + if (files.some((file) => file.sizeBytes <= 0)) { + migrated.push(model) + continue + } + const packageIdentity = modelPackageIdentity({ + id: model.id, + name: model.name, + kind: model.kind, + source: 'downloaded', + files: files as [(typeof files)[number], ...Array<(typeof files)[number]>], + engine: model.kind === 'text' || model.kind === 'vision' ? 'llama' : undefined + }) + migrated.push({ + ...model, + id: packageIdentity, + familyId: model.id, + packageIdentity + }) + changed = true + } + const unique = [...new Map(migrated.map((model) => [model.id, model])).values()] + if (changed || unique.length !== current.length) writeDownloaded(dir, unique) + return unique +} + /** Record a downloaded model (replacing any existing entry with the same id). */ export function recordDownloaded(dir: string, model: DownloadedModel): void { const next = readDownloaded(dir).filter((m) => m.id !== model.id) diff --git a/src/main/files-image-probe.ts b/src/main/files-image-probe.ts new file mode 100644 index 00000000..4694cdaa --- /dev/null +++ b/src/main/files-image-probe.ts @@ -0,0 +1,72 @@ +// Is an image's BYTES decodable? Asked so that the machinery which answers can +// never become a precondition for attaching a file. +// +// sharp is a native module, and it can fail to load for reasons that have nothing +// to do with the file in front of it. sharp ships libvips as `libvips-42.dll`, and +// Windows resolves a DLL by NAME across the whole process: the first copy loaded +// wins every later binding. A second sharp version anywhere in the tree therefore +// broke ours with ERR_DLOPEN_FAILED — `embeddings.ts` loads @xenova/transformers +// (sharp 0.32 / libvips 8.14.5) at startup, so our sharp 0.35 asked that older DLL +// for symbols it does not export. macOS binds by path and never showed it. +// +// `package.json` overrides now hold the tree at one sharp, so that specific clash +// cannot recur. The reason this module exists is the SECOND half of that failure: +// the upload path imported sharp at the top level, so a module that only validates +// images took every attachment down with it. A PDF and a text file cannot be +// attached on Windows either, and neither has anything to do with sharp. +// +// Hence a three-valued answer. "I could not check" is not "this file is broken". + +/** The one call this check needs from sharp — injected so the decision can be + * proved against a real file without a working native module. */ +export type ImageProbe = (filePath: string) => { metadata(): Promise } + +export type ImageDecodeVerdict = + /** sharp read the header: the bytes are a real image. */ + | 'decodable' + /** sharp read the file and rejected it: the user's file is damaged. Say so. */ + | 'undecodable' + /** sharp itself did not load: the check is SKIPPED, not failed. Attach anyway. */ + | 'unchecked' + +/** Load sharp on demand. Returns null when the module cannot load at all, so the + * caller can distinguish a missing checker from a bad file. */ +export async function loadImageProbe(): Promise { + try { + const mod = (await import('sharp')) as unknown as { + default?: (p: string, o?: unknown) => { metadata(): Promise } + } + const sharp = mod.default ?? (mod as unknown as typeof mod.default) + if (!sharp) return null + // failOn: 'error' — a truncated or corrupt image must reject rather than decode + // to garbage and reach the vision runtime as engine noise. + return (filePath: string) => sharp(filePath, { failOn: 'error' }) + } catch (e) { + // Deliberately not thrown: an unloadable validator is an infrastructure fault, + // and it must not present itself as a verdict on the user's file. + console.warn( + '[files] image validation unavailable (sharp did not load); attaching without it:', + (e as Error).message.split('\n')[0] + ) + return null + } +} + +/** + * Judge a file's bytes. Pure in its decision: the probe is injected, so the three + * outcomes are provable without depending on whether this machine's native module + * happens to work. + */ +export async function verifyImageDecodable( + filePath: string, + load: () => Promise = loadImageProbe +): Promise { + const probe = await load() + if (!probe) return 'unchecked' + try { + await probe(filePath).metadata() + return 'decodable' + } catch { + return 'undecodable' + } +} diff --git a/src/main/files.ts b/src/main/files.ts index 9a408b21..818e2fe2 100644 --- a/src/main/files.ts +++ b/src/main/files.ts @@ -8,9 +8,12 @@ import path from 'path' import os from 'os' import fs from 'fs' import { app } from 'electron' -import sharp from 'sharp' import { desktopExtraction as ex } from './rag/extractors' import { IMAGE_EXT, AUDIO_EXT, VIDEO_EXT, sanitizeUploadName } from './files-classify' +// sharp is NOT imported here. It is a native module, and a top-level import let a +// module that only validates images refuse every attachment of every type when it +// could not load — which is exactly what happened on Windows. See files-image-probe. +import { verifyImageDecodable } from './files-image-probe' export interface ProcessedFile { name: string @@ -33,9 +36,11 @@ export async function processUpload( // the upload owner before persisting or marking the attachment ready, so a // damaged image produces a specific recoverable error in the composer instead // of reaching the vision runtime as engine garbage. - try { - await sharp(tmp, { failOn: 'error' }).metadata() - } catch { + // + // Only a READ verdict refuses the file. If the validator itself is unavailable + // the upload proceeds unchecked: a native module that will not load is our + // fault, not a statement about the user's photo. + if ((await verifyImageDecodable(tmp)) === 'undecodable') { throw new Error('Unsupported or damaged image data.') } // Persist the image so the chat can pass the ACTUAL image to the multimodal diff --git a/src/main/imagegen.ts b/src/main/imagegen.ts index 8ed24331..087d09a3 100644 --- a/src/main/imagegen.ts +++ b/src/main/imagegen.ts @@ -28,7 +28,7 @@ import { MFLUX_MODELS } from './mflux' import { getActiveModal } from './active-models' -import { binRoots, dataDir, modelsDir, exe } from './runtime-env' +import { binRoots, dataDir, modelsDir, resourceDirs, exe } from './runtime-env' import { sdServer } from './sd-server' import { standardModelDefaults, taesdFilename } from '../shared/image-defaults' import { defaultImageModelFilename } from './image-default' @@ -55,6 +55,8 @@ import { import { IMAGE_CANCELLED_MESSAGE, ImageGenerationLifecycle } from './imagegen/generation-lifecycle' import { imageMemoryGuardErrorMessage, + type ImageGenerationPipelineUpdateContract, + type ImageGenerationOutputContract, type ImageGenerationRequestContract } from '../shared/image-generation-contract' @@ -163,35 +165,24 @@ export function deleteGeneratedImage(p: string): boolean { } } -// --- Style-preset thumbnails (generated on-device, cached; never hotlinked) -- -function styleThumbDir(): string { - return path.join(dataDir(), 'style-thumbs') -} - -/** Map of style key -> cached thumbnail path (on-device generated). */ +// --- Style-preset thumbnails (bundled release assets; never hotlinked) -------- +/** Map of style key -> bundled thumbnail path. */ export function listStyleThumbs(): Record { const out: Record = {} - try { - for (const f of fs.readdirSync(styleThumbDir())) { - const m = f.match(/^(.+)\.png$/i) - if (m) out[m[1]!] = path.join(styleThumbDir(), f) + for (const resources of resourceDirs()) { + const directory = path.join(resources, 'style-thumbs') + try { + for (const file of fs.readdirSync(directory)) { + const match = file.match(/^(.+)\.png$/i) + if (match && !out[match[1]!]) out[match[1]!] = path.join(directory, file) + } + } catch { + /* this resource root does not contain style previews */ } - } catch { - /* none yet */ } return out } -/** Generate one style thumbnail on-device (small/fast) and cache it. */ -export async function generateStyleThumb(key: string, prompt: string): Promise { - const out = await generateImage({ prompt, width: 512, height: 512, steps: 6 }) - const dir = styleThumbDir() - fs.mkdirSync(dir, { recursive: true }) - const dest = path.join(dir, `${key.replace(/[^\w-]+/g, '_')}.png`) - fs.copyFileSync(out.path, dest) - return dest -} - // --- LoRA adapters ----------------------------------------------------------- // LoRAs live in userData/models/loras as .safetensors. sd-cli applies them via // the `--lora-model-dir` flag + `` syntax injected into the @@ -397,12 +388,10 @@ export function imageGenStatus(): { export type ImageGenParams = ImageGenerationRequestContract -export interface ImageGenOutput { - dataUrl: string - path: string - seed: number - model: string -} +export type ImageGenOutput = ImageGenerationOutputContract + +/** Native runtimes receive the final prompt as input. The wrapper adds it to their output once. */ +type NativeImageGenOutput = Omit export interface ImageGenProgress { step: number @@ -450,10 +439,7 @@ export function saveGeneratedImageScope(imagePath: string, facts: GeneratedImage * itself, does not list an input as though the user had generated it. Returns the copy's path, or null * when the source cannot be read - a generation is not worth failing over its provenance. */ -export function preserveGeneratedImageSource( - syncId: string, - sourcePath: string -): string | null { +export function preserveGeneratedImageSource(syncId: string, sourcePath: string): string | null { try { const directory = path.join(dataDir(), 'generated-images', 'sources') fs.mkdirSync(directory, { recursive: true }) @@ -528,30 +514,60 @@ export function cancelImageGen(): boolean { */ export async function generateImage( params: ImageGenParams, - onProgress?: (p: ImageGenProgress & { preview?: string }) => void + onUpdate?: (update: ImageGenerationPipelineUpdateContract) => void ): Promise { // Prompt enhancement runs FIRST, while the chat model is still resident — the // image job below evicts the LLM, so the text pass must precede it. Gated by a // setting; failure/timeout silently keeps the original prompt. - const enhanced = await maybeEnhancePrompt(params.prompt) + const enhanced = await maybeEnhancePrompt(params.prompt, onUpdate) const effective = enhanced === params.prompt ? params : { ...params, prompt: enhanced } + onUpdate?.({ stage: 'preparing', enhancedPrompt: enhanced }) + const progressObserver = onUpdate + ? (progress: ImageGenProgress & { preview?: string }) => + onUpdate({ + stage: progress.phase === 'decoding' ? 'decoding' : 'generating', + progress + }) + : undefined // The queue evicts 'llm' before this runs AND re-warms it (mode-aware) when the // job finishes — so the image path no longer touches llm.pause/resume itself. - return modalityQueue.run(IMAGE_JOB, () => runImageGen(effective, onProgress)) + const output = await modalityQueue.run(IMAGE_JOB, () => runImageGen(effective, progressObserver)) + return { ...output, prompt: effective.prompt } } /** Expand the user's prompt into a richer generation prompt via the local text * model, when `enhanceImagePrompts` is on. Runs through the queue as a foreground * text job (tier 2, evicts a resident image server) so it's serialized with chat. * Any failure returns the original prompt unchanged — enhancement is best-effort. */ -async function maybeEnhancePrompt(prompt: string): Promise { +async function maybeEnhancePrompt( + prompt: string, + onUpdate?: (update: ImageGenerationPipelineUpdateContract) => void +): Promise { + const enabled = getSetting('enhanceImagePrompts', true) + if (enabled) onUpdate?.({ stage: 'enhancing', enhancedPrompt: '' }) + let streamed = '' return enhancePrompt(prompt, { - enabled: getSetting('enhanceImagePrompts', true), + enabled, + onText: (text) => { + streamed += text + onUpdate?.({ stage: 'enhancing', enhancedPrompt: streamed }) + }, // Foreground text job (tier 2, evicts a resident image server), serialized with // chat. Runs while the chat model is still resident — the image job evicts it after. - chat: (instruction) => + chat: (instruction, onText) => modalityQueue.run(CHAT_JOB, () => - llm.chat(instruction, [], 60_000, 200, { temperature: 0.7, disableThinking: true }) + llm + .chatStream( + instruction, + [], + (text, kind) => { + if (kind === 'content') onText(text) + }, + { temperature: 0.7, thinking: false }, + 200, + 60_000 + ) + .then((result) => result.content) ) }) } @@ -559,7 +575,7 @@ async function maybeEnhancePrompt(prompt: string): Promise { async function runImageGen( params: ImageGenParams, onProgress?: (p: ImageGenProgress & { preview?: string }) => void -): Promise { +): Promise { if (generationLifecycle.isRunning()) { throw new Error('An image is already generating — please wait for it to finish.') } @@ -720,6 +736,7 @@ async function runImageGen( const residentImage = getResidencyMode('image') === 'resident' const eligibleForServer = residentImage && + !onProgress && !coreml && !isZImage && !loras.length && @@ -737,20 +754,17 @@ async function runImageGen( taesdPath: taesd ?? undefined }) generationLifecycle.throwIfCancelled() - const { png, seed: usedSeed } = await sdServer.generate( - { - prompt: params.prompt, - negativePrompt: params.negativePrompt?.trim() || DEFAULT_NEGATIVE, - width: params.width ?? defaultSize, - height: params.height ?? defaultSize, - steps: params.steps ?? defaultSteps, - cfgScale: params.cfgScale ?? defaultCfg, - sampleMethod: sampler, - scheduler, - seed - }, - (p) => onProgress?.({ step: p.step, total: p.total, secPerStep: 0 }) - ) + const { png, seed: usedSeed } = await sdServer.generate({ + prompt: params.prompt, + negativePrompt: params.negativePrompt?.trim() || DEFAULT_NEGATIVE, + width: params.width ?? defaultSize, + height: params.height ?? defaultSize, + steps: params.steps ?? defaultSteps, + cfgScale: params.cfgScale ?? defaultCfg, + sampleMethod: sampler, + scheduler, + seed + }) await fs.promises.writeFile(outPath, png) return { dataUrl: `data:image/png;base64,${png.toString('base64')}`, @@ -914,10 +928,14 @@ async function runImageGen( // Pure progress reducer owns the seed parse + the denoise->decode phase // transition; the shell only handles the preview PNG read + the callback. let progress = initialProgressState(seed) + let progressBuffer = '' const capture = (d: Buffer): void => { const s = d.toString() log += s - const { state, event } = reduceProgress(progress, s) + // Terminal progress lines can arrive across multiple data chunks. Keep a + // short rolling buffer so "12/" and "42" still become step 12 of 42. + progressBuffer = `${progressBuffer}${s}`.slice(-2048) + const { state, event } = reduceProgress(progress, progressBuffer, params.steps) progress = state if (onProgress && event) { let preview: string | undefined diff --git a/src/main/imagegen/__tests__/progress.test.ts b/src/main/imagegen/__tests__/progress.test.ts index 87a1f017..fce29ffe 100644 --- a/src/main/imagegen/__tests__/progress.test.ts +++ b/src/main/imagegen/__tests__/progress.test.ts @@ -34,6 +34,20 @@ describe('progress reducer', () => { expect(state.resolvedSeed).toBe(9) // seed unchanged, no "seed N" }) + it('parses the terminal progress-bar form when it matches the requested steps', () => { + const { event } = reduceProgress( + initialProgressState(-1), + '\r[========> ] 12/42', + 42 + ) + expect(event).toEqual({ step: 12, total: 42, secPerStep: 0, phase: 'sampling' }) + }) + + it('ignores unrelated counters that do not match the requested steps', () => { + const { event } = reduceProgress(initialProgressState(-1), 'loading tensors 12/80', 42) + expect(event).toBeUndefined() + }) + it('marks samplingDone once a pass reaches its total, then flips to decoding on a step drop', () => { let st = initialProgressState(-1) // full sampling pass up to the total diff --git a/src/main/imagegen/generated-image-share.ts b/src/main/imagegen/generated-image-share.ts index 688a0132..f015e909 100644 --- a/src/main/imagegen/generated-image-share.ts +++ b/src/main/imagegen/generated-image-share.ts @@ -21,6 +21,10 @@ export function describeOwnGeneratedImage( if (!facts.syncId) return null const stat = fs.statSync(imagePath) const metadata = readGeneratedImageMetadata(facts.metadataJson) + const recordedHome = + facts.conversationId && facts.messageId + ? { conversationId: facts.conversationId, messageId: facts.messageId } + : undefined return describeGeneratedImage( { syncId: facts.syncId, @@ -32,7 +36,7 @@ export function describeOwnGeneratedImage( ...(facts.height === undefined ? {} : { height: facts.height }), ...(metadata === undefined ? {} : { metadata }) }, - shownIn + shownIn ?? recordedHome ) } @@ -89,10 +93,14 @@ export function describeGeneratedImageEnsuringIdentity( * picture out of the gallery and under the message. */ export function noteGeneratedImageMessage(link: ChatHome & { imagePath: string }): boolean { + const current = readGeneratedImageSidecar(link.imagePath) + const alreadyLinked = + current.conversationId === link.conversationId && current.messageId === link.messageId writeGeneratedImageSidecar(link.imagePath, { conversationId: link.conversationId, messageId: link.messageId }) + if (alreadyLinked) return true return shareGeneratedImage(link.imagePath, { conversationId: link.conversationId, messageId: link.messageId diff --git a/src/main/imagegen/job-service.ts b/src/main/imagegen/job-service.ts index 9e5dd5b7..70b655ed 100644 --- a/src/main/imagegen/job-service.ts +++ b/src/main/imagegen/job-service.ts @@ -3,8 +3,9 @@ import { generatedImageMetadataJson } from '@offgrid/sync' import type { ChatHome } from '@offgrid/sync' import { type ImageGenerationJobContract, - type ImageGenerationProgressContract, - type ImageGenerationRequestContract + type ImageGenerationPipelineUpdateContract, + type ImageGenerationRequestContract, + type ImageGenerationResultContract } from '../../shared/image-generation-contract' import { cancelImageGen, @@ -18,6 +19,8 @@ import { noteGeneratedImageMessage, shareGeneratedImage } from './generated-imag export type ImageGenerationJobRequest = ImageGenerationRequestContract & { conversationId?: string + /** The durable assistant message this image will hang under. */ + messageId?: string projectId?: string | null } @@ -25,12 +28,12 @@ type JobListener = (snapshot: ImageGenerationJobContract) => void type ConversationListener = (conversationId: string) => void /** A finished image, and the name it answers to on every device. */ -export type ImageGenerationResult = ImageGenOutput & { syncId: string } +export type ImageGenerationResult = ImageGenerationResultContract export interface ImageGenerationRuntime { generate( request: ImageGenerationJobRequest, - onProgress: (progress: ImageGenerationProgressContract) => void + onUpdate: (update: ImageGenerationPipelineUpdateContract) => void ): Promise cancel(): boolean /** The scope, not the whole request: the sidecar owns these facts and nothing else here. */ @@ -44,7 +47,7 @@ export interface ImageGenerationRuntime { } const nativeImageGenerationRuntime: ImageGenerationRuntime = { - generate: (request, onProgress) => generateImage(request, onProgress), + generate: (request, onUpdate) => generateImage(request, onUpdate), cancel: () => cancelImageGen(), saveScope: (path, facts) => saveGeneratedImageScope(path, facts), share: (path) => shareGeneratedImage(path), @@ -57,6 +60,8 @@ const idleSnapshot = (): ImageGenerationJobContract => ({ phase: 'idle', conversationId: null, projectId: null, + stage: null, + enhancedPrompt: '', progress: null, outputPath: null, error: null, @@ -89,10 +94,15 @@ export class ImageGenerationJobService { return () => this.conversationListeners.delete(listener) } - async start(request: ImageGenerationJobRequest): Promise { + /** Reject before a caller reserves related state for a job this service cannot accept. */ + assertCanStart(): void { if (this.active) { throw new Error('An image is already generating - please wait for it to finish.') } + } + + async start(request: ImageGenerationJobRequest): Promise { + this.assertCanStart() this.active = true const id = randomUUID() this.snapshot = { @@ -100,6 +110,8 @@ export class ImageGenerationJobService { phase: 'running', conversationId: request.conversationId ?? null, projectId: request.projectId ?? null, + stage: 'enhancing', + enhancedPrompt: '', progress: null, outputPath: null, error: null, @@ -117,9 +129,7 @@ export class ImageGenerationJobService { ) try { - const result = await this.runtime.generate(request, (progress) => - this.updateProgress(id, progress) - ) + const result = await this.runtime.generate(request, (update) => this.update(id, update)) // Always, not only inside a chat. The syncId is what this image is called on the mesh, so an // image made from the tool loop or the gateway needs one exactly as much as one made in a // conversation; without it the gallery and the file record name the same picture differently. @@ -134,6 +144,7 @@ export class ImageGenerationJobService { syncId: id, ...(keptSource ? { initImage: keptSource } : {}), ...(request.conversationId ? { conversationId: request.conversationId } : {}), + ...(request.messageId ? { messageId: request.messageId } : {}), projectId: request.projectId ?? null, createdAt: new Date(this.snapshot.startedAt ?? Date.now()).toISOString(), ...(request.width ? { width: request.width } : {}), @@ -142,7 +153,7 @@ export class ImageGenerationJobService { // phone reads `modelId`, so every image made here arrived with its model reading // "synced" and its steps reading 0. metadataJson: generatedImageMetadataJson({ - prompt: request.prompt, + prompt: result.prompt, ...(request.negativePrompt === undefined ? {} : { negativePrompt: request.negativePrompt }), @@ -164,7 +175,8 @@ export class ImageGenerationJobService { this.snapshot = { ...this.snapshot, phase: 'succeeded', - outputPath: result.path ?? null, + stage: null, + outputPath: result.path, progress: null, finishedAt: Date.now() } @@ -181,6 +193,7 @@ export class ImageGenerationJobService { this.snapshot = { ...this.snapshot, phase: cancelled ? 'cancelled' : 'failed', + stage: null, error: message, progress: null, finishedAt: Date.now() @@ -202,6 +215,7 @@ export class ImageGenerationJobService { this.snapshot = { ...this.snapshot, phase: 'cancelled', + stage: null, progress: null, finishedAt: Date.now() } @@ -213,9 +227,9 @@ export class ImageGenerationJobService { * Called only after the renderer has persisted the generated assistant message. * A remounted Chat observes this and refreshes the conversation from SQLite. * - * This is also the first moment the message EXISTS, so it is the only moment the image can be told - * which message it hangs under. The picture is offered again with that link, which is what lets a - * phone move it out of the gallery and under the message instead of drawing a hole. + * The image was already offered with the stable message id reserved at the start of the turn. + * `noteMessage` confirms the final persisted association. The generated-image owner treats this as + * an idempotent acknowledgement, so it does not publish or transfer the same image a second time. */ acknowledgeConversation(conversationId: string, messageId?: string): boolean { if ( @@ -243,9 +257,14 @@ export class ImageGenerationJobService { return true } - private updateProgress(id: string, progress: ImageGenerationProgressContract): void { + private update(id: string, update: ImageGenerationPipelineUpdateContract): void { if (this.snapshot.id !== id || this.snapshot.phase !== 'running') return - this.snapshot = { ...this.snapshot, progress: { ...progress } } + this.snapshot = { + ...this.snapshot, + stage: update.stage, + ...(update.enhancedPrompt === undefined ? {} : { enhancedPrompt: update.enhancedPrompt }), + progress: update.progress === undefined ? this.snapshot.progress : update.progress + } this.publish() } diff --git a/src/main/imagegen/progress.ts b/src/main/imagegen/progress.ts index e5c8d741..f571b573 100644 --- a/src/main/imagegen/progress.ts +++ b/src/main/imagegen/progress.ts @@ -37,7 +37,8 @@ const SEED_RE = /seed\s+(-?\d+)/i * several per chunk; only the newest matters for a monotonic UI). */ export function reduceProgress( prev: ProgressState, - chunk: string + chunk: string, + expectedTotal?: number ): { state: ProgressState; event?: ProgressEvent } { let resolvedSeed = prev.resolvedSeed const sm = chunk.match(SEED_RE) @@ -48,13 +49,23 @@ export function reduceProgress( let last: RegExpExecArray | null = null for (let mm = re.exec(chunk); mm; mm = re.exec(chunk)) last = mm + // Newer sd.cpp builds render a terminal progress bar without the old + // "- Xs/it" suffix. Match that form only when its total is the requested + // sampling-step count, so model-loading counters cannot become image progress. + if (!last && expectedTotal && !/(?:MB|MiB)\/s/i.test(chunk)) { + const ratioRe = /(\d+)\/(\d+)/g + for (let mm = ratioRe.exec(chunk); mm; mm = ratioRe.exec(chunk)) { + if (Number(mm[2]) === expectedTotal) last = mm + } + } + if (!last) { return { state: { ...prev, resolvedSeed } } } const step = parseInt(last[1]!, 10) const total = parseInt(last[2]!, 10) - const secPerStep = parseFloat(last[3]!) + const secPerStep = last[3] ? parseFloat(last[3]) : 0 let samplingDone = prev.samplingDone let phase = prev.phase if (!samplingDone) { @@ -63,5 +74,6 @@ export function reduceProgress( phase = 'decoding' } const state: ProgressState = { resolvedSeed, samplingDone, prevStep: step, phase } + if (step === prev.prevStep && phase === prev.phase) return { state } return { state, event: { step, total, secPerStep, phase } } } diff --git a/src/main/imagegen/prompt-enhance.ts b/src/main/imagegen/prompt-enhance.ts index 4374c446..6791d844 100644 --- a/src/main/imagegen/prompt-enhance.ts +++ b/src/main/imagegen/prompt-enhance.ts @@ -58,7 +58,9 @@ export interface EnhanceDeps { enabled: boolean /** Run the text model on the instruction prompt. The caller wraps queue + params * + timeout; this module only owns the build → clean → fallback orchestration. */ - chat: (instruction: string) => Promise + chat: (instruction: string, onText: (text: string) => void) => Promise + /** Receive the answer text as the model creates it. */ + onText?: (text: string) => void } /** Gate → build → run → clean → fall back. The orchestration, dependency-injected @@ -69,7 +71,7 @@ export async function enhancePrompt(userPrompt: string, deps: EnhanceDeps): Prom return userPrompt } try { - const raw = await deps.chat(buildEnhancePrompt(userPrompt)) + const raw = await deps.chat(buildEnhancePrompt(userPrompt), deps.onText ?? (() => undefined)) return cleanEnhancedPrompt(raw, userPrompt) } catch { return userPrompt diff --git a/src/main/index.ts b/src/main/index.ts index 929bfe4c..2249a0c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,7 +24,7 @@ import { preloadPath } from './preload-path' import { rendererHtmlPath } from './renderer-path' import { startModelServer, stopModelServer } from './model-server' import { startMediaServer, stopMediaServer, mediaUrlFor } from './media-server' -import { serveCaptureFile } from './ogcapture-serve' +import { capturePathFromUrl, serveCaptureFile } from './ogcapture-serve' import { serveArtifactPreview } from './artifact-preview' import { ipcMain } from 'electron' import { loadProEntitlementProvider, loadProFeaturesMain } from './bootstrap/loadProFeaturesMain' @@ -47,7 +47,9 @@ import { guardConsoleStreams } from './stream-guards' import { PRODUCT_NAME } from '../shared/product-identity' import { installMediaPermissionHandler } from './media-permission' import { localMediaRoots } from './media-roots' +import { resourceDirs } from './runtime-env' import { beginProductIdentityBootstrap } from './product-identity-lifecycle' +import { repairMissingDefaultKeychainAtBootstrap } from './secure-storage-bootstrap' import { installDiagnosticConsoleCapture, installIpcDiagnostics, @@ -66,6 +68,19 @@ import { shutdownModelDownloads } from './models/download-queue' // must never crash main via an uncaught EPIPE. See stream-guards.ts. guardConsoleStreams([process.stdout, process.stderr]) +// Electron asks macOS for its safeStorage password during early bootstrap. Repair +// the one safe, known-bad state before that lookup can trigger SecurityAgent's +// generic "Keychain Not Found" dialog. This never creates or resets a Keychain. +const secureStorageBootstrap = repairMissingDefaultKeychainAtBootstrap( + process.platform, + app.isPackaged +) +if (secureStorageBootstrap?.status === 'repaired') { + console.warn(`[secure-storage] ${secureStorageBootstrap.detail}`) +} else if (secureStorageBootstrap && secureStorageBootstrap.status !== 'healthy') { + console.error(`[secure-storage] ${secureStorageBootstrap.detail}`) +} + // Pin one canonical userData dir ("Off Grid AI Desktop") regardless of package // name, and migrate data from the legacy split dirs ("My Memories" had the // models, "my-memories" had the DB) so nothing is lost / re-downloaded. Must run @@ -296,10 +311,12 @@ app.whenReady().then(async () => { // NOTE: keep this in sync with the dirs the renderer requests over ogcapture://. // 'generated-images' + 'style-thumbs' were missing, so every image-gen output and // every style-picker thumbnail 403'd and rendered as a broken image. - const ogCaptureRoots = localMediaRoots(app.getPath('userData')) + const ogCaptureRoots = localMediaRoots(app.getPath('userData'), resourceDirs()) protocol.handle('ogcapture', async (request) => { try { - const requestedPath = decodeURIComponent(request.url.slice('ogcapture://'.length)) + // Parsed, not sliced: a Windows drive letter lands in the URL's host and loses its colon, so + // slicing produced `C/Users/…` and every preview 404'd on that platform alone. + const requestedPath = capturePathFromUrl(request.url) return serveCaptureFile(requestedPath, ogCaptureRoots, request.headers.get('Range')) } catch { return new Response(null, { status: 400 }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2360a882..7dd84793 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -91,9 +91,18 @@ async function regenerateMasterMemory(): Promise { // Active streaming turns, keyed by streamId, so a renderer 'rag:cancel' can abort // an in-flight generation and keep whatever was produced so far. import { + activeChatStreamSnapshots, + beginChatImageStream, bindChatStream, + continueChatStreamWithImage, + currentChatStreamMessageId, endChatStream, - noteChatStreamDelta + endChatStreamForConversation, + noteChatStreamImageProgress, + noteChatStreamDelta, + noteChatStreamToolCompleted, + noteChatStreamToolStarted, + takeChatStreamMessageId } from './chat-stream-state' const streamControllers = new Map() @@ -149,7 +158,12 @@ async function streamAnswer( }) } finally { streamControllers.delete(streamId) - endChatStream(streamId) + endChatStream(streamId, controller.signal.aborted ? 'discarded' : 'record_pending') + try { + sender.send('rag:stream', { streamId, type: 'done' }) + } catch { + /* window gone */ + } } } @@ -427,7 +441,6 @@ async function extractEntitiesForSession(sessionId: string): Promise { console.error('[IPC] Failed to update entity summary:', e) } } - } catch (e) { console.error('[IPC] Entity extraction failed:', e) } @@ -590,6 +603,7 @@ export function setupIPC() { }) // Cancel an in-flight streaming turn; chatStream resolves with the partial answer. + ipcMain.handle('rag:active-streams', () => activeChatStreamSnapshots()) ipcMain.on('rag:cancel', (_evt, streamId: string) => { streamControllers.get(streamId)?.abort() }) @@ -611,7 +625,7 @@ export function setupIPC() { const imgs = images || [] // Before the classifier, so the whole turn - including its thinking - is attributable to the // conversation it belongs to. - bindChatStream(streamId, conversationId) + bindChatStream(streamId, conversationId, thinking ? 'thinking' : 'waiting') // Intelligence layer: a grammar-constrained classifier picks the output // format (build / image / chat) and extracts URLs to read — replacing the // brittle keyword gate. Skip it in project mode (that path is its own thing). @@ -1180,7 +1194,12 @@ export function setupIPC() { ipcMain.handle( 'rag:add-message', (_, conversationId: string, role: 'user' | 'assistant', content: string, context?: any) => { - return addRagMessage(conversationId, role, content, context) + // A reply that was streamed is already named, and keeps that name: every paired device has been + // rendering it under this id, so the arriving record retires their live preview instead of + // standing beside it. Read from the one owner of "what this device is generating", so no caller + // has to pass it and none can forget to. + const streamed = role === 'assistant' ? takeChatStreamMessageId(conversationId) : undefined + return addRagMessage(conversationId, role, content, context, streamed) } ) @@ -1383,7 +1402,6 @@ export function setupIPC() { console.error(`[IPC] Failed to reprocess session ${session.id}:`, e) } } - } else { // Additive reprocess: keep existing data, just re-run entity extraction on top console.log( @@ -1698,10 +1716,20 @@ export function setupIPC() { const imageJobPublisher = ( snapshot: import('../shared/image-generation-contract').ImageGenerationJobContract ): void => { + if (snapshot.phase === 'running') { + noteChatStreamImageProgress( + snapshot.conversationId, + snapshot.progress?.step, + snapshot.progress?.total + ) + } else if (snapshot.phase === 'succeeded') { + endChatStreamForConversation(snapshot.conversationId, 'record_pending') + } else if (snapshot.phase === 'failed' || snapshot.phase === 'cancelled') { + endChatStreamForConversation(snapshot.conversationId, 'discarded') + } for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue window.webContents.send('imagegen:job-state', snapshot) - if (snapshot.progress) window.webContents.send('imagegen:progress', snapshot.progress) } } const imageConversationPublisher = (conversationId: string): void => { @@ -1733,7 +1761,20 @@ export function setupIPC() { } ) => { const imageGenerationJobs = await imageJobPublisherReady - return imageGenerationJobs.start(params) + // Admission belongs to the job service. Reject before changing the conversation stream, so a + // second request cannot reset or discard the identity of the image that is already running. + imageGenerationJobs.assertCanStart() + beginChatImageStream(params.conversationId) + try { + const messageId = currentChatStreamMessageId(params.conversationId) + return await imageGenerationJobs.start({ + ...params, + ...(messageId ? { messageId } : {}) + }) + } catch (error) { + endChatStreamForConversation(params.conversationId, 'discarded') + throw error + } } ) @@ -1762,10 +1803,6 @@ export function setupIPC() { const { listStyleThumbs } = await import('./imagegen') return listStyleThumbs() }) - ipcMain.handle('imagegen:make-style-thumb', async (_e, key: string, prompt: string) => { - const { generateStyleThumb } = await import('./imagegen') - return generateStyleThumb(key, prompt) - }) ipcMain.handle('imagegen:list-loras', async () => { const { listLoras } = await import('./imagegen') return listLoras() @@ -1845,9 +1882,10 @@ export function setupIPC() { // thinking -> tool-call activity -> answer, and the stop button (rag:cancel) aborts it. const controller = new AbortController() streamControllers.set(streamId, controller) - bindChatStream(streamId, opts?.conversationId) + bindChatStream(streamId, opts.conversationId, opts.thinking ? 'thinking' : 'waiting') + let continuesAsImage = false try { - return await modalityQueue.run(CHAT_JOB, () => + const result = await modalityQueue.run(CHAT_JOB, () => toolChat(query, history || [], { ...opts, thinking: opts.thinking, @@ -1861,6 +1899,7 @@ export function setupIPC() { } }, onStep: (call) => { + noteChatStreamToolStarted(streamId, call.name) try { sender.send('rag:stream', { streamId, @@ -1872,6 +1911,7 @@ export function setupIPC() { } }, onToolResult: (call) => { + noteChatStreamToolCompleted(streamId, call.name, call.result) try { sender.send('rag:stream', { streamId, type: 'tool_result', call }) } catch { @@ -1880,9 +1920,20 @@ export function setupIPC() { } }) ) + if (result.imageRequests.length > 0) { + continuesAsImage = continueChatStreamWithImage(streamId) + } + return result } finally { streamControllers.delete(streamId) - endChatStream(streamId) + if (!continuesAsImage) { + endChatStream(streamId, controller.signal.aborted ? 'discarded' : 'record_pending') + try { + sender.send('rag:stream', { streamId, type: 'done' }) + } catch { + /* window gone */ + } + } } } ) diff --git a/src/main/llm.ts b/src/main/llm.ts index 81054acc..184cb4fc 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -27,7 +27,9 @@ import { buildLaunchArgs, type PresetField } from './llm/settings-math' -import { buildMessages, imageMime, thinkingPayload, type DecodedImage } from './llm/chat-payload' +import { buildMessages, thinkingPayload } from './llm/chat-payload' +import { readImages } from './llm/read-images' +import { detectThinkingDialect, type ThinkingDialect } from './llm/thinking-dialect' import { isValidGgufFile } from './models/gguf' import { readGgufContextLength } from './models/gguf-metadata' import { pickFreePort, isPortFree } from './free-port' @@ -366,17 +368,6 @@ export class LLMService { /** Read each image off disk and decode to base64 + mime (the one impure step of * payload building). A file that can't be read is logged and skipped so a broken * path never fails the whole request. */ - private decodeImages(images: string[]): DecodedImage[] { - const out: DecodedImage[] = [] - for (const imgPath of images) { - try { - out.push({ base64: fs.readFileSync(imgPath).toString('base64'), mime: imageMime(imgPath) }) - } catch (readErr) { - console.error(`[LLMService] Failed to read image ${imgPath}:`, readErr) - } - } - return out - } /** Update inference settings; respawns the server if any launch-time arg changed * (context, KV-cache type, flash-attn, GPU layers, threads, batch). */ @@ -907,6 +898,27 @@ export class LLMService { // 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). + /** Which thinking controls the LOADED model understands. Resolved once per load from the + * template llama-server publishes at /props; 'enable-thinking' until then, which is the + * behaviour every model got before this was resolved at all. */ + private thinkingDialect: ThinkingDialect = 'enable-thinking' + + /** Read the loaded model's chat template and remember which thinking dialect it speaks. + * Best-effort: a server that will not answer /props keeps the safe default rather than + * retaining the dialect of the model that was loaded before it. */ + private async resolveThinkingDialect(): Promise { + this.thinkingDialect = 'enable-thinking' + try { + const res = await fetch(`http://127.0.0.1:${this.port}/props`) + if (!res.ok) return + const body = (await res.json()) as { chat_template?: string } + this.thinkingDialect = detectThinkingDialect(body.chat_template) + console.log(`[LLMService] thinking dialect: ${this.thinkingDialect}`) + } catch (e) { + console.warn('[LLMService] could not read /props for the thinking dialect:', e) + } + } + private async waitForReady(timeout = 60000): Promise { const start = Date.now() let healthOk = false @@ -922,7 +934,10 @@ export class LLMService { 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 + if (Array.isArray(body?.data) && body.data.length > 0) { + await this.resolveThinkingDialect() + return + } } } } catch { @@ -960,7 +975,7 @@ export class LLMService { return await this.chatMutex.runExclusive(async () => { try { - const messages = buildMessages(message, this.decodeImages(images), this.systemPrompt) + const messages = buildMessages(message, readImages(images), this.systemPrompt) const payload: Record = { messages: messages, max_tokens: maxTokensForWire(resolveMaxTokens(maxTokens, this.maxTokens)), @@ -972,7 +987,9 @@ export class LLMService { if (opts.responseFormat) payload.response_format = opts.responseFormat // Turn off the model's reasoning channel for fast, direct output (its // chain-of-thought otherwise eats the token budget and leaves content empty). - if (opts.disableThinking) payload.chat_template_kwargs = { enable_thinking: false } + if (opts.disableThinking) { + Object.assign(payload, thinkingPayload(false, this.thinkingDialect)) + } const body = JSON.stringify(payload) console.log( @@ -1024,7 +1041,7 @@ export class LLMService { this.assertImageInputSupported(images) await this.ensureReady() - const messages = buildMessages(message, this.decodeImages(images), this.systemPrompt) + const messages = buildMessages(message, readImages(images), this.systemPrompt) const resolvedMaxTokens = resolveMaxTokens(maxTokens, this.maxTokens) const payload: Record = { messages, @@ -1035,7 +1052,7 @@ export class LLMService { // Thinking control: when on, ask the template to emit reasoning and have // llama.cpp split it into reasoning_content (deepseek-style); when off, // suppress it so the token budget goes to the answer. - ...thinkingPayload(!!opts.thinking) + ...thinkingPayload(!!opts.thinking, this.thinkingDialect) } const body = JSON.stringify(payload) @@ -1079,7 +1096,7 @@ export class LLMService { temperature: opts.temperature ?? this.temperature, ...this.samplingPayload(), stream: true, - ...thinkingPayload(!!opts.thinking) + ...thinkingPayload(!!opts.thinking, this.thinkingDialect) } if (opts.tools && opts.tools.length) { payload.tools = opts.tools diff --git a/src/main/llm/__tests__/sse-stream.test.ts b/src/main/llm/__tests__/sse-stream.test.ts index 19d22a07..71c08c32 100644 --- a/src/main/llm/__tests__/sse-stream.test.ts +++ b/src/main/llm/__tests__/sse-stream.test.ts @@ -268,4 +268,12 @@ describe('createToolMarkupFilter - hide tool-call markup from the visible stream f.end() expect(f.out()).toBe('answer ') }) + + it('removes a stray closing think tag before a text-form tool call', () => { + const f = collect() + f.push('I will search for that.\nraw protocol') + f.end() + expect(f.out()).toBe('I will search for that.\n') + }) }) diff --git a/src/main/llm/__tests__/thinking-dialect.test.ts b/src/main/llm/__tests__/thinking-dialect.test.ts new file mode 100644 index 00000000..ca4077ee --- /dev/null +++ b/src/main/llm/__tests__/thinking-dialect.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + detectThinkingDialect, + supportsThinkingToggle, + thinkingFragmentFor +} from '../thinking-dialect' + +describe('thinking dialect', () => { + it('detects the control exposed by the model template', () => { + expect(detectThinkingDialect(undefined)).toBe('none') + expect(detectThinkingDialect('plain assistant template')).toBe('none') + expect(detectThinkingDialect('{% if enable_thinking %}')).toBe('enable-thinking') + expect(detectThinkingDialect('{{ reasoning_strength }}')).toBe('reasoning-strength') + }) + + it('builds only the request fragment that the selected dialect understands', () => { + expect(thinkingFragmentFor('enable-thinking', true)).toEqual({ + chat_template_kwargs: { enable_thinking: true }, + reasoning_format: 'deepseek' + }) + expect(thinkingFragmentFor('enable-thinking', false)).toEqual({ + chat_template_kwargs: { enable_thinking: false } + }) + expect(thinkingFragmentFor('reasoning-strength', true)).toEqual({ + chat_template_kwargs: { reasoning_strength: 'high' } + }) + expect(thinkingFragmentFor('reasoning-strength', false)).toEqual({ + chat_template_kwargs: { reasoning_strength: 'none' } + }) + expect(thinkingFragmentFor('none', true)).toEqual({}) + }) + + it('offers a toggle only when the template can act on it', () => { + expect(supportsThinkingToggle('enable-thinking')).toBe(true) + expect(supportsThinkingToggle('reasoning-strength')).toBe(true) + expect(supportsThinkingToggle('none')).toBe(false) + }) +}) diff --git a/src/main/llm/chat-payload.ts b/src/main/llm/chat-payload.ts index 7ac785f7..d12474fe 100644 --- a/src/main/llm/chat-payload.ts +++ b/src/main/llm/chat-payload.ts @@ -8,6 +8,11 @@ import { mimeForExt } from '../mime' import { toWellFormedText } from './well-formed-text' +import { + thinkingFragmentFor, + type ThinkingDialect, + type ThinkingFragment +} from './thinking-dialect' export type ContentPart = | { type: 'text'; text: string } @@ -57,17 +62,16 @@ export function buildMessages( } /** The chat_template_kwargs / reasoning_format fragment for the thinking control. - * Streaming: thinking on -> ask the template to emit reasoning AND set deepseek - * reasoning_format (so llama.cpp splits it into reasoning_content); off -> suppress. - * Returns the exact object to spread into the payload. */ -export function thinkingPayload(thinking: boolean): { - chat_template_kwargs: { enable_thinking: boolean } - reasoning_format?: string -} { - if (thinking) { - return { chat_template_kwargs: { enable_thinking: true }, reasoning_format: 'deepseek' } - } - return { chat_template_kwargs: { enable_thinking: false } } + * + * WHICH controls to send depends on the loaded model's template, so the rule lives in + * thinking-dialect.ts and this delegates. The dialect defaults to 'enable-thinking' - the + * long-standing Qwen/Gemma behaviour - so a caller that has not resolved a template behaves + * exactly as before. */ +export function thinkingPayload( + thinking: boolean, + dialect: ThinkingDialect = 'enable-thinking' +): ThinkingFragment { + return thinkingFragmentFor(dialect, thinking) } /** What a client asked for, or undefined when it said nothing about thinking. */ diff --git a/src/main/llm/read-images.ts b/src/main/llm/read-images.ts new file mode 100644 index 00000000..04711571 --- /dev/null +++ b/src/main/llm/read-images.ts @@ -0,0 +1,33 @@ +// The ONE impure step in assembling a multimodal turn: reading image bytes off disk. +// +// It lived twice - a private decodeImages() in llm.ts for the plain chat path, and an inline +// readFileSync/base64/mime loop in tools.ts for the agentic path. Two copies of "turn a path into +// something the model server can read" drift: the tools copy also skipped the surrogate repair that +// chat-payload calls essential, so the same attachment produced a different request depending on +// whether the composer had tools switched on. +// +// chat-payload.ts stays pure and takes the DECODED images this returns. + +import fs from 'node:fs' +import { imageMime, type DecodedImage } from './chat-payload' + +/** + * Read each image path into base64 + MIME, in order. + * + * A path that cannot be read is skipped rather than throwing: one unreadable attachment must not + * lose the user's whole turn. The caller decides what an empty result means. + */ +export function readImages(paths: string[]): DecodedImage[] { + const decoded: DecodedImage[] = [] + for (const imgPath of paths) { + try { + decoded.push({ + base64: fs.readFileSync(imgPath).toString('base64'), + mime: imageMime(imgPath) + }) + } catch (readErr) { + console.error(`[readImages] Failed to read image ${imgPath}:`, readErr) + } + } + return decoded +} diff --git a/src/main/llm/sse-stream.ts b/src/main/llm/sse-stream.ts index 8c719e48..52c1c3ab 100644 --- a/src/main/llm/sse-stream.ts +++ b/src/main/llm/sse-stream.ts @@ -8,6 +8,8 @@ // chunks (which may straddle a ... tag across chunk boundaries) // and emits {text, kind} events, tracking the reasoning/answer channel. +import { maxPartialTagSuffix, REASONING_DELIMITERS, TOOL_CALL_OPENERS } from '@offgrid/sync' + type DeltaKind = 'content' | 'reasoning' export interface StreamEvent { @@ -121,25 +123,28 @@ export function createToolMarkupFilter(emit: (text: string) => void): { push: (text: string) => void end: () => void } { - const OPENER = /<\|?tool_call\|?>?|', ' + value.toLowerCase() + ) + const thinkDelimiter = REASONING_DELIMITERS.find((delimiter) => delimiter.open === '') + const dropMarkers = thinkDelimiter + ? [thinkDelimiter.open.toLowerCase(), thinkDelimiter.close.toLowerCase()] + : ['', ''] + const partialMarkers = [...suppressOpeners, ...dropMarkers] let suppressing = false let pending = '' - // The earliest index of a '<' from which the tail is a PARTIAL prefix of a known - // opener (so it might complete into one on the next chunk). -1 when no such tail - // exists — i.e. normal content that should stream now, at full granularity. - const partialOpenerAt = (s: string): number => { - for (let i = 0; i < s.length; i++) { - if (s[i] !== '<') { - continue - } - const tail = s.slice(i).toLowerCase() - if (OPENER_PREFIXES.some((p) => p.startsWith(tail))) { - return i - } + const earliest = ( + text: string, + tokens: readonly string[] + ): { index: number; token: string } | null => { + const lower = text.toLowerCase() + let found: { index: number; token: string } | null = null + for (const token of tokens) { + const index = lower.indexOf(token) + if (index >= 0 && (!found || index < found.index)) found = { index, token } } - return -1 + return found } const push = (text: string): void => { @@ -147,28 +152,26 @@ export function createToolMarkupFilter(emit: (text: string) => void): { return } pending += text - const m = OPENER.exec(pending) - if (m) { - if (m.index > 0) { - emit(pending.slice(0, m.index)) + while (pending) { + const opener = earliest(pending, suppressOpeners) + const marker = earliest(pending, dropMarkers) + if (opener && (!marker || opener.index <= marker.index)) { + if (opener.index > 0) emit(pending.slice(0, opener.index)) + suppressing = true + pending = '' + return } - suppressing = true - pending = '' - return - } - // No full opener. Hold back ONLY a trailing tail that could still become one; - // everything before it streams immediately (preserves per-token granularity). - const hold = partialOpenerAt(pending) - if (hold === -1) { - if (pending) { - emit(pending) - } - pending = '' - } else { - if (hold > 0) { - emit(pending.slice(0, hold)) + if (marker) { + if (marker.index > 0) emit(pending.slice(0, marker.index)) + pending = pending.slice(marker.index + marker.token.length) + continue } - pending = pending.slice(hold) + // Hold only a tail that can become a shared protocol marker in the next chunk. + const holdLength = maxPartialTagSuffix(pending.toLowerCase(), partialMarkers) + const emitLength = pending.length - holdLength + if (emitLength > 0) emit(pending.slice(0, emitLength)) + pending = pending.slice(emitLength) + return } } const end = (): void => { diff --git a/src/main/llm/thinking-dialect.ts b/src/main/llm/thinking-dialect.ts new file mode 100644 index 00000000..bb028280 --- /dev/null +++ b/src/main/llm/thinking-dialect.ts @@ -0,0 +1,73 @@ +// Which thinking controls the LOADED model actually understands. +// +// thinkingPayload used to send one pair to every model: chat_template_kwargs.enable_thinking plus +// reasoning_format 'deepseek'. That is the Qwen/Gemma dialect, and it is a no-op on a template that +// does not speak it. Muse Glimmer 30B has no enable_thinking variable and emits no tags - +// it reads `reasoning_strength` and writes its reasoning on a separate assistant channel - so the +// toggle moved nothing and the reasoning was never separated out. The switch looked broken from the +// one side that could not see why. +// +// The template is the source of truth for what a model understands, and llama-server publishes it +// at /props. This module is the pure rule over that string; llm.ts does the fetching. + +export type ThinkingDialect = + /** enable_thinking + delimiters. Qwen, Gemma and friends. */ + | 'enable-thinking' + /** A reasoning_strength string rendered into the system prompt. Muse Glimmer / Onyx ATEM. */ + | 'reasoning-strength' + /** The template exposes no thinking control we recognise. */ + | 'none' + +/** + * Read a chat template and report which thinking dialect it speaks. + * + * Detection is by the variable the template actually branches on, not by model name: a name is a + * label someone chose, while the variable is what the renderer will read. A template we do not + * recognise returns 'none', which is honest - we then send no thinking controls at all rather than + * switches it will ignore. + */ +export function detectThinkingDialect(chatTemplate: string | undefined): ThinkingDialect { + if (!chatTemplate) return 'none' + if (chatTemplate.includes('enable_thinking')) return 'enable-thinking' + if (chatTemplate.includes('reasoning_strength')) return 'reasoning-strength' + return 'none' +} + +export interface ThinkingFragment { + chat_template_kwargs?: Record + reasoning_format?: string +} + +/** + * The request fragment that turns thinking on or off for THIS model. + * + * 'enable-thinking' keeps the long-standing behaviour byte-for-byte: the template switch plus + * reasoning_format so llama.cpp splits the reasoning into reasoning_content instead of burying it + * in the answer. + * + * 'reasoning-strength' sends only the switch its template reads. It deliberately does NOT send + * reasoning_format 'deepseek': that parser looks for delimiters this model never emits, so + * asking for it can only fail to match. Leaving the field off lets the server apply its own + * per-model handling for the channel the model does use. + * + * 'none' sends nothing. A control the template cannot read is not a safe default - it is a silent + * lie to the user holding the toggle. + */ +export function thinkingFragmentFor(dialect: ThinkingDialect, thinking: boolean): ThinkingFragment { + if (dialect === 'enable-thinking') { + return thinking + ? { chat_template_kwargs: { enable_thinking: true }, reasoning_format: 'deepseek' } + : { chat_template_kwargs: { enable_thinking: false } } + } + if (dialect === 'reasoning-strength') { + // The template's own fallback is 'high' when the variable is undefined or empty, so 'high' is + // this model's natural on. The off value is NOT asserted here - see the note in llm.ts. + return { chat_template_kwargs: { reasoning_strength: thinking ? 'high' : 'none' } } + } + return {} +} + +/** Whether a toggle can do anything at all for this model - the UI should not offer a dead switch. */ +export function supportsThinkingToggle(dialect: ThinkingDialect): boolean { + return dialect !== 'none' +} diff --git a/src/main/media-roots.ts b/src/main/media-roots.ts index 40ae7c03..e247aa50 100644 --- a/src/main/media-roots.ts +++ b/src/main/media-roots.ts @@ -17,6 +17,9 @@ const LOCAL_MEDIA_DIRS = [ 'style-thumbs' ] as const -export function localMediaRoots(userData: string): string[] { - return LOCAL_MEDIA_DIRS.map((directory) => path.join(userData, directory)) +export function localMediaRoots(userData: string, resourceRoots: readonly string[] = []): string[] { + return [ + ...LOCAL_MEDIA_DIRS.map((directory) => path.join(userData, directory)), + ...resourceRoots.map((directory) => path.join(directory, 'style-thumbs')) + ] } diff --git a/src/main/media-server.ts b/src/main/media-server.ts index b8358a84..20e49676 100644 --- a/src/main/media-server.ts +++ b/src/main/media-server.ts @@ -21,6 +21,7 @@ import { MEDIA_PORT } from '../shared/ports' import { pickFreePort } from './free-port' import { mimeForExt } from './mime' import { localMediaRoots } from './media-roots' +import { resourceDirs } from './runtime-env' // Fixed loopback port so the renderer CSP (media-src) can allowlist it. Bound to // 127.0.0.1 only — not reachable off-device. Canonical value in shared/ports. @@ -197,7 +198,7 @@ function serveFile(req: http.IncomingMessage, res: http.ServerResponse, filePath /** Start the loopback media server (idempotent). Call after app is ready. */ export function startMediaServer(): void { productionServer ??= new LoopbackMediaServer({ - roots: localMediaRoots(app.getPath('userData')), + roots: localMediaRoots(app.getPath('userData'), resourceDirs()), port: MEDIA_PORT }) void productionServer.start().catch((error) => console.error('[media-server]', error)) diff --git a/src/main/mime.ts b/src/main/mime.ts index b2e0e6b9..af77495e 100644 --- a/src/main/mime.ts +++ b/src/main/mime.ts @@ -28,7 +28,16 @@ const EXT_MIME: Record = { webp: 'image/webp', gif: 'image/gif', bmp: 'image/bmp', - heic: 'image/heic' + heic: 'image/heic', + // document — a PDF served as the octet-stream fallback is DOWNLOADED by Chromium rather than + // rendered, so an attachment that opened correctly on Android arrived on desktop as a file save + // prompt. The kinds here match the ones @offgrid/sync's describeAttachment can name. + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv' } /** diff --git a/src/main/model-server.ts b/src/main/model-server.ts index 2b850359..74ff9fc8 100644 --- a/src/main/model-server.ts +++ b/src/main/model-server.ts @@ -746,8 +746,13 @@ async function executeImage( const b64 = out.dataUrl.slice(out.dataUrl.indexOf(',') + 1) const datum = responseFormat === 'url' - ? { url: `file://${out.path}`, seed: out.seed, model: out.model } - : { b64_json: b64, seed: out.seed, model: out.model } + ? { + url: `file://${out.path}`, + revised_prompt: out.prompt, + seed: out.seed, + model: out.model + } + : { b64_json: b64, revised_prompt: out.prompt, seed: out.seed, model: out.model } return { created: Math.floor(Date.now() / 1000), data: [datum], diff --git a/src/main/models-manager.ts b/src/main/models-manager.ts index 0ef8d35a..8b41cf50 100644 --- a/src/main/models-manager.ts +++ b/src/main/models-manager.ts @@ -21,7 +21,8 @@ import { findDownloaded, installedDownloadedIds, downloadedProtectedNames, - readDownloaded + reconcileDownloadedModelRegistry, + type DownloadedModel } from './downloaded-models' import { mergeCatalog, @@ -35,10 +36,12 @@ import { isChatLoadable, visionStatus, projectorToHeal, + isProjectorFileName, type CatalogEntry, type VisionStatus } from './models/catalog-logic' import { writeDiagnosticLog } from './diagnostics-log' +import { modelPackageIdentity, type TransferredModelManifest } from '@offgrid/sync' export interface DownloadProgress { modelId: string @@ -74,6 +77,22 @@ function fileSizeOf(dir: string, name: string): number { } } +function downloadedPrimary(model: DownloadedModel): string | undefined { + return model.files.find((name) => !isProjectorFileName(name)) ?? model.files[0] +} + +function downloadedProjector(model: DownloadedModel): string | undefined { + return model.files.find(isProjectorFileName) +} + +/** Exact id first; a unique family match keeps a stale pre-migration selection working. */ +function downloadedVariant(models: DownloadedModel[], id: string): DownloadedModel | undefined { + const exact = models.find((model) => model.id === id) + if (exact) return exact + const family = models.filter((model) => model.familyId === id) + return family.length === 1 ? family[0] : undefined +} + export async function getCatalog(): Promise<{ kinds: readonly string[]; models: unknown[] }> { const { CATALOG, MODEL_KINDS } = await import('@offgrid/models') const dir = llm.getModelsDir() @@ -82,9 +101,10 @@ export async function getCatalog(): Promise<{ kinds: readonly string[]; models: // catalog) in that exact order - decision in catalog-logic, filesystem probe // injected as a closure so it stays pure. const present = (name: string): boolean => fileSizeOf(dir, name) > 0 + const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) const models = mergeCatalog({ locals: getLocalModels(), - downloaded: readDownloaded(dir), + downloaded, installedDownloadedIds: installedDownloadedIds(dir), catalog: CATALOG as unknown as CatalogEntry[], present @@ -101,9 +121,10 @@ export async function getVisionStatuses(): Promise> const { CATALOG } = await import('@offgrid/models') const dir = llm.getModelsDir() const present = (name: string): boolean => fileSizeOf(dir, name) > 0 + const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) const merged = mergeCatalog({ locals: getLocalModels(), - downloaded: readDownloaded(dir), + downloaded, installedDownloadedIds: installedDownloadedIds(dir), catalog: CATALOG as unknown as CatalogEntry[], present @@ -123,9 +144,11 @@ export async function listInstalled(): Promise { const { CATALOG } = await import('@offgrid/models') const { isMfluxModelCached } = await import('./mflux') const dir = llm.getModelsDir() + const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) return installedIds({ locals: getLocalModels(), installedDownloadedIds: installedDownloadedIds(dir), + downloaded, catalog: CATALOG as unknown as CatalogEntry[], present: (name) => fileSizeOf(dir, name) > 0, mfluxCached: (id) => isMfluxModelCached(id) @@ -255,6 +278,7 @@ export async function downloadModel( } let activePartPath: string | null = null + let activePartRecoverable = true try { // Decide the JOB before reporting on it. A model is several files, and percent used to be // per-file: a two-file download ran 0→100 for the weights and then 0→100 again for the @@ -283,6 +307,7 @@ export async function downloadModel( const dest = path.join(dir, file.name) const partPath = `${dest}.part` activePartPath = partPath + activePartRecoverable = true // Resume from a partial .part if one exists (e.g. download interrupted by a // quit/crash) via an HTTP Range request, so we don't re-fetch GBs. let resumeFrom = 0 @@ -331,7 +356,10 @@ export async function downloadModel( // Verify the file is complete + valid BEFORE promoting it — never mark a // truncated/corrupt download installed (it loads as a blank "Chat model Down"). const integrityErr = downloadIntegrityError(file.name, written, total, partPath) - if (integrityErr) throw new Error(integrityErr) + if (integrityErr) { + activePartRecoverable = false + throw new Error(integrityErr) + } // Content check: when the file carries an expected SHA-256 (e.g. HF's lfs // oid), verify the bytes match — catches silent corruption the byte-count + // magic check can't. Skipped when no hash is known. @@ -340,7 +368,10 @@ export async function downloadModel( partPath, (file as { sha256?: string }).sha256 ) - if (checksumErr) throw new Error(checksumErr) + if (checksumErr) { + activePartRecoverable = false + throw new Error(checksumErr) + } fs.renameSync(partPath, dest) activePartPath = null jobDoneBytes += written // this file's real bytes now count toward the job, not a new 0% @@ -368,10 +399,10 @@ export async function downloadModel( send({ percent: 100, status: 'completed' }) return { success: true } } catch (err) { - // A capacity failure cannot resume until space is reclaimed, and retaining the - // bytes makes the full-volume condition worse. Other failures keep their - // partial file so retry can resume instead of downloading it again. - if (activePartPath && isStorageCapacityError(err)) { + // A capacity failure cannot resume until space is reclaimed. A file that failed byte or + // checksum validation must restart because the stored prefix is not trustworthy. Only a + // transport interruption keeps its partial file for a Range retry. + if (activePartPath && (!activePartRecoverable || isStorageCapacityError(err))) { try { fs.rmSync(activePartPath, { force: true }) } catch { @@ -397,10 +428,91 @@ export async function downloadModel( ) } +interface DeleteModelResult { + success: boolean + error?: string + freedFiles?: number +} + +interface TransferredDeletionContext { + dir: string + requestedId: string + target: DownloadedModel + downloaded: DownloadedModel[] + catalog: CatalogEntry[] +} + +function retainedTransferredFileNames(context: TransferredDeletionContext): Set { + const { target, downloaded, catalog, dir } = context + const retained = new Set() + catalog.forEach((model) => model.files.forEach((file) => retained.add(file.name))) + getLocalModels(dir).forEach((model) => { + retained.add(model.primary) + if (model.mmproj) retained.add(model.mmproj) + }) + downloaded + .filter((model) => model.id !== target.id) + .forEach((model) => model.files.forEach((name) => retained.add(name))) + return retained +} + +function clearTransferredModelSelections(target: DownloadedModel, requestedId: string): void { + const activeId = getActiveModel() + if (activeId === target.id || activeId === requestedId) { + try { + fs.rmSync(activeModelFile(), { force: true }) + } catch { + /* already clear */ + } + llm.reloadModel() + } + const primary = downloadedPrimary(target) + const modals = getAllActiveModals() + ;(Object.keys(modals) as Modality[]).forEach((kind) => { + if ( + modalSelectionMatches(modals[kind], target.id, primary) || + modalSelectionMatches(modals[kind], requestedId, primary) + ) { + setModal(kind, null) + } + }) +} + +function deleteTransferredModel(context: TransferredDeletionContext): DeleteModelResult { + const { dir, requestedId, target } = context + // A projector can be shared by two installed quants. Delete only files that no other installed + // model owns, then remove this exact package from the registry projection. + const retainedNames = retainedTransferredFileNames(context) + let freedFiles = 0 + for (const name of target.files) { + if (!retainedNames.has(name)) { + try { + const filePath = path.join(dir, name) + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }) + freedFiles++ + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : `could not delete ${name}`, + freedFiles + } + } + } + try { + fs.rmSync(path.join(dir, `${name}.part`), { force: true }) + } catch { + /* the installed package remains authoritative */ + } + } + removeDownloaded(dir, target.id) + clearTransferredModelSelections(target, requestedId) + return { success: true, freedFiles } +} + /** Delete a model's files from disk. Clears it as active if it was selected. */ -export async function deleteModel( - modelId: string -): Promise<{ success: boolean; error?: string; freedFiles?: number }> { +export async function deleteModel(modelId: string): Promise { const dir = llm.getModelsDir() // Imported local model: remove its files + registry entry, clear if active. if (modelId.startsWith('local:')) { @@ -428,6 +540,19 @@ export async function deleteModel( return { success: true, freedFiles: freedLocal } } const { CATALOG, resolveHuggingFaceModel } = await import('@offgrid/models') + const catalog = CATALOG as unknown as CatalogEntry[] + const downloaded = reconcileDownloadedModelRegistry(dir, catalog) + const transferred = downloadedVariant(downloaded, modelId) + if (transferred) { + return deleteTransferredModel({ + dir, + requestedId: modelId, + target: transferred, + downloaded, + catalog + }) + } + const entry = CATALOG.find((m) => m.id === modelId) ?? (await resolveHuggingFaceModel(modelId)) if (!entry) return { success: false, error: 'unknown model' } let freed = 0 @@ -495,6 +620,26 @@ export async function setActiveModel( return { success: true } } const { CATALOG, resolveHuggingFaceModel } = await import('@offgrid/models') + const dir = llm.getModelsDir() + const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) + const transferred = downloadedVariant(downloaded, modelId) + if (transferred) { + if (!isChatLoadable(transferred.kind)) { + return { + success: false, + error: `${transferred.kind} models are not loadable as the chat LLM` + } + } + const primary = downloadedPrimary(transferred) + if (!primary) return { success: false, error: 'transferred model has no primary file' } + const mmproj = downloadedProjector(transferred) ?? null + fs.writeFileSync( + activeModelFile(), + JSON.stringify({ id: transferred.id, primary, mmproj }, null, 2) + ) + llm.reloadModel() + return { success: true } + } const entry = CATALOG.find((m) => m.id === modelId) ?? (await resolveHuggingFaceModel(modelId)) if (!entry) return { success: false, error: 'unknown model' } if (!isChatLoadable(entry.kind)) { @@ -532,8 +677,29 @@ export async function reconcileActiveModelProjector(): Promise { } const { CATALOG } = await import('@offgrid/models') const dir = llm.getModelsDir() - const entry = (CATALOG as unknown as CatalogEntry[]).find((m) => m.id === cfg!.id) - const projector = projectorToHeal(cfg, entry, (name) => fileSizeOf(dir, name) > 0) + const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) + const active = cfg! + const transferred = active.id ? downloadedVariant(downloaded, active.id) : undefined + if (transferred) { + const primary = downloadedPrimary(transferred) + const mmproj = downloadedProjector(transferred) + if ( + primary && + mmproj && + fileSizeOf(dir, primary) > 0 && + fileSizeOf(dir, mmproj) > 0 && + (active.id !== transferred.id || active.primary !== primary || active.mmproj !== mmproj) + ) { + fs.writeFileSync( + activeModelFile(), + JSON.stringify({ id: transferred.id, primary, mmproj }, null, 2) + ) + llm.reloadModel() + return true + } + } + const entry = (CATALOG as unknown as CatalogEntry[]).find((m) => m.id === active.id) + const projector = projectorToHeal(active, entry, (name) => fileSizeOf(dir, name) > 0) if (!projector) { return false // already has one / no projector / not downloaded yet — leave as is } @@ -632,20 +798,14 @@ export interface TransferableModelFile { export interface TransferableModel { id: string + familyId: string + packageIdentity?: string name: string kind: string source: TransferableModelSource files: TransferableModelFile[] } -export interface TransferredModelManifest { - id: string - name: string - kind: string - source: TransferableModelSource - files: Array<{ name: string; sizeBytes: number }> -} - function localRegistryFile(dir = llm.getModelsDir()): string { return path.join(dir, 'local-models.json') } @@ -724,9 +884,12 @@ export async function getTransferableModel( dir = llm.getModelsDir() ): Promise { const local = getLocalModels(dir).find((model) => model.id === modelId) - const downloaded = findDownloaded(dir, modelId) const { CATALOG } = await import('@offgrid/models') const catalog = CATALOG.find((model) => model.id === modelId) + const downloaded = downloadedVariant( + reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]), + modelId + ) const source: TransferableModelSource | null = local ? 'local' @@ -749,7 +912,9 @@ export async function getTransferableModel( if (!files) return null return { - id: modelId, + id: downloaded?.id ?? modelId, + familyId: downloaded?.familyId ?? catalog?.id ?? local?.id ?? modelId, + packageIdentity: downloaded?.packageIdentity, name: local?.name ?? downloaded?.name ?? catalog?.name ?? modelId, kind: local?.kind ?? downloaded?.kind ?? catalog?.kind ?? 'text', source, @@ -780,31 +945,41 @@ export async function registerTransferredModel( const resolved = transferredFilesOnDisk(dir, manifest.files) if (!resolved.files) return { success: false, error: resolved.error } + // Projector presence is the package capability SSOT. A caller can still label an older catalog + // entry as text, but the installed package and its deterministic identity must be vision. + const normalizedManifest: TransferredModelManifest = manifest.files.some( + (file) => file.role === 'projector' || isProjectorFileName(file.name) + ) + ? { ...manifest, kind: 'vision' } + : manifest + const { CATALOG } = await import('@offgrid/models') - const catalog = CATALOG.find((model) => model.id === manifest.id) + const catalog = CATALOG.find((model) => model.id === normalizedManifest.id) if (catalog) { const expected = new Set(catalog.files.map((file) => file.name)) - const received = new Set(manifest.files.map((file) => file.name)) - if (expected.size !== received.size || [...expected].some((name) => !received.has(name))) { - return { success: false, error: 'transferred catalog model files do not match the catalog' } + const received = new Set(normalizedManifest.files.map((file) => file.name)) + if (expected.size === received.size && [...expected].every((name) => received.has(name))) { + return { success: true, id: normalizedManifest.id } } - return { success: true, id: manifest.id } + // A catalog id can have several valid quantizations and projector variants. The sender's + // manifest owns the exact installed files; the catalog owns only its download variant. + // Register a verified alternate variant below so it remains installed and transferable. } - if (manifest.source === 'local') { + if (normalizedManifest.source === 'local') { const primary = - manifest.files.find( + normalizedManifest.files.find( (file) => /\.gguf$/i.test(file.name) && !/mmproj|projector/i.test(file.name) - ) ?? manifest.files.find((file) => /\.gguf$/i.test(file.name)) + ) ?? normalizedManifest.files.find((file) => /\.gguf$/i.test(file.name)) if (!primary) return { success: false, error: 'local model transfer requires a GGUF file' } - const mmproj = manifest.files.find( + const mmproj = normalizedManifest.files.find( (file) => file.name !== primary.name && /\.gguf$/i.test(file.name) ) const id = `local:${primary.name}` const list = getLocalModels(dir).filter((model) => model.id !== id) list.push({ id, - name: manifest.name, + name: normalizedManifest.name, primary: primary.name, mmproj: mmproj?.name, kind: mmproj ? 'vision' : 'text', @@ -817,19 +992,23 @@ export async function registerTransferredModel( return { success: true, id } } + const exactPackageId = + normalizedManifest.packageIdentity ?? modelPackageIdentity(normalizedManifest) recordDownloaded(dir, { - id: manifest.id, - name: manifest.name, - kind: manifest.kind, - files: manifest.files.map((file) => file.name) + id: exactPackageId, + familyId: normalizedManifest.id, + packageIdentity: exactPackageId, + name: normalizedManifest.name, + kind: normalizedManifest.kind, + files: normalizedManifest.files.map((file) => file.name) }) - if (!findDownloaded(dir, manifest.id)) { + if (!findDownloaded(dir, exactPackageId)) { return { success: false, error: 'could not register the transferred model' } } if (dir === llm.getModelsDir()) { await reconcileActiveModelProjector().catch(() => false) } - return { success: true, id: manifest.id } + return { success: true, id: exactPackageId } } /** Set of every filename referenced by the local registry (primary + mmproj), so @@ -931,6 +1110,7 @@ export async function getStorageInfo(): Promise { const dir = llm.getModelsDir() const { CATALOG } = await import('@offgrid/models') const catalog = CATALOG as unknown as CatalogEntry[] + const reconciledDownloaded = reconcileDownloadedModelRegistry(dir, catalog) // Protect catalog + imported-local + free-form-download files, plus the active // chat selection's files, from being flagged/deleted as orphans. let activePrimary: string | null = null @@ -959,7 +1139,7 @@ export async function getStorageInfo(): Promise { const locals = getLocalModels() const installed = await listInstalled() const sizeOf = (name: string): number => fileSizeOf(dir, name) - const downloaded = readDownloaded(dir) + const downloaded = reconciledDownloaded const catalogIds = new Set(catalog.map((m) => m.id)) const catalogById = (id: string): CatalogEntry | undefined => catalog.find((m) => m.id === id) const models: ModelDiskEntry[] = installed.map((id) => diff --git a/src/main/models/__tests__/catalog-logic.test.ts b/src/main/models/__tests__/catalog-logic.test.ts index 4352f13a..601135aa 100644 --- a/src/main/models/__tests__/catalog-logic.test.ts +++ b/src/main/models/__tests__/catalog-logic.test.ts @@ -104,8 +104,8 @@ describe('downloadedForCatalog', () => { org: 'Hugging Face', tags: ['Downloaded'], files: [ - { name: 'hf.gguf', url: '' }, - { name: 'hf-mmproj.gguf', url: '' } + { name: 'hf.gguf', url: '', role: 'primary' }, + { name: 'hf-mmproj.gguf', url: '', role: 'mmproj' } ] } ]) @@ -152,6 +152,18 @@ describe('mergeCatalog — order + all three sources', () => { }) expect(out).toEqual([]) }) + it('projects one installed row when an exact variant represents a catalog family', () => { + const variant = { ...dl, id: 'model-package-v1:exact', familyId: catEntry.id } + const out = mergeCatalog({ + locals: [], + downloaded: [variant], + installedDownloadedIds: [variant.id], + catalog: [catEntry], + present: presentAll + }) + expect(out.map((model) => model.id)).toEqual([variant.id]) + expect(out[0]!.files.find((file) => file.role === 'mmproj')?.name).toBe('hf-mmproj.gguf') + }) }) describe('catalogEntryInstalled', () => { @@ -193,6 +205,19 @@ describe('installedIds — order + per-source predicate', () => { }) expect(out).toEqual(['cat/text']) }) + it('does not list the catalog alias beside an installed exact family variant', () => { + const variant = { ...dl, id: 'model-package-v1:exact', familyId: catEntry.id } + expect( + installedIds({ + locals: [], + installedDownloadedIds: [variant.id], + downloaded: [variant], + catalog: [catEntry], + present: presentAll, + mfluxCached: () => false + }) + ).toEqual([variant.id]) + }) it('includes an mflux id only when cached', () => { const both = installedIds({ locals: [], diff --git a/src/main/models/__tests__/model-integrity.integration.test.ts b/src/main/models/__tests__/model-integrity.integration.test.ts index e99b285f..5229f95b 100644 --- a/src/main/models/__tests__/model-integrity.integration.test.ts +++ b/src/main/models/__tests__/model-integrity.integration.test.ts @@ -126,6 +126,35 @@ describe('model-manager GGUF integrity', () => { }) }) + it('rejects same-shape Muse bytes when their SHA-256 does not match the shared catalog', async () => { + const muse = CATALOG.find((entry) => entry.id === 'unsloth/Muse-Glimmer-30B-GGUF') + if (!muse) throw new Error('Shared model catalog must include Muse Glimmer') + const wrongBytes = Buffer.concat([Buffer.from('GGUF', 'ascii'), Buffer.alloc(2_000, 17)]) + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Promise.resolve( + new Response(wrongBytes, { + status: 200, + headers: { 'content-length': String(wrongBytes.length) } + }) + ) + ) + ) + + const result = await manager.downloadModel(muse.id) + + expect(result).toEqual({ + success: false, + error: expect.stringMatching(/checksum mismatch/i) + }) + for (const file of muse.files) { + expect(fs.existsSync(path.join(dataDir, 'models', file.name))).toBe(false) + expect(fs.existsSync(path.join(dataDir, 'models', `${file.name}.part`))).toBe(false) + } + expect(await manager.listInstalled()).not.toContain(muse.id) + }) + it('rejects a truncated local GGUF before copying or registration', async () => { const source = path.join(dataDir, 'truncated.gguf') fs.writeFileSync(source, Buffer.from('GGUF', 'ascii')) diff --git a/src/main/models/__tests__/model-transfer-registration.integration.test.ts b/src/main/models/__tests__/model-transfer-registration.integration.test.ts index 28429e7f..dab1b80b 100644 --- a/src/main/models/__tests__/model-transfer-registration.integration.test.ts +++ b/src/main/models/__tests__/model-transfer-registration.integration.test.ts @@ -28,6 +28,58 @@ afterAll(() => { }) describe('device-transferred model registration', () => { + it('registers a valid vision variant whose files differ from the download catalog', async () => { + const primary = 'Qwen3.5-0.8B-Q4_0.gguf' + const projector = 'qwen3.5-0.8b-mmproj-F16.gguf' + const primaryBytes = validGguf(3) + const projectorBytes = validGguf(4) + fs.writeFileSync(path.join(dataDir, 'models', primary), primaryBytes) + fs.writeFileSync(path.join(dataDir, 'models', projector), projectorBytes) + + const result = await manager.registerTransferredModel({ + id: 'unsloth/Qwen3.5-0.8B-GGUF', + name: 'Qwen3.5 0.8B', + kind: 'vision', + source: 'downloaded', + files: [ + { name: primary, sizeBytes: primaryBytes.length }, + { name: projector, sizeBytes: projectorBytes.length } + ] + }) + + expect(result.success).toBe(true) + expect(result.id).toMatch(/^model-package-v1:[0-9a-f]{64}$/) + expect(await manager.listInstalled()).toContain(result.id) + expect(await manager.getTransferableModel(result.id!)).toMatchObject({ + id: result.id, + familyId: 'unsloth/Qwen3.5-0.8B-GGUF', + packageIdentity: result.id, + kind: 'vision', + source: 'downloaded', + files: [ + { name: primary, sizeBytes: primaryBytes.length }, + { name: projector, sizeBytes: projectorBytes.length } + ] + }) + + const otherPrimary = 'Qwen3.5-0.8B-Q5_K_M.gguf' + const otherBytes = validGguf(5) + fs.writeFileSync(path.join(dataDir, 'models', otherPrimary), otherBytes) + const other = await manager.registerTransferredModel({ + id: 'unsloth/Qwen3.5-0.8B-GGUF', + name: 'Qwen3.5 0.8B', + kind: 'vision', + source: 'downloaded', + files: [ + { name: otherPrimary, sizeBytes: otherBytes.length }, + { name: projector, sizeBytes: projectorBytes.length } + ] + }) + expect(other.success).toBe(true) + expect(other.id).not.toBe(result.id) + expect(await manager.listInstalled()).toEqual(expect.arrayContaining([result.id, other.id])) + }) + it('registers a verified multi-file model through the production catalog owner', async () => { const primary = 'shared-model-q4.gguf' const projector = 'mmproj-shared-model-f16.gguf' @@ -47,11 +99,13 @@ describe('device-transferred model registration', () => { ] }) - expect(result).toEqual({ success: true, id: 'off-grid/test-shared-model' }) - expect(await manager.listInstalled()).toContain('off-grid/test-shared-model') + expect(result.success).toBe(true) + expect(result.id).toMatch(/^model-package-v1:[0-9a-f]{64}$/) + expect(await manager.listInstalled()).toContain(result.id) expect((await manager.getStorageInfo()).orphans).toEqual([]) - expect(await manager.getTransferableModel('off-grid/test-shared-model')).toMatchObject({ - id: 'off-grid/test-shared-model', + expect(await manager.getTransferableModel(result.id!)).toMatchObject({ + id: result.id, + familyId: 'off-grid/test-shared-model', name: 'Test shared model', kind: 'vision', source: 'downloaded', @@ -62,6 +116,48 @@ describe('device-transferred model registration', () => { }) }) + it('deletes the selected transferred variant and keeps files owned by a sibling variant', async () => { + const sharedProjector = 'mmproj-delete-family-f16.gguf' + const firstPrimary = 'delete-family-q4_0.gguf' + const secondPrimary = 'delete-family-q5_k_m.gguf' + const projectorBytes = validGguf(6) + const firstBytes = validGguf(7) + const secondBytes = validGguf(8) + fs.writeFileSync(path.join(dataDir, 'models', sharedProjector), projectorBytes) + fs.writeFileSync(path.join(dataDir, 'models', firstPrimary), firstBytes) + fs.writeFileSync(path.join(dataDir, 'models', secondPrimary), secondBytes) + + const register = ( + primary: string, + bytes: Buffer + ): ReturnType => + manager.registerTransferredModel({ + id: 'off-grid/delete-family', + name: 'Delete family', + kind: 'vision', + source: 'downloaded', + files: [ + { name: primary, sizeBytes: bytes.length }, + { name: sharedProjector, sizeBytes: projectorBytes.length } + ] + }) + const first = await register(firstPrimary, firstBytes) + const second = await register(secondPrimary, secondBytes) + expect(first.success).toBe(true) + expect(second.success).toBe(true) + + expect(await manager.deleteModel(first.id!)).toEqual({ success: true, freedFiles: 1 }) + expect(fs.existsSync(path.join(dataDir, 'models', firstPrimary))).toBe(false) + expect(fs.existsSync(path.join(dataDir, 'models', sharedProjector))).toBe(true) + expect(await manager.listInstalled()).not.toContain(first.id) + expect(await manager.listInstalled()).toContain(second.id) + + expect(await manager.deleteModel(second.id!)).toEqual({ success: true, freedFiles: 2 }) + expect(fs.existsSync(path.join(dataDir, 'models', secondPrimary))).toBe(false) + expect(fs.existsSync(path.join(dataDir, 'models', sharedProjector))).toBe(false) + expect(await manager.listInstalled()).not.toContain(second.id) + }) + it('rejects traversal and corrupt GGUF manifests without registering them', async () => { expect( await manager.registerTransferredModel({ diff --git a/src/main/models/__tests__/transferred-vision-activation.integration.test.ts b/src/main/models/__tests__/transferred-vision-activation.integration.test.ts new file mode 100644 index 00000000..7c8f731c --- /dev/null +++ b/src/main/models/__tests__/transferred-vision-activation.integration.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { CATALOG } from '@offgrid/models' + +const originalDataDir = process.env.OFFGRID_DATA_DIR +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-transferred-vision-')) +process.env.OFFGRID_DATA_DIR = dataDir + +const manager = await import('../../models-manager') + +const FAMILY_ID = 'unsloth/Qwen3.5-0.8B-GGUF' +const PRIMARY = 'Qwen3.5-0.8B-Q4_K_M.gguf' +const PROJECTOR = 'qwen3.5-0.8b-mmproj-F16.gguf' +const modelsDir = path.join(dataDir, 'models') + +function validGguf(marker: number): Buffer { + return Buffer.concat([Buffer.from('GGUF', 'ascii'), Buffer.alloc(2_048, marker)]) +} + +beforeAll(() => { + fs.mkdirSync(modelsDir, { recursive: true }) +}) + +afterAll(() => { + if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR + else process.env.OFFGRID_DATA_DIR = originalDataDir + fs.rmSync(dataDir, { recursive: true, force: true }) +}) + +describe('transferred vision variant activation', () => { + it('migrates the legacy family alias and always activates the transferred projector', async () => { + const catalog = CATALOG.find((model) => model.id === FAMILY_ID) + expect(catalog).toBeDefined() + const catalogPrimary = catalog!.files.find((file) => file.role === 'primary')!.name + const catalogProjector = catalog!.files.find((file) => file.role === 'mmproj')!.name + + for (const [index, name] of [PRIMARY, PROJECTOR, catalogPrimary, catalogProjector].entries()) { + fs.writeFileSync(path.join(modelsDir, name), validGguf(index + 1)) + } + fs.writeFileSync( + path.join(modelsDir, 'downloaded-models.json'), + JSON.stringify([ + { + id: FAMILY_ID, + name: 'Qwen 3.5 0.8B', + kind: 'vision', + files: [PRIMARY, PROJECTOR] + } + ]) + ) + fs.writeFileSync( + path.join(modelsDir, 'active-model.json'), + JSON.stringify({ id: FAMILY_ID, primary: catalogPrimary, mmproj: catalogProjector }) + ) + + const catalogView = (await manager.getCatalog()).models as Array<{ + id: string + name: string + files: Array<{ name: string; role?: string }> + }> + const registry = JSON.parse( + fs.readFileSync(path.join(modelsDir, 'downloaded-models.json'), 'utf8') + ) as Array<{ id: string; familyId?: string; packageIdentity?: string; files: string[] }> + expect(registry).toHaveLength(1) + expect(registry[0]).toMatchObject({ + id: expect.stringMatching(/^model-package-v1:[0-9a-f]{64}$/), + familyId: FAMILY_ID, + packageIdentity: expect.stringMatching(/^model-package-v1:[0-9a-f]{64}$/), + files: [PRIMARY, PROJECTOR] + }) + const exactId = registry[0]!.id + expect( + catalogView.filter((model) => model.id === FAMILY_ID || model.id === exactId).map((m) => m.id) + ).toEqual([exactId]) + expect(catalogView.find((model) => model.id === exactId)?.files).toEqual( + expect.arrayContaining([expect.objectContaining({ name: PROJECTOR, role: 'mmproj' })]) + ) + expect(await manager.listInstalled()).toEqual(expect.arrayContaining([exactId])) + expect(await manager.listInstalled()).not.toContain(FAMILY_ID) + + expect(await manager.reconcileActiveModelProjector()).toBe(true) + expect(JSON.parse(fs.readFileSync(path.join(modelsDir, 'active-model.json'), 'utf8'))).toEqual({ + id: exactId, + primary: PRIMARY, + mmproj: PROJECTOR + }) + expect(await manager.setActiveModel(FAMILY_ID)).toEqual({ success: true }) + expect(JSON.parse(fs.readFileSync(path.join(modelsDir, 'active-model.json'), 'utf8'))).toEqual({ + id: exactId, + primary: PRIMARY, + mmproj: PROJECTOR + }) + expect(await manager.getVisionStatuses()).toMatchObject({ + [exactId]: { supportsVision: true, projectorInstalled: true } + }) + + // Registry reconciliation only changes metadata. It never deletes either transferred bytes or + // older catalog bytes that may still be on disk. + for (const name of new Set([PRIMARY, PROJECTOR, catalogPrimary, catalogProjector])) { + expect(fs.existsSync(path.join(modelsDir, name))).toBe(true) + } + }) +}) diff --git a/src/main/models/catalog-logic.ts b/src/main/models/catalog-logic.ts index e3f47112..733ed355 100644 --- a/src/main/models/catalog-logic.ts +++ b/src/main/models/catalog-logic.ts @@ -43,6 +43,7 @@ export interface LocalModelLike { } export interface DownloadedModelLike { id: string + familyId?: string name: string kind: string files: string[] @@ -53,6 +54,9 @@ export type SizeOf = (name: string) => number /** True when a filename exists on disk with size > 0. */ export type FilePresent = (name: string) => boolean +export const isProjectorFileName = (name: string): boolean => + /(?:^|[-_.])(mmproj|projector)(?:[-_.]|$)/i.test(name) + /** A catalog-shaped view of an imported local model whose primary file is present * (size > 0). Tagged "Imported" and surfaced at the top of the catalog. */ export function localsForCatalog(locals: LocalModelLike[], present: FilePresent): CatalogEntry[] { @@ -73,9 +77,9 @@ export function localsForCatalog(locals: LocalModelLike[], present: FilePresent) * `installedIds` is the set of downloaded ids whose every file is present. */ export function downloadedForCatalog( downloaded: DownloadedModelLike[], - installedIds: Iterable + installedDownloadIds: Iterable ): CatalogEntry[] { - const installed = new Set(installedIds) + const installed = new Set(installedDownloadIds) return downloaded .filter((m) => installed.has(m.id)) .map((m) => ({ @@ -84,7 +88,11 @@ export function downloadedForCatalog( kind: m.kind, org: 'Hugging Face', tags: ['Downloaded'], - files: m.files.map((name) => ({ name, url: '' })) + files: m.files.map((name) => ({ + name, + url: '', + role: isProjectorFileName(name) ? 'mmproj' : 'primary' + })) })) } @@ -97,10 +105,17 @@ export function mergeCatalog(opts: { catalog: CatalogEntry[] present: FilePresent }): CatalogEntry[] { + const installed = new Set(opts.installedDownloadedIds) + const representedFamilies = new Set( + opts.downloaded + .filter((model) => installed.has(model.id)) + .map((model) => model.familyId) + .filter((id): id is string => Boolean(id)) + ) return [ ...localsForCatalog(opts.locals, opts.present), ...downloadedForCatalog(opts.downloaded, opts.installedDownloadedIds), - ...opts.catalog + ...opts.catalog.filter((entry) => !representedFamilies.has(entry.id)) ] } @@ -120,15 +135,25 @@ export function catalogEntryInstalled( export function installedIds(opts: { locals: LocalModelLike[] installedDownloadedIds: Iterable + downloaded?: DownloadedModelLike[] catalog: CatalogEntry[] present: FilePresent mfluxCached: (id: string) => boolean }): string[] { + const downloadedIds = [...opts.installedDownloadedIds] + const installed = new Set(downloadedIds) + const representedFamilies = new Set( + (opts.downloaded ?? []) + .filter((model) => installed.has(model.id)) + .map((model) => model.familyId) + .filter((id): id is string => Boolean(id)) + ) const catalog = opts.catalog + .filter((model) => !representedFamilies.has(model.id)) .filter((m) => catalogEntryInstalled(m, opts.present, opts.mfluxCached)) .map((m) => m.id) const locals = opts.locals.filter((lm) => opts.present(lm.primary)).map((lm) => lm.id) - return [...locals, ...opts.installedDownloadedIds, ...catalog] + return [...locals, ...downloadedIds, ...catalog] } /** The primary filename for a catalog entry: the file tagged role 'primary', else @@ -217,7 +242,7 @@ export function buildDiskEntry(opts: { const dl = opts.downloaded.find((m) => m.id === id) if (dl && !opts.isCatalogId(id)) { const bytes = dl.files.reduce((s, n) => s + sizeOf(n), 0) - const primary = dl.files[0] + const primary = dl.files.find((name) => !isProjectorFileName(name)) ?? dl.files[0] return { id, name: dl.name, diff --git a/src/main/ogcapture-serve.ts b/src/main/ogcapture-serve.ts index dc3748bd..daddc799 100644 --- a/src/main/ogcapture-serve.ts +++ b/src/main/ogcapture-serve.ts @@ -122,3 +122,10 @@ export async function serveCaptureFile( return new Response(null, { status: 404 }) } } + +/** + * The reader half of the scheme now lives beside its writer in `shared/ogcapture-url`, because the + * renderer builds these URLs and the main process resolves them: written apart, they drifted, and the + * drift was invisible on macOS. Re-exported here so existing callers and their tests keep their import. + */ +export { capturePathFromUrl } from '../shared/ogcapture-url' diff --git a/src/main/secrets.ts b/src/main/secrets.ts index 5b53c1f6..c9112bc3 100644 --- a/src/main/secrets.ts +++ b/src/main/secrets.ts @@ -8,6 +8,14 @@ import { safeStorage } from 'electron' import { getDB } from './database' let ready = false +let unavailableWarningLogged = false + +function reportUnavailable(): void { + if (unavailableWarningLogged) return + unavailableWarningLogged = true + console.error('[secrets] OS encryption unavailable - protected secrets cannot be read or written') +} + function ensure(): void { if (ready) return getDB().exec( @@ -32,7 +40,7 @@ export function secretsAvailable(): boolean { export function setSecret(key: string, value: string): boolean { ensure() if (!secretsAvailable()) { - console.error('[secrets] OS encryption unavailable — refusing to store plaintext') + reportUnavailable() return false } const enc = safeStorage.encryptString(value) @@ -51,6 +59,10 @@ export function getSecret(key: string): string | null { | { blob: Buffer } | undefined if (!row) return null + if (!secretsAvailable()) { + reportUnavailable() + return null + } try { return safeStorage.decryptString(row.blob) } catch (e) { diff --git a/src/main/secure-storage-bootstrap.ts b/src/main/secure-storage-bootstrap.ts new file mode 100644 index 00000000..6c23629e --- /dev/null +++ b/src/main/secure-storage-bootstrap.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs' +import path from 'node:path' + +interface KeychainRepairResult { + status: 'healthy' | 'repaired' | 'unavailable' | 'failed' + detail: string + osStatus?: number | null +} + +function helperCandidates(packaged: boolean): string[] { + if (packaged) return [path.join(process.resourcesPath, 'bin', 'keychain-bootstrap.node')] + return [ + path.join(process.cwd(), 'resources', 'bin', 'keychain-bootstrap.node'), + path.join(__dirname, '../../resources/bin/keychain-bootstrap.node') + ] +} + +function describeNativeResult(value: unknown): KeychainRepairResult | null { + if (!value || typeof value !== 'object') return null + const result = value as { status?: unknown; osStatus?: unknown } + const osStatus = typeof result.osStatus === 'number' ? result.osStatus : null + switch (result.status) { + case 0: + return { status: 'healthy', detail: 'The user default Keychain is available.', osStatus } + case 1: + return { + status: 'repaired', + detail: 'The existing login Keychain was restored for Off Grid secure storage.', + osStatus + } + case 2: + return { + status: 'unavailable', + detail: 'The login Keychain is unavailable and was not changed.', + osStatus + } + case 3: + return { + status: 'failed', + detail: 'macOS refused to restore the existing login Keychain.', + osStatus + } + default: + return null + } +} + +/** + * Restore only the observed macOS failure: no user-default Keychain registration, + * while the existing login.keychain-db remains valid. The native bridge refuses + * every other state and never creates, resets, unlocks, or deletes a Keychain. + * + * This must run before app.ready and before any safeStorage consumer. Electron's + * macOS safeStorage bootstrap otherwise opens SecurityAgent's generic "Keychain + * Not Found" dialog before Off Grid can explain or recover the problem. + */ +export function repairMissingDefaultKeychainAtBootstrap( + platform: NodeJS.Platform, + packaged: boolean +): KeychainRepairResult | null { + if (platform !== 'darwin') return null + const bindingPath = helperCandidates(packaged).find((candidate) => fs.existsSync(candidate)) + if (!bindingPath) { + console.error('[secure-storage] Keychain recovery bridge is missing') + return null + } + + try { + const nativeModule = { exports: {} } as NodeModule + process.dlopen(nativeModule, bindingPath) + const result = describeNativeResult(nativeModule.exports) + if (!result) console.error('[secure-storage] Keychain recovery returned an invalid response') + return result + } catch (error) { + console.error('[secure-storage] Keychain recovery failed', error) + return null + } +} diff --git a/src/main/tool-content.ts b/src/main/tool-content.ts deleted file mode 100644 index a416e5c9..00000000 --- a/src/main/tool-content.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Pure, Electron-free helper for the agentic tool chat: build the user message -// content for an OpenAI-style /chat/completions call. With no images it's a plain -// string; with images it's a multimodal content array (text + image_url parts) so -// the vision model can read attachments even in tools/connectors mode. - -export type UserContentPart = - | { type: 'text'; text: string } - | { type: 'image_url'; image_url: { url: string } } - -export function buildUserContent( - query: string, - imageDataUrls: string[] = [] -): string | UserContentPart[] { - if (!imageDataUrls.length) return query - return [ - { type: 'text', text: query }, - ...imageDataUrls.map((url): UserContentPart => ({ type: 'image_url', image_url: { url } })) - ] -} diff --git a/src/main/tools.ts b/src/main/tools.ts index 477d086e..45098f1b 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -6,15 +6,14 @@ // and loop until it answers. Built-in tools only (no network) for now — web // search + MCP connectors plug in here later. -import fs from 'fs' import { llm } from './llm' import { SEARCH_KB_TOOL, makeSearchKnowledgeBaseHandler } from '@offgrid/rag' import { isMemoryToolAllowed } from './tools/memory-scope' import { parseToolCallsFromText } from './tools/tool-call-parse' import { getSetting, saveSetting } from './database' -import { buildUserContent } from './tool-content' +import { buildContentParts } from './llm/chat-payload' +import { readImages } from './llm/read-images' import { stripTags, htmlToText, decodeDdgHref } from './tools-parsers' -import { mimeFromExt } from './model-server/data-url' import { evaluateArithmetic } from './calculator' // Per-tool enable/disable, persisted as a list of disabled tool names. @@ -309,7 +308,7 @@ const TOOLS: ToolDef[] = [ }, { // generate_image is DEFERRED: run() never generates. It records the requested - // prompt as `imageRequest` (the loop keeps the last one) so the renderer + // prompt as an image request so the renderer // generates AFTER the turn — generating inline would evict the LLM from unified // memory mid-loop and risk a nested modality-queue deadlock. name: 'generate_image', @@ -448,6 +447,8 @@ export async function toolChat( answer: string toolCalls: ToolCall[] unified: UnifiedSource[] + imageRequests: { prompt: string }[] + /** Compatibility alias for older renderer bundles that can generate only one image. */ imageRequest?: { prompt: string } }> { await llm.init() // respects pause; ensures the server is up @@ -543,33 +544,38 @@ export async function toolChat( // of truth (the renderer's flag is fetched once per mount and can be stale). A // text-only model given image_url parts either ignores them (silent wrong answer) // or errors, so drop the attachments when there's no vision projector. - const imageDataUrls: string[] = [] - if (opts.images?.length && llm.hasVision()) { - for (const p of opts.images) { - try { - const base64 = fs.readFileSync(p).toString('base64') - // Route through the shared ext->MIME map (image/png fallback) so a .webp - // attachment is labelled image/webp, not the old png-or-jpeg guess that - // mislabelled webp as image/jpeg (which the vision model may reject). - const mime = mimeFromExt(p.split('.').pop() ?? '') - imageDataUrls.push(`data:${mime};base64,${base64}`) - } catch (e) { - console.error('[tools] failed to read image', p, e) - } - } - } + const decodedImages = opts.images?.length && llm.hasVision() ? readImages(opts.images) : [] // eslint-disable-next-line @typescript-eslint/no-explicit-any const messages: any[] = [ { role: 'system', content: sys }, ...history.slice(-10).map((m) => ({ role: m.role, content: m.content })), - { role: 'user', content: buildUserContent(query, imageDataUrls) } + { role: 'user', content: buildContentParts(query, decodedImages) } ] const toolCalls: ToolCall[] = [] const unified: UnifiedSource[] = [] const unifiedKeys = new Set() - // Deferred image generation: the loop only RECORDS the requested prompt (last call - // wins). The renderer generates after the turn so we never evict the LLM mid-loop. - let imageRequest: { prompt: string } | undefined + // Deferred image generation: keep EVERY request in tool-call order. The renderer generates after + // the turn so we never evict the LLM mid-loop, and one model round that asks for two pictures does + // not silently replace the first request with the last. + const imageRequests: { prompt: string }[] = [] + const resultWithImages = (result: { + answer: string + toolCalls: ToolCall[] + unified: UnifiedSource[] + }): { + answer: string + toolCalls: ToolCall[] + unified: UnifiedSource[] + imageRequests: { prompt: string }[] + imageRequest?: { prompt: string } + } => { + const finalImageRequest = imageRequests.at(-1) + return { + ...result, + imageRequests, + ...(finalImageRequest ? { imageRequest: finalImageRequest } : {}) + } + } for (let step = 0; step < 5; step++) { // Stream this round: reasoning + any answer text flow through onDelta live; tool_calls @@ -590,7 +596,8 @@ export async function toolChat( // tool_calls it assembled (it doesn't reject on abort), so we MUST NOT execute // them — a cancelled turn fires no side effects (e.g. an MCP send/create). // Return what we have; the renderer treats the turn as cancelled. - if (opts.signal?.aborted) return { answer: content.trim(), toolCalls, unified, imageRequest } + if (opts.signal?.aborted) + return resultWithImages({ answer: content.trim(), toolCalls, unified }) // Native tool_calls are preferred; but small on-device models (the gemma-4 we // ship) often emit a call as TEXT instead of on the tool_calls channel. When @@ -634,8 +641,8 @@ export async function toolChat( } opts.onStep?.({ name: c.name, args: c.args }) // surface the tool activity BEFORE running it // Uniform dispatch — every tool owns its own result. Merge any structured - // side channels: sources are deduped into `unified` across rounds; the last - // non-empty imageRequest wins (deferred generation after the turn). + // side channels: sources are deduped into `unified` across rounds; image requests retain + // call order for deferred generation after the turn. const res = await runTool( c.name, c.args, @@ -647,7 +654,7 @@ export async function toolChat( unifiedKeys.add(s.key) unified.push(s) } - if (res.imageRequest) imageRequest = res.imageRequest + if (res.imageRequest) imageRequests.push(res.imageRequest) toolCalls.push({ name: c.name, args: c.args, result: res.text }) // Surface the COMPLETED call (with its result) live, so the UI can show each // tool call + result as it lands, not only in the final batch. @@ -657,13 +664,13 @@ export async function toolChat( continue // let the model use the results } // No tool calls this round: `content` is the final answer (already streamed via onDelta). - return { answer: content.trim(), toolCalls, unified, imageRequest } + return resultWithImages({ answer: content.trim(), toolCalls, unified }) } // Step cap reached with the model still calling tools. Instead of dead-ending // with a canned "stopped" message, FORCE one final answer WITHOUT tools, so the // user gets a real response built from the results gathered so far. if (opts.signal?.aborted) { - return { answer: '', toolCalls, unified, imageRequest } + return resultWithImages({ answer: '', toolCalls, unified }) } const final = await llm.streamChat(messages, onDelta, { temperature: 0.3, @@ -672,12 +679,11 @@ export async function toolChat( thinking: false, signal: opts.signal }) - return { + return resultWithImages({ answer: final.content.trim() || 'Stopped after too many tool steps.', toolCalls, - unified, - imageRequest - } + unified + }) } /** Parse a tool-call arguments JSON string to an object; empty object on failure. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 1eae4bad..c4ce7977 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,11 +2,15 @@ import { contextBridge, ipcRenderer } from 'electron' import { CACHE_CLEANUP_CHANNEL, type ArtifactKindContract, + type ActiveChatStreamContract, type CacheCleanupResultContract, type RagChatResultContract, type SystemHealthContract } from '../shared/ipc-contracts' -import type { ImageGenerationRequestContract } from '../shared/image-generation-contract' +import type { + ImageGenerationRequestContract, + ImageGenerationResultContract +} from '../shared/image-generation-contract' import { BACKUP_EXPORT_ALL_CHANNEL, BACKUP_IMPORT_CHANNEL, @@ -118,7 +122,7 @@ const offGridApi = { onRagStream: ( callback: (data: { streamId: string - type: 'content' | 'reasoning' | 'step' | 'tool_result' + type: 'content' | 'reasoning' | 'step' | 'tool_result' | 'done' text?: string step?: unknown call?: { name: string; result: string } @@ -128,7 +132,7 @@ const offGridApi = { _: unknown, data: { streamId: string - type: 'content' | 'reasoning' | 'step' | 'tool_result' + type: 'content' | 'reasoning' | 'step' | 'tool_result' | 'done' text?: string step?: unknown call?: { name: string; result: string } @@ -137,6 +141,8 @@ const offGridApi = { ipcRenderer.on('rag:stream', sub) return unsubscribe('rag:stream', sub) }, + getActiveRagStreams: () => + ipcRenderer.invoke('rag:active-streams') as Promise, // Stop an in-flight streaming turn; the partial answer is kept. cancelRag: (streamId: string) => ipcRenderer.send('rag:cancel', streamId), @@ -521,8 +527,6 @@ const offGridApi = { listGeneratedImages: (scope?: { conversationId?: string; projectId?: string | null }) => ipcRenderer.invoke('imagegen:list', scope), styleThumbs: () => ipcRenderer.invoke('imagegen:style-thumbs'), - makeStyleThumb: (key: string, prompt: string) => - ipcRenderer.invoke('imagegen:make-style-thumb', key, prompt), listLoras: () => ipcRenderer.invoke('imagegen:list-loras'), revealLoras: () => ipcRenderer.invoke('imagegen:reveal-loras'), downloadLora: (url: string, filename: string) => @@ -535,28 +539,6 @@ const offGridApi = { deleteGeneratedImage: (p: string) => ipcRenderer.invoke('imagegen:delete', p), exportGeneratedImage: (srcPath: string, suggestedName?: string) => ipcRenderer.invoke('imagegen:export', srcPath, suggestedName), - onImageGenProgress: ( - cb: (p: { - step: number - total: number - secPerStep: number - preview?: string - phase?: 'sampling' | 'decoding' - }) => void - ) => { - const sub = ( - _event: unknown, - p: { - step: number - total: number - secPerStep: number - preview?: string - phase?: 'sampling' | 'decoding' - } - ): void => cb(p) - ipcRenderer.on('imagegen:progress', sub) - return unsubscribe('imagegen:progress', sub) - }, onImageGenJobState: ( cb: (state: import('../shared/image-generation-contract').ImageGenerationJobContract) => void ) => { @@ -583,7 +565,7 @@ const offGridApi = { conversationId?: string projectId?: string | null } - ) => ipcRenderer.invoke('imagegen:generate', params), + ) => ipcRenderer.invoke('imagegen:generate', params) as Promise, // --- Projects + RAG (knowledge bases) + project chat --- listProjects: () => ipcRenderer.invoke('projects:list'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eaee738f..3684dee6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -154,6 +154,22 @@ function ReprocessingBanner() { ) } +// One rule for the look of EVERY sidebar row - nav items, the model-status row, the mobile-app +// link. The Tailwind palette is remapped onto the theme-aware --og-* tokens in assets/main.css, +// so these classes already flip with data-theme and no `dark:` variant belongs here: `dark:` is +// Tailwind's own prefers-color-scheme media query, a SECOND source of truth for the theme that +// disagrees with data-theme whenever the app theme and the OS theme differ. +// The tell that made this visible: neutral-900 is a SURFACE token here (#f5f5f5 in light), not a +// text token, so `hover:text-neutral-900` painted the label near-white on a near-white row. +const navRowClass = (expanded: boolean, active = false): string => + cn( + 'group/nav relative flex items-center gap-3 rounded-lg py-2 text-sm transition-colors', + expanded ? 'px-3' : 'justify-center px-0', + active + ? 'bg-green-500/10 text-emerald-400' + : 'text-neutral-400 hover:bg-neutral-500/10 hover:text-white' + ) + // Model-server health dot for the sidebar. Uses the SAME live probe as the System // Health panel (system:health → real /health check), not llm.isReady() (an internal // flag that lags). Green = running, amber = starting, red = stopped (e.g. a SIGKILL @@ -215,13 +231,11 @@ function ModelStatusDot({ : `${text} - expand for details` return ( + + + ) +} -// Gemini-style visual style presets — pick one, then describe the subject; the -// style text is appended to the prompt. `swatch` = a characteristic gradient so -// the gallery is pictorial (no bundled images needed). -// `prompt` = style modifier appended to the user's subject when generating. -// `preview` = the subject used for the on-device style-thumbnail (varied per -// style so the grid showcases the style, not a gallery of faces). -const STYLE_PRESETS: { name: string; prompt: string; preview: string; swatch: string }[] = [ - { - name: 'Photoreal', - prompt: 'photorealistic, sharp focus, high detail, 50mm photo', - preview: 'a red fox standing in a misty forest', - swatch: 'from-stone-400 to-stone-600' - }, - { - name: 'Cinematic', - prompt: 'cinematic film still, dramatic lighting, shallow depth of field, color graded', - preview: 'a lone car on a coastal highway at sunset', - swatch: 'from-orange-800 via-neutral-800 to-teal-800' - }, - { - name: 'Anime', - prompt: 'anime illustration, clean lineart, vibrant colors', - preview: 'a bustling futuristic city street with cherry blossoms', - swatch: 'from-pink-400 via-purple-400 to-sky-400' - }, - { - name: 'Sketch', - prompt: 'detailed pencil sketch on paper, monochrome line art', - preview: 'an old european cathedral', - swatch: 'from-neutral-300 to-neutral-500' - }, - { - name: 'Watercolor', - prompt: 'watercolor painting, soft washes, paper texture', - preview: 'a serene mountain lake with pine trees', - swatch: 'from-rose-300 via-sky-200 to-emerald-300' - }, - { - name: 'Oil painting', - prompt: 'oil painting, visible brushstrokes, classical, rich color', - preview: 'a still life of fruit and a wine bottle on a table', - swatch: 'from-amber-700 via-red-800 to-yellow-700' - }, - { - name: 'Monochrome', - prompt: 'black and white, high contrast, monochrome', - preview: 'a rainy city street with umbrellas', - swatch: 'from-neutral-900 to-neutral-500' - }, - { - name: 'Neon', - prompt: 'neon-lit cyberpunk, glowing lights, night, moody', - preview: 'a rain-soaked alley in a cyberpunk city', - swatch: 'from-fuchsia-600 via-purple-700 to-cyan-500' - }, - { - name: '3D render', - prompt: '3D render, octane, soft studio lighting, subsurface detail', - preview: 'a cute friendly robot character', - swatch: 'from-slate-300 via-slate-500 to-slate-700' - }, - { - name: 'Steampunk', - prompt: 'steampunk, brass and gears, victorian, intricate', - preview: 'a flying steampunk airship above the clouds', - swatch: 'from-amber-800 via-yellow-900 to-stone-700' - }, - { - name: 'Surreal', - prompt: 'surreal, dreamlike, imaginative composition', - preview: 'floating islands with waterfalls in a dreamlike sky', - swatch: 'from-indigo-500 via-fuchsia-500 to-amber-400' - }, - { - name: 'Vintage film', - prompt: 'vintage film photograph, faded colors, grain, 1970s', - preview: 'a vintage convertible car on a desert road', - swatch: 'from-amber-300 via-orange-300 to-rose-300' - }, - { - name: 'Minimal', - prompt: 'minimal flat design, clean, simple shapes, lots of negative space', - preview: 'a single sailboat on calm water', - swatch: 'from-neutral-100 to-neutral-300' - }, - { - name: 'Risograph', - prompt: 'risograph print, halftone texture, limited palette', - preview: 'a bicycle leaning against a wall', - swatch: 'from-pink-500 via-yellow-400 to-blue-500' - }, - { - name: 'Fantasy art', - prompt: 'epic fantasy concept art, dramatic, highly detailed', - preview: 'a majestic dragon perched on a mountain peak', - swatch: 'from-purple-800 via-indigo-700 to-amber-600' - }, - { - name: 'Studio portrait', - prompt: 'studio portrait, soft key light, bokeh background', - preview: 'a golden retriever dog', - swatch: 'from-neutral-600 via-neutral-800 to-neutral-900' - } -] +type StoredMessageAttachment = NonNullable[number] +type OpenImage = { url: string; path?: string } -const NEW_CHAT = '__new__' // bucket key for a fresh, not-yet-saved conversation -const EMPTY_MSGS: ChatMessage[] = [] +function isSupportingMessage(message: ChatMessage): boolean { + return isSupportingChatContext({ + answer: message.content, + reasoning: message.reasoning, + reasoningLabel: message.reasoningLabel + }) +} -export function MemoryChat({ - onNavigateToMemory, - onNavigateToChat, - onNavigateToEntity, - onOpenProject, - onSeekReplay, - openTarget, - onTargetConsumed -}: MemoryChatProps) { - // Messages are kept PER CONVERSATION so a background tab keeps its own thread and - // an in-flight stream can't leak into whatever tab you switch to. `messages` (below, - // after activeConversationId) is the active tab's slice; sends target their own conv. - const [messagesByConv, setMessagesByConv] = useState>({}) - const setConvMessages = useCallback( - ( - cid: string | null, - updater: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]) - ): void => { - const k = cid ?? NEW_CHAT - setMessagesByConv((prev) => ({ - ...prev, - [k]: - typeof updater === 'function' - ? (updater as (p: ChatMessage[]) => ChatMessage[])(prev[k] ?? []) - : updater - })) - }, - [] +function selectedMessageContent(message: ChatMessage): string { + if (!message.variants || message.variantIndex == null) return message.content + return message.variants[message.variantIndex] ?? message.content +} + +function renderedMessageContent(message: ChatMessage): string { + const selected = selectedMessageContent(message) + if (message.role !== 'assistant') return preprocessChatMarkdown(selected) + return preprocessChatMarkdown( + selected + .replace(ASK_FENCE, '') + .replace(/\[S(\d+)\]/g, '[S$1](cite:$1)') + .trim() ) - const [input, setInput] = useState('') - const [attachments, setAttachments] = useState([]) - // Whether the active chat model can read images. Gate image attachment on this and - // re-check periodically (the user can switch models from the Models screen). - const [chatVision, setChatVision] = useState(true) - const [attachWarn, setAttachWarn] = useState(null) - useEffect(() => { - const check = (): void => { - void (window.api as { chatVisionAvailable?: () => Promise }) - .chatVisionAvailable?.() - .then((v) => setChatVision(!!v)) - .catch(() => {}) - } - check() - const t = setInterval(check, 4000) - return () => clearInterval(t) - }, []) - useEffect(() => { - if (chatVision) setAttachWarn(null) - }, [chatVision]) // cleared once a vision model is active - const [skills, setSkills] = useState<{ name: string; description: string }[]>([]) - const [askSel, setAskSel] = useState>({}) - const [loading, setLoading] = useState(false) - const [conversations, setConversations] = useState([]) - const [convSearch, setConvSearch] = useState('') - // Conversation ids whose MESSAGE CONTENT matches the sidebar search (title is - // matched client-side; content needs a debounced backend query). - const [contentMatchIds, setContentMatchIds] = useState>(new Set()) - useEffect(() => { - const q = convSearch.trim() - if (!q) { - setContentMatchIds(new Set()) - return - } - let live = true - const t = setTimeout(async () => { - try { - const ids = (await window.api.searchRagConversationIds?.(q)) as string[] | undefined - if (live) setContentMatchIds(new Set(ids ?? [])) - } catch { - /* keep title-only matches */ - } - }, 200) - return () => { - live = false - clearTimeout(t) - } - }, [convSearch]) - const [activeConversationId, setActiveConversationId] = useState(null) - // Active tab's messages (derived) + a shim so the existing active-conversation call - // sites keep working. The send path targets its own conv via setConvMessages instead. - const messages = messagesByConv[activeConversationId ?? NEW_CHAT] ?? EMPTY_MSGS - const setMessages = useCallback( - (updater: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])): void => { - setConvMessages(activeConversationId, updater) - }, - [activeConversationId, setConvMessages] +} + +function standardMessageRowClass(message: ChatMessage): string { + const margin = isSupportingMessage(message) ? 'mb-2' : 'mb-5' + const alignment = message.role === 'user' ? 'items-end' : 'items-start' + return `${margin} flex flex-col ${alignment}` +} + +function standardMessageBubbleClass(message: ChatMessage, editing: boolean): string { + const emptyAssistant = + message.role === 'assistant' && + !message.content.trim() && + !message.image && + !message.imageMemoryRetry + if (emptyAssistant) return 'hidden' + // A turn carrying a picture gets a COLUMN, not the full 85%. + // + // A generated image already did this; an attached one never did, so it inherited a bubble as wide + // as its prompt - some 1700px on a maximised window - and a picture told to fill that width was + // gigantic. The same cap makes the two kinds of picture behave the same way. + const width = + editing || message.image || message.attachments?.length ? 'w-full max-w-2xl' : 'max-w-[85%]' + const color = + message.role === 'user' + ? 'bg-neutral-800 text-neutral-100' + : 'border border-neutral-800 bg-neutral-900/40 text-neutral-200' + return `rounded-md px-3.5 py-2.5 text-sm leading-relaxed ${width} ${color}` +} + +function contextResultCount(context: RagContext): number { + return ( + (context.sources?.length ?? 0) + + (context.memories?.length ?? 0) + + (context.summaries?.length ?? 0) + + (context.entities?.length ?? 0) + + (context.entityFacts?.length ?? 0) + + (context.unified?.length ?? 0) ) - // Voice playback must never carry across chats — stop it whenever the active - // conversation changes (and on unmount). - useEffect(() => { - stopAllVoicePlayback() - return () => stopAllVoicePlayback() - }, [activeConversationId]) - const [openTabs, setOpenTabs] = useState([]) // conversation ids open as tabs - const [showHistory, setShowHistory] = useState(true) - const [mode, setMode] = useState('ask') - const [showImageOptions, setShowImageOptions] = useState(false) - const [imageAvailable, setImageAvailable] = useState(false) - const [imgSize, setImgSize] = useState(512) - const [imgSteps, setImgSteps] = useState(10) - const [imgCfgScale, setImgCfgScale] = useState(2) - const [imgSeed, setImgSeed] = useState('') - const [imgNegative, setImgNegative] = useState('') - // Rewrite the prompt with the local model before generating (default on). Reads - // the SAME key the main-process image gate reads (enhanceImagePrompts). - const [enhanceImg, setEnhanceImg] = useState(true) +} + +function NoticeMessageRow({ message }: Readonly<{ message: ChatMessage }>): React.JSX.Element { + return ( +
+ + {noticeText(message.content)} + +
+ ) +} + +function PromptEnhancementMessageRow({ + message +}: Readonly<{ message: ChatMessage }>): React.JSX.Element { + return ( +
+ +
+ ) +} + +function ToolMessageRow({ + message, + nextMessageRole +}: Readonly<{ + message: ChatMessage + nextMessageRole?: SyncedMessageRole +}>): React.JSX.Element { + // A tool row is one step of a turn, not a message between people. `mb-5` is the gap BETWEEN + // messages, and applying it after every call that is followed by a reasoning pill spaced a single + // turn's steps as far apart as separate conversations. + const margin = nextMessageRole === 'tool' ? 'mb-1' : 'mb-2' + return ( +
+ +
+ ) +} + +function VoiceMessageRow({ + message, + autoPlay, + onCopy, + onOpenImage, + onRegenerate +}: Readonly<{ + message: ChatMessage + autoPlay: boolean + onCopy: (text: string, key?: string) => void + onOpenImage: (image: OpenImage) => void + onRegenerate: (messageId: string) => void +}>): React.JSX.Element { + const alignment = message.role === 'user' ? 'items-end' : 'items-start' + let body: React.JSX.Element + if (message.role === 'user') { + body = ( + window.api.speak(text)} + onCopy={onCopy} + /> + ) + } else if (isSupportingMessage(message)) { + body = + } else if (message.image) { + body = ( + + ) + } else { + body = ( + window.api.speak(text)} + onCopy={onCopy} + onRetry={() => onRegenerate(message.id)} + /> + ) + } + return
{body}
+} + +function MessageThinkingHeader({ message }: Readonly<{ message: ChatMessage }>): React.JSX.Element { + if (message.role !== 'assistant') return <> + if (message.streaming) { + const activity = activityLabel(message.activity) + return ( +
+ + + + + + {message.reasoning?.trim() ? : null} + {activity ? {activity} : null} +
+ ) + } + if (!message.reasoning?.trim()) return <> + const supporting = isSupportingMessage(message) + return ( +
+ +
+ ) +} + +function IncomingFileRows({ + files +}: Readonly<{ files: readonly IncomingSharedFile[] }>): React.JSX.Element { + return ( + <> + {files.map((incoming) => ( +
+ + + + + + + {incoming.name} + +
+ ))} + + ) +} + +function MessageAttachments({ + attachments, + onOpenAttachment, + onOpenImage +}: Readonly<{ + attachments: readonly StoredMessageAttachment[] + onOpenAttachment: (attachment: StoredMessageAttachment) => void + onOpenImage: (image: OpenImage) => void +}>): React.JSX.Element { + return ( +
+ {attachments.map((attachment, index) => { + if (attachment.kind === 'image' && attachment.path) { + const source = captureUrlForPath(attachment.path) + return ( + + ) + } + // The UI holds no opinion about what a PDF is: sync answers, this draws. + const view = describeAttachment({ + fileName: attachment.name, + mimeType: (attachment as { mimeType?: string }).mimeType, + path: attachment.path, + text: attachment.text + }) + const viewable = view.viewable + return ( + + ) + })} +
+ ) +} + +function MessageEditor({ + messageId, + text, + onChange, + onCancel, + onSave +}: Readonly<{ + messageId: string + text: string + onChange: (text: string) => void + onCancel: () => void + onSave: (messageId: string) => void +}>): React.JSX.Element { + return ( +
+