Skip to content

feat(mobile): model catalog for on-device GGUF models - #7431

Merged
iscekic merged 9 commits into
mainfrom
igor/quick-chat-model-catalog
Oct 13, 2026
Merged

iscekic merged 9 commits into
mainfrom
igor/quick-chat-model-catalog

Conversation

@iscekic

@iscekic iscekic commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

bot: Quick Chat gets a full catalog of on-device GGUF models from Hugging Face.

What changes

  • A new screen, /model-catalog, opens from "Browse models" in the Chat backend settings.
  • The screen has a search field and two tabs: "Fits this device" and "All".
  • "Fits this device" asks Hugging Face only for models whose parameter count fits the device memory (num_parameters filter). Pages stay full. This filter is only a first step: Hugging Face can state the parameter count of a draft model in the repository (for example, a 27B repository reports 1.9B).
  • Each list row gets its fit badge from the smallest real quantization file (and its projector), from the repository tree. It does not use the parameter count. The "Fits this device" tab hides a row when that file does not fit. The sizes stay cached for 30 minutes, because rows mount again while the list scrolls.
  • The 4 pinned models stay at the top as "Recommended".
  • The list loads 20 models per page and follows the Hugging Face Link header for the next page.
  • A model detail screen shows downloads, likes, parameters, context window, license, tool and image hints, and every single-file quantization.
  • Each quantization shows its download size, the estimated memory, and a fit badge: Fits, Tight fit, Too large, or Not enough storage. Its download button has an accessibility label that names the quantization (for example, "Download Q4_K_M").
  • When a repository names its quantizations, a file without a quantization in its name (a draft model or an importance matrix) is not offered.
  • A download uses the existing pipeline. The URL is pinned to the commit the tree was read at. The size and SHA-256 come from the tree API. The file ID is hf- plus the first 16 hex digits of the SHA-256, so the same bytes never download twice. A file that the pinned catalog already offers keeps its pinned identity.
  • A "Too large" model asks for confirmation before it downloads. A gated model or a model without room on disk cannot download.
  • Split files (-00001-of-00002.gguf) are not offered, because llama.rn loads one file. A vision repository pairs each quantization with an mmproj file: the one with the same quantization, else the smallest.
  • The backend settings sheet now lists downloaded and downloading models, the "Browse models" button, and the direct link form.

Download integrity

  • Before this PR, the pipeline checked only the size; nothing compared the SHA-256 that the pinned catalog entries carry.
  • Now every catalog download (pinned and Hugging Face) hashes each file (model and projector) after the transfer and before llama.cpp reads it. A mismatch deletes the partial files and shows "The downloaded file doesn't match its published checksum, so it was removed. Try the download again."
  • A direct link publishes no hash, so it is not checked.
  • A new local Expo module, modules/kilo-file-hash, gives sha256File(uri). iOS streams the file through CryptoKit SHA256 with FileHandle in 1 MiB chunks. Android streams it through MessageDigest (SHA-256) with a 1 MiB buffer. Both run in an Expo AsyncFunction, so the hash runs on a background thread, not on the JS or main thread.

Device fit

  • Memory comes from expo-device totalMemory. Free storage comes from Paths.availableDiskSpace.
  • Estimated memory = download size × 1.15 + 300 MiB (weights, a 4096-token context, buffers).
  • Fits: the estimate is at most 45% of the memory. Tight: at most 65%. Else Too large.
  • Not enough storage: free storage is less than the download plus the existing 512 MiB headroom.

Untrusted input

  • Every Hugging Face response goes through Zod. A list entry that does not parse is dropped. The detail requires a 40-hex commit and 64-hex LFS hashes.
  • The app follows a next-page link only when it points back to https://huggingface.co/api/models?.
  • Route parameters must have the Hugging Face repository ID shape before the app requests them.
  • Requests send no credentials.

Library choices

  • Lists: @shopify/flash-list 2.3.3 (the app's list element).
  • Tabs: the app's SegmentedControl from @/components/ui/segmented-control. I did not use @expo/ui community segmented-control, because the app already has one tab element for this concern.
  • Device memory: expo-device ~57.0.2 (already installed). Free storage: expo-file-system ~57.0.7 (already installed).
  • Fetching and pages: @tanstack/react-query useInfiniteQuery and useQuery.
  • Validation: zod.
  • File hash: a local Expo module (modules/kilo-file-hash, about 30 lines per platform) with the platform SHA-256 (CryptoKit on iOS, java.security.MessageDigest on Android). Rejected: @noble/hashes (removed; the pure-JS hash of a 1 GB file took too long), expo-crypto (hashes only data in memory), expo-file-system 57 (only MD5 for a file), react-native-quick-crypto (a large Nitro native dependency with no file hash; the bytes still go through JS). The existing on-device model modules are platform-specific (kilo-apple-model compiles a stub on older SDKs), so a small separate module is cleaner than a new function in them.
  • Rejected: @huggingface/hub. The app uses three plain GET endpoints, and the responses still need validation in the app. The package adds a dependency without less code.

Checks

  • pnpm format, pnpm typecheck, pnpm lint, pnpm check:unused, pnpm check:i18n: pass.
  • vitest for hf-catalog.test.ts, device-fit.test.ts, gguf-catalog.test.ts: pass.

@iscekic iscekic self-assigned this Oct 12, 2026
Comment thread apps/mobile/src/lib/chat/hf-catalog.ts Resolved
@kilo-code-bot

kilo-code-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental change is correct and self-consistent: the new local kilo-file-hash Expo module streams SHA-256 in 1 MiB chunks and closes its file handle on both platforms, the catalog list now rates fit by the smallest real quantization file (quantFiles / smallestDownloadBytes), and the download button gains a per-quantization accessibility label — with no new correctness, security, or leak issues found.

Files Reviewed (11 code files + generated assets)
  • apps/mobile/modules/kilo-file-hash/ios/KiloFileHashModule.swift - handle closed in defer, autoreleasepool per chunk, lowercase hex output
  • apps/mobile/modules/kilo-file-hash/ios/KiloFileHash.podspec - mirrors the existing artifacts-provider podspec
  • apps/mobile/modules/kilo-file-hash/android/src/main/java/expo/modules/kilofilehash/KiloFileHashModule.kt - FileInputStream in use, 1 MiB buffer, %02x renders each Byte as two lowercase hex digits
  • apps/mobile/modules/kilo-file-hash/android/build.gradle, AndroidManifest.xml, expo-module.config.json - matches existing module layout
  • apps/mobile/src/lib/chat/gguf-models.ts - sha256 now delegates to the native module; format stays lowercase 64-hex so readVerified comparison is unchanged
  • apps/mobile/src/lib/chat/hf-catalog.ts - quantOf/quantFiles filtering and smallestDownloadBytes
  • apps/mobile/src/lib/chat/use-hf-catalog.ts - useHfSmallestDownload (30 min staleTime)
  • apps/mobile/src/components/chat/model-catalog-screen.tsx - ModelRow hides non-fitting rows in the fits tab; hook placed before the early return
  • apps/mobile/src/components/chat/model-catalog-detail-screen.tsx - passes downloadLabel
  • apps/mobile/src/components/chat/gguf-model-section.tsx - downloadLabel forwarded to Button.accessibilityLabel
  • apps/mobile/src/lib/chat/device-fit.ts - parameterFit removed, maxParametersFor kept
  • tests: device-fit.test.ts, hf-catalog.test.ts, gguf-downloads.test.ts, gguf-downloads-vision.test.ts, gguf-downloads.test-helpers.ts
  • apps/mobile/package.json (@noble/hashes removed), pnpm-lock.yaml, and 87 src/i18n/locales/*.json (downloadQuant added; every catalog keeps the {{quant}} placeholder)

Verification notes: re-read every changed hunk against HEAD 418a5c1d2. The prior toSorted, per-render budget, and noStorage recommended-row findings are all fixed at HEAD and were not re-raised; the previous review carried no open findings. The Android Uri.parse(uri).path and both hex encoders produce lowercase 64-char hex matching readVerified's case-sensitive compare, and the file hash is awaited through settled(...), so a read failure maps to the checksum problem. No removed symbol (parameterFit, @noble/hashes) is still referenced. The per-row tree query is intentional and bounded by react-query caching, so it is not a leak.

Previous Review Summaries (3 snapshots, latest commit e8f9699)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit e8f9699)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental SHA-256 change is correct and self-consistent: each catalog transfer's partial file is hashed in 4 MiB chunks, compared to its published lowercase hex hash, and a mismatch removes the partial files and surfaces a dedicated checksum error, with no new correctness, security, or leak issues found.

Files Reviewed (8 files)
  • apps/mobile/src/lib/chat/gguf-transfers.ts - new sha256 field on GgufTransfer, new readVerified hashing helper
  • apps/mobile/src/lib/chat/gguf-downloads.ts - verify now hashes before inspecting and maps the mismatch to checksum
  • apps/mobile/src/lib/chat/gguf-models.ts - chunked fileSha256 using @noble/hashes
  • apps/mobile/src/lib/chat/gguf-records.ts - GgufStorage.sha256 contract and checksum problem
  • apps/mobile/src/components/chat/gguf-model-section.tsx - failure-key mapping for checksum
  • apps/mobile/src/lib/chat/gguf-downloads.test.ts - new checksum tests
  • apps/mobile/src/lib/chat/gguf-downloads.test-helpers.ts - sha256 fixture
  • apps/mobile/package.json - @noble/hashes dependency

Verification notes: re-read every changed hunk against HEAD e8f9699. The file handle in fileSha256 is closed in a finally (no handle leak), the hash comparison is case-safe because Hugging Face OIDs and the pinned catalog constants are validated/regex-enforced lowercase hex, a direct-link download (no published hash) correctly skips hashing, and Promise.withResolvers is already used by production code in this app (apps/mobile/src/lib/action-sheet-dismissal.ts), so it is supported by this build's Hermes VM.

Previous review (commit c839768)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental changes resolve all three previously reported findings and introduce no new issues: the Hermes toSorted crash is replaced with a local in-place .sort(), the per-render device-budget read is moved into a useDeviceBudget hook that refreshes on store changes, and a noStorage recommended model now hides its download button like the detail row.

Files Reviewed (4 files)
  • apps/mobile/src/lib/chat/hf-catalog.ts - toSorted crash fixed (ggufFiles now sorts its local list; projectorFor takes projectors[0]; readModelDetail no longer sorts)
  • apps/mobile/src/lib/chat/use-hf-catalog.ts - new useDeviceBudget hook reads free storage once and again only when models or the active download change
  • apps/mobile/src/components/chat/model-catalog-screen.tsx - recommended row passes no onDownload for fit === 'noStorage'
  • apps/mobile/src/components/chat/model-catalog-detail-screen.tsx - uses useDeviceBudget

Verification: re-read every changed hunk against current HEAD; no remaining toSorted in the changed files; GgufDownloadControls already treats an undefined onDownload as hidden, matching the detail screen; the new hook places both hooks before any early return in DetailBody.

Previous review (commit 980e08b)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/mobile/src/lib/chat/hf-catalog.ts 221 readModelDetail uses Array.prototype.toSorted, which Hermes does not implement, so opening any model detail crashes on device (same at line 196). Unit tests pass only under Node.

SUGGESTION

File Line Issue
apps/mobile/src/components/chat/model-catalog-screen.tsx 152 deviceBudget() reads native disk space on every render and the captured budget can go stale, so fit badges may not refresh after a download.
apps/mobile/src/components/chat/model-catalog-parts.tsx 69 A recommended model with fit === 'noStorage' starts a download without confirmation, unlike the detail row which blocks it.
Files Reviewed (101 files)
  • apps/mobile/src/lib/chat/hf-catalog.ts - 1 issue (CRITICAL)
  • apps/mobile/src/lib/chat/device-fit.ts - no issues
  • apps/mobile/src/lib/chat/use-hf-catalog.ts - no issues
  • apps/mobile/src/lib/chat/hf-catalog.test.ts, device-fit.test.ts - no issues
  • apps/mobile/src/components/chat/model-catalog-screen.tsx - 1 issue
  • apps/mobile/src/components/chat/model-catalog-detail-screen.tsx - no issues
  • apps/mobile/src/components/chat/model-catalog-parts.tsx - 1 issue
  • apps/mobile/src/components/chat/model-catalog-search.tsx - no issues
  • apps/mobile/src/components/chat/gguf-model-section.tsx - no issues
  • apps/mobile/src/components/chat/backend-settings-sheet.tsx - no issues
  • apps/mobile/src/app/(app)/model-catalog/index.tsx, [owner]/[repo].tsx - no issues
  • apps/mobile/src/i18n/locales/*.json (87 catalogs) - new modelChat.catalog copy, no issues

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash · Input: 122.2K · Output: 29.5K · Cached: 2.5M

Review guidance: REVIEW.md from base branch main

@iscekic

iscekic commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

bot: E2E for e8f9699. A local backend, Metro, and an Android 15 emulator ran on a GCE VM. An iOS 26.5 simulator (iPhone 17 Pro) ran on the Mac through SSH tunnels. Both used a fake local account.

Result: all scenarios pass on Android and iOS.

Scenario Android iOS
Sign in (device flow, fake account; analytics off, required consent accepted) Pass Pass
Browse models from Manage backends Pass Pass
Fits this device / All tabs Pass Pass
Search "SmolLM2 135M" Pass Pass
Open bartowski/SmolLM2-135M-Instruct Pass, quants with fit badges Pass
Download Q8_0 from All (144,811,360 bytes, shown as 138.1 MB) Pass: "Checking the downloaded file…", then Downloaded Pass
Chat with it in quick chat Pass: "The capital of France is Paris." Pass, same answer

SHA-256 step. I timed it with a temporary console.log around storage.sha256. The log was not committed.

  • Android emulator (x86_64, nested virtualization, debug build): 154.9 s for 144.8 MB, about 0.9 MB/s.
  • iOS simulator (Apple silicon, debug build): 85.0 s for 144.8 MB, about 1.7 MB/s. On iOS the full download took 150 s, and the check started at 59 s.
  • These are debug Hermes builds, which run JS slower than release builds. At these rates, a 1 GB model would spend about 10 min (iOS) to 18 min (Android) in "Checking the downloaded file…". A release-build measurement on a real device would show whether pure-JS hashing is fast enough.

Notes

  • Hugging Face metadata decides the parameter badges. HauhauCS "Qwen3.8-27B-Uncensored…" reports 1.9B parameters, so it shows "Fits".
  • Each quant's button reads "Download model" to accessibility, with no quant name.
  • iOS native fingerprint changed. The remote build (mobile-native-build, run 38289430315, 18.3 min) produced an artifact for toolchain 17F113/26.5, but this Mac uses 27A9275/27.1. The script then built locally (36.7 min total).
  • One iOS sign-in code appeared in the agent's local tool output. I cancelled that flow without authorizing it and signed in with a fresh code.
Android iOS
Fits Fits
All All
Search Search
Detail Detail
Downloaded Downloaded
Chat Chat

@iscekic

iscekic commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

bot: E2E for 418a5c1: a local backend, Metro and an Android 15 emulator on a GCE VM; an iOS 26.5 simulator (iPhone 17 Pro) on the Mac through SSH tunnels. A fake local account, analytics off.

Native SHA-256 (task 1)

The hash of a downloaded file runs in the platform SHA-256 over a stream, on an Expo AsyncFunction thread, before llama.cpp reads the file. Only a 1 MiB chunk is in memory at a time.

File, platform Bytes Hash step
Llama 3.2 1B Q4_K_M (pinned catalog), Android 15 emulator 807,694,464 3,138 ms
Qwen2.5 0.5B Q4_K_M (pinned catalog), Android 15 emulator 491,400,032 1,560 ms
Llama 3.2 1B Q4_K_M (pinned catalog), iOS 26.5 simulator 807,694,464 402 ms
Qwen2.5 0.5B Q4_K_M (pinned catalog), iOS 26.5 simulator 491,400,032 229 ms

The numbers come from a dev-only log line around the storage sha256 call, which is not committed. The Android numbers are CPU hashing in an emulator; iOS uses CryptoKit on the Mac.

A corrupted file is refused, with both paths that the check covers. In the dev-only run the pinned Qwen2.5 0.5B entry points at an all-zero SHA-256 (a wrong published hash), so the downloaded bytes cannot match: the app hashes the file, compares, deletes it, and shows "The downloaded file doesn't match its published checksum, so it was removed. Try the download again." The row returns to Download model, and no model is listed. A wrong size and an unreadable model file were already covered by tests.

Catalog gaps (task 2)

  • The list badge uses the smallest real quantization file plus its projector, not the parameter count that Hugging Face reports. Qwen3.8-27B-Uncensored-HauhauCS-Aggressive-MTP reports 1.9B parameters, because the repository also holds a FastMTP draft file; the app rates it by its real files instead (10.3 GB on the Android emulator: "Not enough storage").
  • A file without a quantization in its name is not offered as a quantization once the repository names its quantizations.
  • Each quantization's download button names its quantization in the accessibility label ("Download Q4_K_M").

Scenarios

Scenario Android 15 emulator iOS 26.5 simulator
Download a 0.5–1 GB quantization Pass (Llama 3.2 1B Q4_K_M, 770.28 MB, downloaded and listed) Pass (same file)
Hash step duration 3,138 ms / 1,560 ms 402 ms / 229 ms
A file with a wrong published hash is refused, and removed Pass Pass
27B repository that reports 1.9B parameters Pass ("Not enough storage" from the real file) Pass (rated by the real files; the simulator has more memory and disk, so it fits)
A quantization download button names its quantization Pass Pass
Android: 27B repository rated by its real file Android: quant labels Android: refused file
iOS: 27B repository rated by its real file iOS: quant labels iOS: download, then a refused file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants