From 2ddc40c79fcd51c7901a6e3f1723ccbd6395fd2e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:05:01 +0530 Subject: [PATCH 001/229] feat(clipboard): a copy made in another app still has its text, on Android From Android 10 the platform refuses `primaryClip` to an app that is not on screen. The change listener still fires, so the app learned that a copy HAPPENED and never what it was - and the `?: return` on the read dropped it. That is why nothing copied outside Off Grid ever reached a paired device: the transport was fine and there was simply nothing to send. Accessibility supplies the missing half. A service reports text selections and nothing else - its config declares `typeViewTextSelectionChanged` alone, with no window-content access - and `ClipboardSelectionMemory` holds one selection for 30s, consumed by one copy, so a stale selection can never be published as new. A clip this app CAN read still wins, and clears the memory. The service is off until the user turns it on in Settings, and nothing here asks them to. --- android/app/src/main/AndroidManifest.xml | 19 +++++ .../clipboard/ClipboardSelectionMemory.kt | 58 ++++++++++++++ .../SyncClipboardAccessibilityService.kt | 78 +++++++++++++++++++ .../clipboard/SyncClipboardModule.kt | 48 +++++++++++- android/app/src/main/res/values/strings.xml | 11 +++ .../sync_clipboard_accessibility_service.xml | 18 +++++ 6 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/ai/offgridmobile/clipboard/ClipboardSelectionMemory.kt create mode 100644 android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardAccessibilityService.kt create mode 100644 android/app/src/main/res/xml/sync_clipboard_accessibility_service.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 27c9a3781..911b90c2a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -116,5 +116,24 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> + + + + + + + + diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/ClipboardSelectionMemory.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/ClipboardSelectionMemory.kt new file mode 100644 index 000000000..a94900c59 --- /dev/null +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/ClipboardSelectionMemory.kt @@ -0,0 +1,58 @@ +package ai.offgridmobile.clipboard + +/** + * The text a copy took, when the clipboard itself will not say. + * + * Android 10 and later refuse `primaryClip` to an app that does not hold focus, so a copy made in + * ANOTHER app arrives as a change notification with no content: the listener fires, the read returns + * null, and the copy is lost. Accessibility is the other half of that fact - it reports the selection + * as the user makes it - so remembering the last selection turns a contentless notification into the + * text that was actually copied. + * + * Pure and self-contained on purpose: this is the only judgement in the whole path ("is this selection + * recent enough to be what was just copied"), and it must be readable without a device, a service, or + * an emulator. + */ +internal class ClipboardSelectionMemory( + /** + * How long a selection stays eligible. + * + * A copy follows its selection by the time it takes to reach for the menu, so the window has to + * cover a deliberate tap and no more. Too long and an old selection is attributed to an unrelated + * copy - which would publish text the user never copied, the one outcome worse than losing it. + */ + private val eligibilityMs: Long = 30_000L, +) { + private var text: String? = null + private var recordedAt: Long = 0L + + /** Accessibility saw the user select something. A fact, stored without interpretation. */ + fun remember(selected: String, at: Long) { + val trimmed = selected.trim() + if (trimmed.isEmpty()) return + text = selected + recordedAt = at + } + + /** + * The text to attribute to a copy that happened at `at`, or null when nothing may be. + * + * Consumed on read: one selection answers for ONE copy. Left in place, a single selection would be + * re-published by every later clipboard change - a paste loop with no new content behind it. + */ + fun takeFor(at: Long): String? { + val remembered = text ?: return null + if (at < recordedAt) return null + if (at - recordedAt > eligibilityMs) { + forget() + return null + } + forget() + return remembered + } + + fun forget() { + text = null + recordedAt = 0L + } +} diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardAccessibilityService.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardAccessibilityService.kt new file mode 100644 index 000000000..edbf59a25 --- /dev/null +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardAccessibilityService.kt @@ -0,0 +1,78 @@ +package ai.offgridmobile.clipboard + +import android.accessibilityservice.AccessibilityService +import android.provider.Settings +import android.view.accessibility.AccessibilityEvent + +/** + * Reports what the user selected, so a copy made OUTSIDE this app still has text behind it. + * + * It exists because of one platform rule: from Android 10, `primaryClip` is refused to an app without + * focus. The change notification still arrives, so this app knows a copy HAPPENED and cannot know what + * it was. Accessibility is the only sanctioned way to learn the second half. + * + * Deliberately narrow. It reads selection events and nothing else - no window content, no keystrokes, + * no scraping of the screen - and it stores exactly one string at a time, in memory, consumed by the + * next copy. Its config declares `typeViewTextSelectionChanged` alone, so the platform never delivers + * the rest. + * + * It never touches the clipboard itself. `SyncClipboardObserver` owns that, and asks here only when the + * platform has denied it a read. + */ +class SyncClipboardAccessibilityService : AccessibilityService() { + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + val selection = event?.let(::selectedText) ?: return + selectionMemory.remember(selection, System.currentTimeMillis()) + } + + override fun onInterrupt() { + // Nothing to interrupt: this service holds one string and runs no work of its own. + } + + override fun onDestroy() { + // Turning the service off must not leave a selection behind for a later copy to claim. + selectionMemory.forget() + super.onDestroy() + } + + /** + * The selected substring, taken from the event's own indices. + * + * `fromIndex`/`toIndex` describe the selection inside the field's full text, so the range is what + * the user highlighted and the whole text is not. A collapsed range (a caret move, not a selection) + * carries nothing to copy. + */ + private fun selectedText(event: AccessibilityEvent): String? { + val whole = event.text.firstOrNull()?.toString() ?: return null + val from = event.fromIndex + val to = event.toIndex + if (from < 0 || to < 0 || from >= to || to > whole.length) return null + return whole.substring(from, to) + } + + companion object { + /** + * Shared with `SyncClipboardObserver`, which is instantiated by the React module rather than by + * the platform - so the two halves cannot be handed to each other and must meet on one owner. + */ + internal val selectionMemory = ClipboardSelectionMemory() + + /** + * Is the service switched on in system settings? + * + * Read from the setting rather than remembered, because the user can revoke it in Settings at + * any time and this app is never told. A cached answer would promise a capture that cannot run. + */ + fun isEnabled(context: android.content.Context): Boolean { + val enabled = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + // The setting is a colon-separated list of component names, so reading it needs nothing + // more than a split. `SimpleStringSplitter` is both Iterable and Iterator, which makes + // `asSequence()` ambiguous and buys nothing here. + val target = "${context.packageName}/${SyncClipboardAccessibilityService::class.java.name}" + return enabled.split(':').any { it.trim().equals(target, ignoreCase = true) } + } + } +} diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt index 218641f88..39c1ec320 100644 --- a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt @@ -3,7 +3,10 @@ package ai.offgridmobile.clipboard import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.provider.Settings import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -18,13 +21,28 @@ internal class SyncClipboardObserver( private var enabled = false private val listener = ClipboardManager.OnPrimaryClipChangedListener { if (!enabled) return@OnPrimaryClipChangedListener - val clip = clipboardManager.primaryClip ?: return@OnPrimaryClipChangedListener + val at = now() + val clip = clipboardManager.primaryClip + if (clip == null) { + // Not an error, and the ordinary case: Android 10+ refuses `primaryClip` to an app that is + // not on screen, so a copy made in ANOTHER app arrives here as a notification with no + // content. Accessibility reports what was selected, which is the same text - and is the + // whole reason that service exists. Absent it, the copy is genuinely unknowable and this + // returns without publishing a guess. + val selected = SyncClipboardAccessibilityService.selectionMemory.takeFor(at) + ?: return@OnPrimaryClipChangedListener + onText(selected, at.toDouble()) + return@OnPrimaryClipChangedListener + } if (clip.description.label?.toString() == SYNC_CLIP_LABEL) { return@OnPrimaryClipChangedListener } val item = clip.getItemAt(0) val text = item.coerceToText(context)?.toString() ?: return@OnPrimaryClipChangedListener - onText(text, now().toDouble()) + // A copy this app COULD read is the truth; the remembered selection would only compete with it, + // and a selection left behind would be claimed by the next copy that arrives contentless. + SyncClipboardAccessibilityService.selectionMemory.forget() + onText(text, at.toDouble()) } fun setEnabled(next: Boolean) { @@ -67,6 +85,32 @@ class SyncClipboardModule( observer.writeText(text) } + /** + * Is the accessibility service switched on right now? + * + * A FACT, read from system settings on every call. The user can revoke it in Settings without this + * app being told, so a remembered answer would promise a capture that cannot happen. + */ + @ReactMethod + fun isAccessibilityEnabled(promise: Promise) { + promise.resolve(SyncClipboardAccessibilityService.isEnabled(reactContext)) + } + + /** + * Open the system Accessibility screen so the user can turn it on. + * + * There is no runtime prompt for accessibility - the grant lives in Settings and nowhere else - so + * taking them there is the only thing an app can do. Called from the clipboard toggle, never at + * launch: a permission asked for before the feature is wanted reads as an app overreaching. + */ + @ReactMethod + fun openAccessibilitySettings() { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + reactContext.startActivity(intent) + } + @ReactMethod fun addListener(eventName: String) { // Required by React Native's NativeEventEmitter contract. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0f4782f75..6af030c05 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,14 @@ Off Grid AI + + Copy on your phone, paste on your other devices + Turn this on and anything you copy on this phone is ready to paste on your Mac or PC, without opening Off Grid first.\n\nAndroid hides the clipboard from apps that are not on screen, so Off Grid reads only the text you highlight, only while clipboard sync is switched on, and keeps it on your own devices. It never reads the rest of your screen. diff --git a/android/app/src/main/res/xml/sync_clipboard_accessibility_service.xml b/android/app/src/main/res/xml/sync_clipboard_accessibility_service.xml new file mode 100644 index 000000000..0c4436a72 --- /dev/null +++ b/android/app/src/main/res/xml/sync_clipboard_accessibility_service.xml @@ -0,0 +1,18 @@ + + + From 0a7167a4a8a4fc7c246a32cc47dc0e8415d31b6c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:06:04 +0530 Subject: [PATCH 002/229] feat(clipboard): the boundary answers whether this device can capture a background copy A fact the platform owns, asked rather than remembered. An older native build that cannot answer is treated as capable, not as denied: reading silence as "off" would nag the user to enable something this build cannot even see. --- .../sync/clipboardSync.integration.test.tsx | 11 +++++++ src/services/sync/nativeClipboard.ts | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/__tests__/pro/sync/clipboardSync.integration.test.tsx b/__tests__/pro/sync/clipboardSync.integration.test.tsx index b11f71b8d..3b3cceb27 100644 --- a/__tests__/pro/sync/clipboardSync.integration.test.tsx +++ b/__tests__/pro/sync/clipboardSync.integration.test.tsx @@ -81,8 +81,19 @@ jest.mock('react-native-zeroconf', () => { class ClipboardBoundary implements NativeClipboardBoundary { enabled = false; readonly writes: string[] = []; + /** The device's answer to "can I capture a copy made in another app". Android's is the user's. */ + backgroundCapture = true; + backgroundCaptureRequests = 0; private listener: ((change: NativeClipboardChange) => void) | null = null; + async canCaptureInBackground(): Promise { + return this.backgroundCapture; + } + + requestBackgroundCapture(): void { + this.backgroundCaptureRequests += 1; + } + observe(listener: (change: NativeClipboardChange) => void): () => void { this.enabled = true; this.listener = listener; diff --git a/src/services/sync/nativeClipboard.ts b/src/services/sync/nativeClipboard.ts index 63080f053..7c0386bef 100644 --- a/src/services/sync/nativeClipboard.ts +++ b/src/services/sync/nativeClipboard.ts @@ -1,6 +1,7 @@ import { NativeEventEmitter, NativeModules, + Platform, type EmitterSubscription, } from 'react-native'; @@ -16,11 +17,26 @@ interface SyncClipboardNativeModule { writeText(text: string): void; addListener(eventName: string): void; removeListeners(count: number): void; + /** Android only: is the selection-reporting service switched on in system settings? */ + isAccessibilityEnabled?(): Promise; + /** Android only: open the system Accessibility screen, the only place the grant lives. */ + openAccessibilitySettings?(): void; } export interface NativeClipboardBoundary { observe(listener: (change: NativeClipboardChange) => void): () => void; writeText(text: string): void; + /** + * Can this platform capture a copy made in ANOTHER app right now? + * + * A fact, not a verdict, and asked rather than remembered: on Android the user can revoke the + * accessibility grant in Settings without this app being told. iOS answers true because it has no + * such gate - and answering `false` there would send the user hunting for a switch that does not + * exist. + */ + canCaptureInBackground(): Promise; + /** Take the user to where the grant lives. A no-op where there is nothing to grant. */ + requestBackgroundCapture(): void; } function module(): SyncClipboardNativeModule { @@ -58,4 +74,18 @@ export const nativeClipboardBoundary: NativeClipboardBoundary = { writeText(text): void { module().writeText(text); }, + + async canCaptureInBackground(): Promise { + if (Platform.OS !== 'android') return true; + const ask = module().isAccessibilityEnabled; + // An older native build without the method is not a denial: it is a build that cannot answer, and + // treating silence as "off" would nag the user to enable something this app cannot even see. + if (!ask) return true; + return ask(); + }, + + requestBackgroundCapture(): void { + if (Platform.OS !== 'android') return; + module().openAccessibilitySettings?.(); + }, }; From fcee17d0b194a48815d24892f0e32ffe91a622f3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:25:15 +0530 Subject: [PATCH 003/229] docs(feedback): the cross-device pass of 2026-08-12, and what it found Nine defects as reported, separated from the two that were not defects and from what is confirmed working. Two carry a cause already: the Android clipboard (the platform refuses a background read) and the desktop mDNS bind (one dead interface takes the whole advertisement down). Muse Glimmer is confirmed rather than guessed - `muse-glimmer` appears in zero files of the llama.cpp that llama.rn 0.12.9 bundles, against 35 mentioning `qwen3`. --- docs/FEEDBACK_2026-08-12.md | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/FEEDBACK_2026-08-12.md diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md new file mode 100644 index 000000000..f1febee12 --- /dev/null +++ b/docs/FEEDBACK_2026-08-12.md @@ -0,0 +1,117 @@ +# Cross-device sync feedback — 2026-08-12 + +From Anurag's pass over the 0.0.104-beta.1 mobile build against desktop (macOS and Windows), reported in +Slack between 09:31 and 10:07. Recorded as he described it. Nothing here is diagnosed unless the cause is +already known; a guess in this list would be read as a finding. + +## Defects + +- **Pairing keeps a dead attempt alive.** Enter a wrong code, cancel, and the state does not reset. The + next attempt still shows "pairing…", and only restarting the app clears it. Mobile. +- **A file sent phone → Windows arrives with no local file.** The transfer reports complete, then the + activity row reads "This activity no longer has a local file" and the file will not open. Windows + desktop, receive path. Confirmed by Anurag at 09:52 as a MANUAL send, not an ambient share - so the + explicit share path is what to read, and the ambient rules are not in it. +- **Clipboard Android → desktop never arrives.** iPhone → desktop works. Cause found on 2026-08-12: from + Android 10 the platform refuses `primaryClip` to an app that is not on screen, so the copy reaches the + app with no text behind it and is dropped. Fix in progress on `feat/android-clipboard-accessibility`. +- **A reply to a phone-sent message is slow on desktop, and sometimes leaks the system prompt.** The + response occasionally opens with "A helpful AI assistant running locally on your device." — which is + the persona text, not an answer — despite the model running on device. +- **Windows image generation breaks its own preview.** The image is generated but the preview cannot + resolve its path; downloading the file works. This also blocks any test of image-generation sync. Anurag + believes the preview has always been broken here, so it may predate this release. +- **An image attached on the phone will not open on desktop.** It reports that the file has moved. +- **Generated media does not travel desktop → android.** Attachments from the same direction do arrive, so + the transport is working and the generated-media path specifically is not. +- **The desktop publishes no mDNS record when a dead interface is present.** Found on 2026-08-12 while + chasing "the Android debug build cannot find OGAD". `new Bonjour({}, onError)` in + `desktop/pro/main/sync/desktop-discovery.ts` binds every interface, so an `en7` with link but no DHCP + lease (self-assigned `169.254.112.10`) makes the multicast send fail: + + ``` + [sync] Bonjour discovery unavailable: send EHOSTUNREACH 224.0.0.251:5353 + ``` + + `sync.discoverable` reads true while the wire carries nothing. A phone that is ALREADY paired keeps + working, because it dials the saved address - which is why only a fresh install shows it. The address + the socket should use is the one `lanAddress()` already picks, and its predicate already rejects + link-local and virtual interfaces. + +- **A desktop profile that predates the default keeps the old one.** A fresh Android install already has + generated media and message attachments on, with an offline device set to queue. Anurag's DESKTOP needed + both switched on by hand. + + Not a per-host divergence, which is what it looks like: both hosts read the same + `DEFAULT_RECEIVE_POLICY` from `shared/packages/sync/src/receive-policy.ts`, and it is `enabled: true` + with nothing disabled. The default applies only when NOTHING is stored, so a profile carrying a policy + written by an earlier build keeps its disabled categories for ever - there is no migration that + reconciles a stored policy with a default that has since changed. + + Unprovable on his Mac now: switching them on overwrote the value, which today reads + `{"enabled":true,"disabledCategories":[],"devices":{}}`. A fresh desktop profile would confirm it in a + minute. + +## Not defects + +- **An attachment from desktop not showing on the phone** — the receive category was switched off. User + error, confirmed by Anurag at 10:03. +- **Generating an image on desktop from the phone** — not built. Wanted, deferred past this release. + +## Confirmed working + +- Projects and chats converge. +- Model settings sync. +- Clipboard iPhone → desktop. +- Attachments desktop → android. + +## Open questions for Anurag + +- Does the slow desktop reply reproduce for a message typed on the desktop itself, or only for one that + arrived from the phone? +- How old is the desktop profile that needed the receive categories switched on? A profile created before + the default flipped explains it; a fresh one would make it a real defect in the desktop default. + +## Community report — a model that will not load + +- **Muse Glimmer 30B fails to load; Qwen3.6-27B of similar size loads fine on the same device.** Tried + both the official Meta GGUF (K-quant, 17 GB) and the Unsloth quants. + + The reporter's own diagnosis is the likely one and is worth stating as theirs, not ours: Muse Glimmer + introduced a new architecture (`muse-glimmer`) merged into llama.cpp on release day, 2026-08-10. A + runtime that predates that commit does not know the architecture, which is exactly the shape of "this + model refuses while older ones work". + + **Confirmed in our own tree.** `muse-glimmer` appears nowhere in the llama.cpp that llama.rn 0.12.9 + bundles - zero files under `node_modules/llama.rn/cpp`, against a control of 35 files mentioning + `qwen3` and `LLM_ARCH_QWEN3` declared in `llama-arch.h`. The runtime we ship cannot know the + architecture, so the reporter is right and no device-side setting will change it. + + Two things the upgrade alone will not solve: + + - **The model needs a perception encoder passed at load**, the same shape as the mmproj path vision + models already take. Support in the runtime is necessary and not sufficient - the app has to hand it + over. Speculative decoding additionally wants a companion drafter model. + - **30B does not fit a phone.** Full precision is 55 GB+, and the 17 GB K-quant the reporter tried is + still far past any handset. Whatever the runtime knows, the memory gate should be refusing this with + a reason the user can read, and "fails to load" suggests it is not saying which of the two is wrong. + + Meta's stated floor is llama.cpp build b10353 or newer, so that is the number to check against whatever + llama.rn release we land on. + +## Runtime upgrade to schedule + +- **Move llama.rn off 0.12.9.** The app pins `^0.12.9` and has 0.12.9 installed; npm's latest is + `0.13.0-rc.0`, published 2026-08-10. New llama.cpp architectures arrive only through this dependency, so + every "new model will not load" report ends here. + + Three things to weigh before taking it: + - It is a release CANDIDATE, and its own CI badge reads failing on the page. A pinned rc in a shipping + app is a choice, not an upgrade. + - 0.13 does not promise the `muse-glimmer` commit. The floor is llama.cpp b10353; grep the candidate's + `cpp/` for the architecture rather than trusting the tag - it took one grep to disprove 0.12.9. + - New Architecture is already on (`newArchEnabled=true`), so the v0.10 requirement is met. + + It also reaches the Hexagon NPU assets and the TTS surface, so it is not a drop-in bump: the HTP kernels + in `android/app/src/main/assets/ggml-hexagon/` come from this runtime, and Gemma is already known to + garble on HTP. From f93fcd9d2c1d536ba382553c576da0a505a82b33 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:37:27 +0530 Subject: [PATCH 004/229] docs(feedback): the receive default was never off, so there is nothing to migrate Checked the history of DEFAULT_RECEIVE_POLICY rather than assuming: `disabledCategories: []` from its first commit. A stored policy with categories off is therefore a choice made on that machine, not an inheritance - and a migration that cleared it would silently re-enable something a user switched off. Recorded as not-fixed with the reason, and with the one question that would turn it into a real defect. --- docs/FEEDBACK_2026-08-12.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index f1febee12..d29d6a355 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -42,15 +42,23 @@ already known; a guess in this list would be read as a finding. generated media and message attachments on, with an offline device set to queue. Anurag's DESKTOP needed both switched on by hand. - Not a per-host divergence, which is what it looks like: both hosts read the same - `DEFAULT_RECEIVE_POLICY` from `shared/packages/sync/src/receive-policy.ts`, and it is `enabled: true` - with nothing disabled. The default applies only when NOTHING is stored, so a profile carrying a policy - written by an earlier build keeps its disabled categories for ever - there is no migration that - reconciles a stored policy with a default that has since changed. - - Unprovable on his Mac now: switching them on overwrote the value, which today reads - `{"enabled":true,"disabledCategories":[],"devices":{}}`. A fresh desktop profile would confirm it in a - minute. + **Not a code divergence, and NOT an old default either.** Both hosts read the same + `DEFAULT_RECEIVE_POLICY` from `shared/packages/sync/src/receive-policy.ts`, and its history shows + `disabledCategories: []` from the first commit onward - there has never been a default that disabled a + category. So a stored policy with categories off did not come from a default; it came from a choice made + on that machine, by hand or by an earlier test run. + + Deliberately NOT fixed. A migration that cleared stored disabled categories would override a real + user choice, and the stored shape records no provenance, so nothing can tell a choice from an + inheritance. Writing one would trade a confusing default for silently re-enabling something a user + switched off. + + Unprovable now in any case: switching them on overwrote the value, which today reads + `{"enabled":true,"disabledCategories":[],"devices":{}}`. + + What would settle it: did anyone switch those off on that Mac during earlier testing? If not, a fresh + desktop profile that comes up with categories disabled IS a real defect, and then the place to look is + whichever host wrote the first policy - not the shared constant. ## Not defects From e4a77e366ee4bc50b200e05c11795e505e5489c0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:44:06 +0530 Subject: [PATCH 005/229] =?UTF-8?q?docs(feedback):=20Pat's=20report=20?= =?UTF-8?q?=E2=80=94=20a=20reinstall=20costs=20a=20seat,=20and=20a=20modal?= =?UTF-8?q?ity=20switch=20finds=20the=20model=20busy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat one is the sharper of the two: the mesh already reclaims the least attributable seat before evicting a live device, so a reinstall leaving a ghost behind means that policy did not run on this path. A user should never have to ask for a seat their own phone vacated. His requests are recorded separately from his defects, including the hosted-GPU one, which deserves a plain answer rather than an open question - the promise is that data stays on the user's devices. --- docs/FEEDBACK_2026-08-12.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index d29d6a355..e75d2b29b 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -123,3 +123,30 @@ already known; a guess in this list would be read as a finding. It also reaches the Hexagon NPU assets and the TTS surface, so it is not a drop-in bump: the HTP kernels in `android/app/src/main/assets/ggml-hexagon/` come from this runtime, and Gemma is already known to garble on HTP. + +## Pro user report — Pat, 2026-08-12 + +### Defects + +- **A reinstall costs a mesh seat.** Pat reinstalled Off Grid Pro on ONE Android device, entered the Pro + key, and the roster now reads "devices 2 of 5". Both entries are the same physical phone; the first + install is gone. He asks whether it can be reset, or whether the key only activates five times. + + This is a seat that cannot be attributed to a live device, which the mesh already has a word for: the + cap policy reclaims the least attributable seat before evicting anything live. So the machinery exists + and either did not run here or does not run on reinstall. A user must never have to ask us to give back + a seat their own phone vacated. + +- **"LLM is busy" when switching from text to voice, and voice arrives slowly on a matched device.** Pat + reports the Android app as sometimes choppy. Two symptoms and probably one cause: something holds the + model while the other modality asks for it. Related to the residency lock - all model loads go through + one owner - so the question is which caller holds it across a modality switch. + + His closing advice is worth recording as given: spend a few hours using the app on real devices. + +### Requests, not defects + +- **Linux and Windows builds.** Windows now exists as a nightly; Linux does not. +- **Share a phone's GPU with the desktop, or expose the phone as an API.** Wanted, unbuilt. +- **Hosted GPU as an offering.** Out of scope for an on-device product, and worth answering plainly rather + than leaving open: the promise is that data stays on the user's devices. From 9d73f51d71b4d690ec9f45b81e5998c34e50a05d Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 10:44:28 +0530 Subject: [PATCH 006/229] docs(feedback): the rule this list is fixed under, at the top where it governs All business logic in shared/sync, hosts as consumers that supply facts and decide nothing. Everything that moves is an item on one durable queue, because a device offline means "not yet" and never "lost". And the tell, written down: "X works here and Y does not" means two code paths doing one job. Four of today's defects are exactly that shape, so the first question for each is where the one owner is - not which side to patch. --- docs/FEEDBACK_2026-08-12.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index e75d2b29b..894b662b1 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -1,5 +1,37 @@ # Cross-device sync feedback — 2026-08-12 +## The rule this list is fixed under + +Mac stated it while we worked through these, and it is the generative diagnosis of nearly every item +below, so it goes first. + +**All business logic lives in `shared/sync`. Desktop and mobile are consumers.** Not "mostly", and not +"the tricky parts". A host supplies FACTS about its platform - what addresses it holds, what the user +tapped, what is on disk - and consumes decisions. It decides nothing. + +**Everything that moves is an ITEM on one durable queue, and the queue syncs it.** A clipboard entry, a +chat, a model, an attachment, a generated image: one item kind each, one queue, one set of rules for +retry, ordering, receive gates and provenance. Durability is the point - a device that is offline means +"not yet", never "lost". Mac: *the durability of the queue is very important.* + +**"X works here and Y does not" is the tell.** Every time this list says one direction works and the +other does not, or one file kind arrives and another does not, the cause is the same shape: two code +paths doing one job, one per host or one per kind, and only one of them was got right. That is not fixed +by patching the broken side. It is fixed by deleting the second path. + +Today's evidence, all of it this shape: + +- A file's activity found its bytes on desktop and not on mobile, because each host answered "which peer + is this activity keyed by" from what it happened to hold. +- A cancelled pairing attempt cleared on desktop and stuck on mobile, because each screen listed the + stages it would show. +- The desktop published no mDNS record, because it read its own address once while mobile had a rule for + following it. +- Attachments travel desktop to android and generated media does not, which is two paths for one job. + +So the fix for each entry below is the same question first: **where is the one owner, and is it in +`shared/sync`?** A change that leaves the rule in a host is not a fix; it is the next report. + From Anurag's pass over the 0.0.104-beta.1 mobile build against desktop (macOS and Windows), reported in Slack between 09:31 and 10:07. Recorded as he described it. Nothing here is diagnosed unless the cause is already known; a guess in this list would be read as a finding. From 3693a185931efe22d7c3940590f05510eaa4f198 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:29:00 +0530 Subject: [PATCH 007/229] refactor(prompt): one owner for the default persona, not three `constants`, `appStore` and `projectStore` each carried their own default system prompt - three different texts for one idea, all opening with the same sentence. It matters beyond tidiness. `systemPrompt` is a SYNCED model setting, so whichever copy a device happens to hold is the one that travels to its peers. A report of a desktop reply opening with "A helpful AI assistant running locally on your device" can only have come from a phone, because that sentence exists nowhere else - and with three sources there was no single thing to point at. --- src/stores/appStore.ts | 6 +++++- src/stores/projectStore.ts | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 7e5bcee1f..d4014498c 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { RecordProvenance } from '@offgrid/sync'; +import { APP_CONFIG } from '../constants'; import { DeviceInfo, DownloadedModel, ModelRecommendation, ONNXImageModel, ImageGenerationMode, AutoDetectMethod, CacheType, InferenceBackend, INFERENCE_BACKENDS, LiteRTBackend, GeneratedImage } from '../types'; import { emitChangedModelSettings, @@ -188,7 +189,10 @@ const DEFAULT_CHECKLIST: OnboardingChecklist = { }; const DEFAULT_SETTINGS: AppSettings = { - systemPrompt: 'You are a helpful AI assistant running locally on the user\'s device. Be concise and helpful.', + // ONE owner for the default persona. This was its own copy, and `projectStore` a third - three texts for + // one idea, all opening with the same sentence. That matters beyond tidiness: `systemPrompt` is a SYNCED + // model setting, so whichever copy a device happens to hold is the one that travels to its peers. + systemPrompt: APP_CONFIG.defaultSystemPrompt, temperature: 0.7, maxTokens: 1024, topP: 0.9, diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 2a58ff340..854081ade 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { APP_CONFIG } from '../constants'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Project } from '../types'; @@ -34,8 +35,9 @@ const DEFAULT_PROJECTS: Project[] = [ id: 'default-assistant', name: 'General Assistant', description: 'A helpful, concise AI assistant for everyday tasks', - systemPrompt: - "You are a helpful AI assistant running locally on the user's device. Be concise and helpful. Focus on providing accurate information and solving the user's problems efficiently.", + // The same one owner the app settings use. A third copy of the default persona is a third answer to + // "who is this assistant", and it is the one a synced `systemPrompt` would carry to every peer. + systemPrompt: APP_CONFIG.defaultSystemPrompt, icon: '#6366F1', // Indigo createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), From ddb11a0340eb2e29120a714e6caa3780247a097e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:29:24 +0530 Subject: [PATCH 008/229] docs(feedback): what was fixed, and where the evidence ran out The Windows preview is fixed and says how. The persona leak is traced to a real route - systemPrompt is a synced setting and that sentence exists only in mobile - but the value has since been overwritten, so it is recorded as not-fixed with the query to run before touching settings next time. The web-search complaint did not reproduce: that chip renders collapsed. Recorded as needing a screenshot rather than left implying a fix. --- docs/FEEDBACK_2026-08-12.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 894b662b1..5252c1cc6 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -50,9 +50,37 @@ already known; a guess in this list would be read as a finding. - **A reply to a phone-sent message is slow on desktop, and sometimes leaks the system prompt.** The response occasionally opens with "A helpful AI assistant running locally on your device." — which is the persona text, not an answer — despite the model running on device. + + **Traced as far as the evidence allows, and NOT fixed.** That sentence exists only in mobile, so it + reached the desktop over sync, and `systemPrompt` is a synced model setting - the route is real. But the + evidence has since been overwritten: the synced `systemPrompt` on that Mac now reads + `"follow instructions"`, and no `messages` row contains the persona text. Nothing left to diagnose from. + + What was fixed is what made it unattributable: mobile held THREE default personas + (`constants/index.ts`, `appStore.ts`, `projectStore.ts`), all opening with that sentence, so there was no + single source to point at. They now share one owner. + + To catch it next time, before changing any setting: + + ```sh + sqlite3 "$HOME/Library/Application Support/Off Grid AI Desktop/memories.db" \ + "SELECT key, origin_device_name, value_json FROM sync_model_settings WHERE key='systemPrompt'; + SELECT id, role, substr(content,1,120) FROM messages + WHERE content LIKE '%helpful AI assistant running locally%';" + ``` +- **The web-search result reads as the answer.** Anurag was confused about which part was the response. + Not reproduced: the tool result already renders as a collapsed 32-character chip in `MemoryChat.tsx`, not + expanded, so what he saw was something else in the answer bubble - plausibly the same class as the + persona leak above, which is content arriving where the reply belongs. Needs a screenshot of the turn. + - **Windows image generation breaks its own preview.** The image is generated but the preview cannot resolve its path; downloading the file works. This also blocks any test of image-generation sync. Anurag believes the preview has always been broken here, so it may predate this release. + + **FIXED.** The handler sliced the scheme off the URL string, but a URL's authority comes before its path - + so `ogcapture://C:/Users/oga/…` put the drive letter in the HOST and dropped its colon, leaving + `C/Users/oga/…`, which names nothing. macOS never showed it because its paths start with a slash. Now + parsed by `capturePathFromUrl`, pure and tested on both dialects. - **An image attached on the phone will not open on desktop.** It reports that the file has moved. - **Generated media does not travel desktop → android.** Attachments from the same direction do arrive, so the transport is working and the generated-media path specifically is not. From 56bb937d6d699d79a7602d6e8455e67d0e0c433f Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:32:36 +0530 Subject: [PATCH 009/229] docs(feedback): Pat's two, diagnosed to the line, and why neither is patched yet The seat: reclaim is gated to the cap, and `isUnclaimedSeat` only names a seat with NO syncDeviceId - his ghost has one, so it is orphaned rather than unclaimed. Acting on "no live device answers to this seat" from one node would evict a device that is merely offline or paired elsewhere. It needs an identity that survives reinstall, which is a product decision. The busy error: the send is refused after a 15s `waitForIdle`, while this codebase documents a 74s CPU prefill in two places. So a healthy prefill reads as busy, and Pat's slowness is the same prefill from the other side. The right fix waits on progress rather than elapsed time, and wants a device round first. --- docs/FEEDBACK_2026-08-12.md | 41 ++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 5252c1cc6..3df020c70 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -192,15 +192,42 @@ already known; a guess in this list would be read as a finding. key, and the roster now reads "devices 2 of 5". Both entries are the same physical phone; the first install is gone. He asks whether it can be reset, or whether the key only activates five times. - This is a seat that cannot be attributed to a live device, which the mesh already has a word for: the - cap policy reclaims the least attributable seat before evicting anything live. So the machinery exists - and either did not run here or does not run on reinstall. A user must never have to ask us to give back - a seat their own phone vacated. + **Diagnosed, and deliberately not fixed - it needs your call.** Two things are true: + + - `meshReclaim` (`shared/.../control-center.ts:742`) returns early below the cap: + `installations.length < PERSONAL_MESH_DEVICE_CAP`. At 2 of 5 nothing reclaims, by design. + - `isUnclaimedSeat` means "the installation has NO syncDeviceId". Pat's ghost has one - the old + install's fingerprint - so it is not unclaimed. It is ORPHANED, and only the first kind has a name, + which is why nothing can act on the second. + + The unsafe fix is the tempting one. From one node, "no live device answers to this seat" cannot be + distinguished from "that device is offline, or paired with another node and not with me" - a five-device + mesh does not pair every node to every other. Acting on that would evict a device someone still uses, + which is the one outcome worse than an inflated count. + + What would make it safe is an identity that survives reinstall, so a phone can say "that seat was mine". + That is a product decision about device identity, not a patch, so it is written down rather than guessed + at. - **"LLM is busy" when switching from text to voice, and voice arrives slowly on a matched device.** Pat - reports the Android app as sometimes choppy. Two symptoms and probably one cause: something holds the - model while the other modality asks for it. Related to the residency lock - all model loads go through - one owner - so the question is which caller holds it across a modality switch. + reports the Android app as sometimes choppy. + + **Cause found, and this codebase documents the contradiction itself.** + `generationServiceHelpers.ts:183` refuses the send when `waitForIdle()` comes back false, and that wait + is bounded at 15 seconds (`llm.ts:437`). Two comments in the same tree record a **74-second CPU prefill + on-device** (`generationToolLoop.ts:886`, `llmToolGeneration.ts:251`). So a perfectly healthy long + prefill reads as "busy" - and the slowness Pat reports is the same prefill, seen from the other side. + + Not changed unverified, because it is generation timing on a hot path and there are two candidate fixes + with different risks: + + - Raise the bound. Cheap, and picks another arbitrary number that some device will exceed. + - Wait on PROGRESS instead of total elapsed time. A stuck engine is one that has stopped moving, which + is not the same thing as one that is taking a while - and it is the condition the guard was actually + written for. `activeCompletionPromise` is swallow-wrapped and always settles, so the engine finishing + is already a reliable signal. + + The second is the right shape. It wants a device round before it ships. His closing advice is worth recording as given: spend a few hours using the app on real devices. From 5be0b50c5b90613b228a6328af1a2e3a43441eb1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:41:35 +0530 Subject: [PATCH 010/229] docs(feedback): first release, so the receive policy has nothing to migrate The objection to a migration was protecting choices already in the field. There is no field. What has to be right is a FRESH install, and that is proven: the default accepts everything, and the two categories added today inherit it - asserted in receive-category-coverage.test.mjs. Anurag's Mac is a dev profile whose categories were switched off during earlier testing. A fact about that machine, not about the product. --- docs/FEEDBACK_2026-08-12.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 3df020c70..8ae78fc4d 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -108,17 +108,21 @@ already known; a guess in this list would be read as a finding. category. So a stored policy with categories off did not come from a default; it came from a choice made on that machine, by hand or by an earlier test run. - Deliberately NOT fixed. A migration that cleared stored disabled categories would override a real - user choice, and the stored shape records no provenance, so nothing can tell a choice from an - inheritance. Writing one would trade a confusing default for silently re-enabling something a user - switched off. + **Closed: nothing to migrate.** This is the first release, so there are no stored policies in the field + to protect - the objection to a migration was about overriding real user choices, and there are none. The + only thing that has to be right is what a FRESH install gets, and that is proven: `DEFAULT_RECEIVE_POLICY` + is `enabled: true` with nothing disabled, and the two categories added today for generated media and + message attachments inherit it. `receive-category-coverage.test.mjs` asserts both are accepted from a + paired device by default. + + Anurag's Mac is a development profile whose categories were switched off during earlier testing, which is + a fact about that machine and not about the product. Unprovable now in any case: switching them on overwrote the value, which today reads `{"enabled":true,"disabledCategories":[],"devices":{}}`. - What would settle it: did anyone switch those off on that Mac during earlier testing? If not, a fresh - desktop profile that comes up with categories disabled IS a real defect, and then the place to look is - whichever host wrote the first policy - not the shared constant. + If a fresh profile ever comes up with categories disabled, that IS a real defect - and the place to look + is whichever host wrote the first policy, not the shared constant. ## Not defects From 20d857732de0595f63a425b0b0efb36ecb876ca4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:43:37 +0530 Subject: [PATCH 011/229] docs(feedback): say which release each report is against Anurag tested the current build, where sync ships for the first time - so his section has no installed base behind it. Pat and the Muse Glimmer report are about previously shipped releases, and the app DOES have an installed base even though sync does not. The distinction decides what "nothing to migrate" may be used for: receive policies, yes; a paying user's licence seat, no. --- docs/FEEDBACK_2026-08-12.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 8ae78fc4d..fa909c45c 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -32,9 +32,17 @@ Today's evidence, all of it this shape: So the fix for each entry below is the same question first: **where is the one owner, and is it in `shared/sync`?** A change that leaves the rule in a host is not a fix; it is the next report. -From Anurag's pass over the 0.0.104-beta.1 mobile build against desktop (macOS and Windows), reported in -Slack between 09:31 and 10:07. Recorded as he described it. Nothing here is diagnosed unless the cause is -already known; a guess in this list would be read as a finding. +Two sources, and they are not about the same build. Keeping them apart decides what has an installed base. + +- **Anurag** tested the CURRENT build - 0.0.104-beta.1 mobile against desktop on macOS and Windows - + reported in Slack between 09:31 and 10:07. Cross-device sync ships for the first time here, so nothing + in his section has users in the field behind it. +- **Pat and the Muse Glimmer report** are about PREVIOUSLY SHIPPED releases. The app has an installed base + even though sync does not, so a licence seat, a model that will not load, and a modality switch are all + live for real users right now - and they cannot be fixed by "there is nothing to migrate". + +Recorded as each person described it. Nothing here is diagnosed unless the cause is established; a guess in +this list would be read as a finding. ## Defects @@ -108,8 +116,8 @@ already known; a guess in this list would be read as a finding. category. So a stored policy with categories off did not come from a default; it came from a choice made on that machine, by hand or by an earlier test run. - **Closed: nothing to migrate.** This is the first release, so there are no stored policies in the field - to protect - the objection to a migration was about overriding real user choices, and there are none. The + **Closed: nothing to migrate.** Sync ships for the first time in this release, so there are no stored + receive policies in the field to protect - the objection to a migration was about overriding real user choices, and there are none. The only thing that has to be right is what a FRESH install gets, and that is proven: `DEFAULT_RECEIVE_POLICY` is `enabled: true` with nothing disabled, and the two categories added today for generated media and message attachments inherit it. `receive-category-coverage.test.mjs` asserts both are accepted from a @@ -146,6 +154,8 @@ already known; a guess in this list would be read as a finding. ## Community report — a model that will not load +Against a previously shipped release, on a device the reporter still uses. + - **Muse Glimmer 30B fails to load; Qwen3.6-27B of similar size loads fine on the same device.** Tried both the official Meta GGUF (K-quant, 17 GB) and the Unsloth quants. @@ -190,6 +200,8 @@ already known; a guess in this list would be read as a finding. ## Pro user report — Pat, 2026-08-12 +Against a PREVIOUSLY SHIPPED release, so both defects below are live for paying users now. + ### Defects - **A reinstall costs a mesh seat.** Pat reinstalled Off Grid Pro on ONE Android device, entered the Pro From 3aa3b0f128c82a957021a66a967478b52e235889 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 11:45:38 +0530 Subject: [PATCH 012/229] docs(test): what to verify by hand, and what the failure looked like One section per fix, each naming the exact action, the expected result, and the old symptom - so a partial fix cannot pass as a whole one. The re-registered-peer case and the dead-interface case are called out because they are the conditions that produced the reports, and neither happens by accident. Also lists what is NOT fixed, so nobody spends time testing for a fix that is deliberately waiting. --- docs/MANUAL_TEST_2026-08-12.md | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/MANUAL_TEST_2026-08-12.md diff --git a/docs/MANUAL_TEST_2026-08-12.md b/docs/MANUAL_TEST_2026-08-12.md new file mode 100644 index 000000000..0441cfd4a --- /dev/null +++ b/docs/MANUAL_TEST_2026-08-12.md @@ -0,0 +1,129 @@ +# What to test by hand — `fix/feedback-2026-08-12` + +Six defects were fixed. Each one below names the exact thing to do, what you should see, and what the +failure looked like before, so a partial fix cannot pass as a whole one. + +Automated coverage already holds the logic: shared 461, mobile 3129, desktop pro sync 573, renderer 204. +None of it can prove a real transfer between two machines, which is what this list is for. + +**Setup once:** Mac desktop and Windows desktop both running, Android and iPhone both paired, all four on +the same network. Install the new mobile build on the phones and a fresh desktop build on both machines. + +--- + +## 1. A received file can be opened — the one to test first + +Two reports were one defect, so both directions must pass. + +1. On the **phone**, send a file manually to **Windows**. Wait for the transfer to complete. +2. On Windows, open Activity and find that row. + +- **Expect:** the row offers the file — a preview and a working Open. +- **Before:** "This activity no longer has a local file", while the bytes were on disk. + +3. On the **phone**, attach an image to a chat message. Open that chat on the **Mac**. + +- **Expect:** the image opens. +- **Before:** "the file has moved". + +**The case that actually broke it:** a device that has re-registered since the transfer. If you can, unpair +and re-pair a phone, then open an activity row from *before* the re-pair. That is the condition the fix +targets — the record names the peer as it was, the row asks for the peer as it is now. + +## 2. Generated media arrives on the phone + +1. On the **desktop**, generate an image. +2. Watch the **Android** phone. + +- **Expect:** it arrives with no extra switch touched. Generated media is on by default now. +- **Before:** attachments arrived and generated media did not. + +3. Then go to the phone's receiving settings and turn **Files** off. Generate another image. + +- **Expect:** it still arrives. "Files" means files sent to you directly, and it no longer governs + generated media. +- **Before:** one switch silently governed both. + +4. Turn **Generated media** off. Generate one more. + +- **Expect:** now it is refused, and the sender reports it as refused rather than silently dropping it. + +Repeat 1 and 2 for a **message attachment** from desktop, which has its own switch for the same reason. + +## 3. A cancelled pairing lets you try again + +1. On the **phone**, start pairing with a desktop and deliberately type a **wrong** code. +2. When it fails, press **Cancel**. +3. Immediately start pairing again with the same device, this time with the right code. + +- **Expect:** the sheet is gone after Cancel, and the retry shows its own progress and pairs. +- **Before:** the sheet still said "pairing…" over the retry, and only restarting the app cleared it. + +Also confirm the useful half survived: type a wrong code and do **not** cancel. + +- **Expect:** it still tells you the codes did not match. Only your own cancel is withheld. + +## 4. A fresh phone can find the desktop + +This is the one that needs the awkward setup, and it is worth it — it silenced the desktop completely. + +1. On the **Mac**, plug in a **dock or USB-Ethernet adapter with no DHCP** — anything that lands on a + `169.254.x` self-assigned address. `ifconfig` should show it active with that address. +2. From another machine: `dns-sd -B _offgrid._tcp local` + +- **Expect:** the Mac's record is listed. +- **Before:** nothing from the Mac appeared, while its own setting still read discoverable. The log said + `Bonjour discovery unavailable: send EHOSTUNREACH 224.0.0.251:5353`. + +3. On a phone with **no pairing to that Mac**, open Devices and scan. + +- **Expect:** the Mac appears and can be paired. +- **Before:** invisible. An already-paired phone kept working, which is what hid this. + +4. Now move the Mac between networks — switch Wi-Fi, or pull the cable and use Wi-Fi. + +- **Expect:** within a few seconds the Mac is discoverable again at its new address, with no restart. +- **This is new behaviour.** Desktop never followed its own address before, so it is the most likely place + for a regression. Please try it twice. + +## 5. A generated image previews on Windows + +1. On **Windows**, generate an image. + +- **Expect:** the preview renders in the chat. +- **Before:** the preview was broken; only Download produced a working file. + +2. Generate one whose prompt makes a long filename, and one while the app is at a different window size. + +- **Expect:** both preview. The old failure was in the path, not the picture, so a path with a space in it + is the interesting case — the profile directory "Off Grid AI Desktop" already contains two. + +3. Then confirm sync: that image should reach the phone (this is also test 2). + +## 6. Nothing regressed on macOS previews + +The preview fix touched a path shared by every locally served image. + +1. On the **Mac**, open Replay, a generated image, and a style-picker thumbnail. + +- **Expect:** all render as before. macOS never had the Windows fault, so this is purely a no-regression + check on the same code. + +--- + +## Not fixed, so do not test for a fix + +Recorded in `FEEDBACK_2026-08-12.md` with the reason each one waits: + +- **A reinstall costs a mesh seat** (Pat). Needs a device identity that survives reinstall — a product + decision, and acting without one would evict a device someone still uses. +- **"LLM is busy" on a text-to-voice switch** (Pat). Cause found: the send is refused after a 15-second + wait while this codebase documents a 74-second prefill. The fix should wait on progress rather than + elapsed time, and wants a device round of its own. +- **Muse Glimmer 30B will not load.** Confirmed: the architecture is absent from the llama.cpp that + llama.rn 0.12.9 bundles. Needs the dependency moved, not a setting changed. +- **The persona text opening a reply.** The route is real — `systemPrompt` is a synced setting and that + sentence exists only in mobile — but the evidence was overwritten before it could be read. If you see it + again, run the query in the feedback doc **before** changing any setting. +- **The web-search result reading as the answer.** Did not reproduce; that chip renders collapsed. A + screenshot of the turn would settle it. From 612622968bd838f629dcbeb5c8690eeec01812f6 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 12:00:23 +0530 Subject: [PATCH 013/229] docs: the pairing fix is withdrawn, and the test plan says so My first cut hid a cancelled attempt. The mobile integration journey disproved the premise by passing without it: it cancels, reads "Pairing cancelled", retries and pairs. Retry-after-cancel already works from waiting_for_confirmation, and the confirmation is wanted. Kept the fold-by-id, which is a real fix for a second route to the same stuck sheet. The untested difference is order: Anurag cancelled an attempt that had already FAILED, and a terminal attempt may have nothing left to cancel. The plan now asks for that sequence instead of claiming a fix. --- docs/FEEDBACK_2026-08-12.md | 12 ++++++++++++ docs/MANUAL_TEST_2026-08-12.md | 27 ++++++++++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index fa909c45c..95a3d5aab 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -48,6 +48,18 @@ this list would be read as a finding. - **Pairing keeps a dead attempt alive.** Enter a wrong code, cancel, and the state does not reset. The next attempt still shows "pairing…", and only restarting the app clears it. Mobile. + + **STILL OPEN.** A first fix hid a cancelled attempt from the sheet; + `deviceManagement.integration.test.tsx` disproved the premise by passing without it - that journey + cancels, reads "Pairing cancelled", retries and pairs. So retry-after-cancel already works from + `waiting_for_confirmation`, and the confirmation is a message the design wants. Reverted. + + Kept from that work: an attempt is read at its LAST state, so it can never be presented from an earlier + row of its own history - which was a second route to the same stuck sheet. + + The untested difference is the order. The passing journey cancels while still waiting; Anurag cancelled + an attempt that had already FAILED on a wrong code, and a terminal attempt may have nothing left to + cancel. Needs the three-step sequence walked on a device before anything else is changed. - **A file sent phone → Windows arrives with no local file.** The transfer reports complete, then the activity row reads "This activity no longer has a local file" and the file will not open. Windows desktop, receive path. Confirmed by Anurag at 09:52 as a MANUAL send, not an ambient share - so the diff --git a/docs/MANUAL_TEST_2026-08-12.md b/docs/MANUAL_TEST_2026-08-12.md index 0441cfd4a..a8ae9e02e 100644 --- a/docs/MANUAL_TEST_2026-08-12.md +++ b/docs/MANUAL_TEST_2026-08-12.md @@ -1,6 +1,6 @@ # What to test by hand — `fix/feedback-2026-08-12` -Six defects were fixed. Each one below names the exact thing to do, what you should see, and what the +Five defects were fixed, and one that I thought I had fixed is corrected below in section 3. Each one below names the exact thing to do, what you should see, and what the failure looked like before, so a partial fix cannot pass as a whole one. Automated coverage already holds the logic: shared 461, mobile 3129, desktop pro sync 573, renderer 204. @@ -50,18 +50,27 @@ targets — the record names the peer as it was, the row asks for the peer as it Repeat 1 and 2 for a **message attachment** from desktop, which has its own switch for the same reason. -## 3. A cancelled pairing lets you try again +## 3. Pairing — NOT FIXED, but worth reproducing precisely -1. On the **phone**, start pairing with a desktop and deliberately type a **wrong** code. -2. When it fails, press **Cancel**. -3. Immediately start pairing again with the same device, this time with the right code. +I had this wrong and the test suite caught me. My first fix hid a cancelled attempt, and +`deviceManagement.integration.test.tsx` disproved the premise: that journey cancels, reads "Pairing +cancelled", retries and pairs, so retry-after-cancel already worked and the confirmation is wanted. I +reverted the behaviour change and kept only a genuine robustness fix — an attempt is now read at its LAST +state, so it can never be presented from an earlier row of its own history. -- **Expect:** the sheet is gone after Cancel, and the retry shows its own progress and pairs. -- **Before:** the sheet still said "pairing…" over the retry, and only restarting the app cleared it. +**So do not test for a fix here. Test to pin down the sequence**, because the working journey and your +report disagree, and the difference is the bug: -Also confirm the useful half survived: type a wrong code and do **not** cancel. +1. Start pairing, type a **wrong** code, let it fail. +2. Press **Cancel** on the failed attempt. +3. Start pairing again with the correct code. -- **Expect:** it still tells you the codes did not match. Only your own cancel is withheld. +Tell me exactly what the sheet says at each step, and whether the retry pairs. My suspicion is that a +cancel does nothing to an attempt that has already reached `failed` — a terminal attempt has nothing left +to cancel — so it stays on screen as the last thing that happened. The journey that passes cancels while +still `waiting_for_confirmation`, which is a different state and a different code path. + +A screen recording of those three steps would settle it in one pass. ## 4. A fresh phone can find the desktop From 3985f20bcfa59dde1cae1ec0dfa72e98d090f998 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 12:44:00 +0530 Subject: [PATCH 014/229] chore(llama): move the runtime to llama.rn 0.13.0-rc.0 Pinned exactly rather than caretted: a caret on a prerelease resolves unpredictably, and this is a release candidate whose own CI badge reads failing upstream - so the version we ship should be the version we chose. What it buys: the bundled llama.cpp knows `nemotron`, `nemotron_h` and `nemotron_h_moe`, so Nemotron 3.5 loads. What it does NOT buy: `muse-glimmer` appears in zero files of its cpp/, against 48 mentioning qwen3 - so Muse Glimmer still cannot load on mobile and no setting will change that. The Hexagon kernels already in this repo are byte-identical to the ones this version ships, so the assets need no change. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6156fcd4c..179622511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "buffer": "^6.0.3", "js-sha256": "^0.11.0", "js-sha512": "^0.9.0", - "llama.rn": "^0.12.9", + "llama.rn": "0.13.0-rc.0", "node-html-parser": "^7.1.0", "patch-package": "^8.0.1", "react": "19.2.0", @@ -12662,9 +12662,9 @@ } }, "node_modules/llama.rn": { - "version": "0.12.9", - "resolved": "https://registry.npmjs.org/llama.rn/-/llama.rn-0.12.9.tgz", - "integrity": "sha512-uRsTVARp1KnDkDg00FvOGIrN6SZfMqYVJfCOdxN9FSyGufLC+Aad6oFWZVEO8vmtNqu+JBlsCGevP4sJGifAvg==", + "version": "0.13.0-rc.0", + "resolved": "https://registry.npmjs.org/llama.rn/-/llama.rn-0.13.0-rc.0.tgz", + "integrity": "sha512-6VWkmFzcPBX+Xv2gqKm+o0kWpa4gPhNJ74EM89iL2zaxruxmu/eApTHDjGRzX3dMPHhDJWyloJ+d00xhg+9FeA==", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 899fdf9c1..38cb1c325 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "buffer": "^6.0.3", "js-sha256": "^0.11.0", "js-sha512": "^0.9.0", - "llama.rn": "^0.12.9", + "llama.rn": "0.13.0-rc.0", "node-html-parser": "^7.1.0", "patch-package": "^8.0.1", "react": "19.2.0", From b1d147f6e06f9c2f19a8d19c091d8ef532e385de Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 12:45:43 +0530 Subject: [PATCH 015/229] test(tts): the OuteTTS double matches the runtime we ship It implemented the OLD positional API and asserted guide tokens were forwarded - behaviour 0.13 removed. A boundary double that lags the runtime proves the engine against an API that no longer exists. Now: one options object, and the assertion is that we send NO guide tokens, which is the actual guarantee. --- .../pro/audio/engines/OuteTTSEngine.test.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/__tests__/pro/audio/engines/OuteTTSEngine.test.ts b/__tests__/pro/audio/engines/OuteTTSEngine.test.ts index b45fe56fe..ecd74d9a2 100644 --- a/__tests__/pro/audio/engines/OuteTTSEngine.test.ts +++ b/__tests__/pro/audio/engines/OuteTTSEngine.test.ts @@ -16,14 +16,13 @@ */ // ── Native llama.rn mockRuntime — a dumb, controllable context stub ───────────── -type CompletionArgs = { prompt: string; grammar: unknown; guide_tokens: number[] }; +type CompletionArgs = { prompt: string; grammar: unknown; guide_tokens?: number[] }; interface MockCtx { released: boolean; vocoderReleased: boolean; initVocoder: jest.Mock; isVocoderEnabled: jest.Mock; getFormattedAudioCompletion: jest.Mock; - getAudioCompletionGuideTokens: jest.Mock; completion: jest.Mock; decodeAudioTokens: jest.Mock; releaseVocoder: jest.Mock; @@ -45,10 +44,11 @@ function mockMakeContext(): MockCtx { vocoderReleased: false, initVocoder: jest.fn(() => Promise.resolve()), isVocoderEnabled: jest.fn(() => Promise.resolve(mockRuntime.vocoderEnabled)), - getFormattedAudioCompletion: jest.fn((_speaker: unknown, text: string) => + // llama.rn 0.13 takes ONE options object here. The double follows the runtime we ship, or it proves + // the engine against an API that no longer exists. + getFormattedAudioCompletion: jest.fn(({ prompt: text }: { prompt: string }) => Promise.resolve({ prompt: `PROMPT:${text}`, grammar: 'G' }), ), - getAudioCompletionGuideTokens: jest.fn(() => Promise.resolve(mockRuntime.guideTokens)), completion: jest.fn((args: CompletionArgs) => { mockRuntime.completionArgs = args; return Promise.resolve({ audio_tokens: mockRuntime.audioTokens }); @@ -340,25 +340,23 @@ describe('OuteTTSEngine — speak', () => { expect(e.getPhase()).toBe('ready'); }); - it('forwards guide tokens + prompt from the runtime into completion()', async () => { + it('forwards the runtime\'s formatted prompt into completion(), and no guide tokens', async () => { + // llama.rn 0.13 removed `getAudioCompletionGuideTokens` AND the `guide_tokens` completion param: + // native owns them now, carried by the grammar the formatted completion returns. Sending our own + // would be this engine deciding something the runtime already decided. const e = await readyEngine(); await e.speak('the quick brown fox'); expect(mockRuntime.completionArgs?.prompt).toBe('PROMPT:the quick brown fox'); - expect(mockRuntime.completionArgs?.guide_tokens).toEqual(mockRuntime.guideTokens); - }); - - it('defaults guide tokens to [] when the runtime returns null', async () => { - mockRuntime.guideTokens = null; - const e = await readyEngine(); - await e.speak('x'); - expect(mockRuntime.completionArgs?.guide_tokens).toEqual([]); + expect(mockRuntime.completionArgs?.guide_tokens).toBeUndefined(); }); it('truncates text longer than 300 chars before generation', async () => { const e = await readyEngine(); const long = 'a'.repeat(500); await e.speak(long); - const forwarded = mockRuntime.lastContext!.getFormattedAudioCompletion.mock.calls[0][1] as string; + const forwarded = ( + mockRuntime.lastContext!.getFormattedAudioCompletion.mock.calls[0][0] as { prompt: string } + ).prompt; expect(forwarded.length).toBe(300); expect(forwarded.endsWith('...')).toBe(true); }); @@ -464,11 +462,11 @@ describe('OuteTTSEngine — stop / pause / resume phase logic', () => { it('pause() moves processing → paused and resume() moves it back', async () => { const e = await readyEngine(); - // Hold generation open so the engine sits in 'processing'. Deferring the - // FIRST awaited runtime call (guide tokens) keeps speak() in-flight. - let resolveGuide: (v: number[]) => void = () => {}; - mockRuntime.lastContext!.getAudioCompletionGuideTokens.mockImplementationOnce( - () => new Promise((resolve) => { resolveGuide = resolve; }), + // Hold generation open so the engine sits in 'processing'. The first awaited runtime call is now the + // formatted completion - 0.13 removed the guide-token call this used to defer. + let resolveGuide: (v: { prompt: string; grammar: string }) => void = () => {}; + mockRuntime.lastContext!.getFormattedAudioCompletion.mockImplementationOnce( + () => new Promise((resolve) => { resolveGuide = resolve; }), ); const speaking = e.speak('hold'); await flushMicrotasks(); @@ -479,16 +477,16 @@ describe('OuteTTSEngine — stop / pause / resume phase logic', () => { e.resume(); expect(e.getPhase()).toBe('processing'); - resolveGuide([1, 2]); + resolveGuide({ prompt: 'PROMPT:hold', grammar: 'G' }); await speaking; expect(e.getPhase()).toBe('ready'); }); it('stop() during generation aborts playback (no audioComplete) and restores ready', async () => { const e = await readyEngine(); - let resolveGuide: (v: number[]) => void = () => {}; - mockRuntime.lastContext!.getAudioCompletionGuideTokens.mockImplementationOnce( - () => new Promise((resolve) => { resolveGuide = resolve; }), + let resolveGuide: (v: { prompt: string; grammar: string }) => void = () => {}; + mockRuntime.lastContext!.getFormattedAudioCompletion.mockImplementationOnce( + () => new Promise((resolve) => { resolveGuide = resolve; }), ); const completes: unknown[] = []; e.on('audioComplete', (a) => completes.push(a)); @@ -500,7 +498,7 @@ describe('OuteTTSEngine — stop / pause / resume phase logic', () => { e.stop(); // clears _isSpeakingFlag while generation is in-flight expect(e.getPhase()).toBe('ready'); - resolveGuide([1, 2]); + resolveGuide({ prompt: 'PROMPT:hold', grammar: 'G' }); await speaking; expect(completes).toHaveLength(0); // aborted before emit/playback }); From a2fd75b7035a06fabc3525f7b787d0e86b358c1e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 12:57:50 +0530 Subject: [PATCH 016/229] docs(feedback): Meta's own numbers put Muse Glimmer on the desktop, not the phone 30B, Apache 2.0, multimodal with a dedicated perception encoder. Over 55 GB at full precision, under 20 GB at 4-bit, needing a 24-32 GB envelope on "Mac or PC with a single consumer GPU". That reframes the report: no phone has that envelope, so the mobile attempt could not have succeeded whatever the runtime. The llama.rn gap is real and secondary. Desktop is where it is viable, and b10369 already knows the architecture. --- docs/FEEDBACK_2026-08-12.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 95a3d5aab..509b49823 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -181,6 +181,19 @@ Against a previously shipped release, on a device the reporter still uses. `qwen3` and `LLM_ARCH_QWEN3` declared in `llama-arch.h`. The runtime we ship cannot know the architecture, so the reporter is right and no device-side setting will change it. + **Meta's own numbers put this model on the desktop** (research.meta.ai, 2026-08-10): 30B, Apache 2.0, + multimodal with "a dedicated perception encoder" and interleaved text and images. Full precision "would + require over 55 GB of memory"; at 4-bit "under 20 GB", fitting "a 24 GB or 32 GB envelope" on a consumer + GPU. The target it names is "Mac or PC with a single consumer GPU". It ships a DFlash "drafter" for + speculative decoding, quoted at 3.1x on an RTX 5090 and 1.5x on an M4 Max. + + That reframes the report. No phone has a 24 GB envelope, so the mobile attempt could not have succeeded + whatever the runtime - the llama.rn gap is real but SECONDARY, and the honest answer to that reporter is + that Android cannot hold this model, not that we are behind on a dependency. + + Desktop is where it is viable, and there the path is short: llama.cpp b10369 (2026-08-12) knows + `muse-glimmer`, and the perception encoder rides the mmproj path desktop already has for vision models. + Two things the upgrade alone will not solve: - **The model needs a perception encoder passed at load**, the same shape as the mmproj path vision From a1ed12cf7ab129ff0a0c95e20dbee952d72217b8 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 13:19:41 +0530 Subject: [PATCH 017/229] docs(feedback): the llama.rn decision, and what to check before the release Mobile is on 0.13.0-rc.0, which gets Nemotron 3.5 and not Muse Glimmer. PR #379 upstream syncs to b10362 and names Muse Glimmer, but it is open - and a merge alone is not enough, because the prebuilt native artefacts come from the matching release. So: check again immediately before the release, bump if it landed, otherwise next release. Desktop has it today via b10369. --- docs/FEEDBACK_2026-08-12.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/FEEDBACK_2026-08-12.md b/docs/FEEDBACK_2026-08-12.md index 509b49823..0b7322dc6 100644 --- a/docs/FEEDBACK_2026-08-12.md +++ b/docs/FEEDBACK_2026-08-12.md @@ -206,6 +206,21 @@ Against a previously shipped release, on a device the reporter still uses. Meta's stated floor is llama.cpp build b10353 or newer, so that is the number to check against whatever llama.rn release we land on. +## Runtime upgrade — decided 2026-08-12 + +**Mobile is on `llama.rn` 0.13.0-rc.0 now.** That buys Nemotron 3.5 (`nemotron`, `nemotron_h`, +`nemotron_h_moe`). It does NOT buy Muse Glimmer: neither `muse-glimmer` nor `granite-switch` appears in its +bundled `cpp/`. + +**Muse Glimmer on Android waits for upstream.** llama.rn PR #379 syncs to llama.cpp b10362 and names Muse +Glimmer support; it is open, its checks pass, and the sync looks like a daily automated job. A merge alone +is not enough - the postinstall pulls the prebuilt `rnllama.xcframework` and `jniLibs` from the MATCHING +release, so a PR-branch install would have no artefacts and we would have to build the native side +ourselves. + +Decision: **check again immediately before the release.** If a release carries it, bump the pin. If not, it +goes in the next one. Desktop already has it via `b10369`, so the model is testable there today. + ## Runtime upgrade to schedule - **Move llama.rn off 0.12.9.** The app pins `^0.12.9` and has 0.12.9 installed; npm's latest is From 4304f39ac7e721e134e233de64cc0d5112b1237e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 12 Aug 2026 13:25:15 +0530 Subject: [PATCH 018/229] docs(test): the Android clipboard and the runtime bump were missing from the plan The clipboard fix is the headline item from Anurag's list and had no section at all - including the Accessibility grant, which is a permission nobody has granted before and the only new user-facing flow in this branch. The llama.rn 0.13 bump had none either, and it is the riskiest change here: a release candidate that moved the TTS API, so OuteTTS speech is the first thing to listen to. Also corrects the branch name in the header. --- docs/MANUAL_TEST_2026-08-12.md | 81 ++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/docs/MANUAL_TEST_2026-08-12.md b/docs/MANUAL_TEST_2026-08-12.md index a8ae9e02e..f1cc40cc2 100644 --- a/docs/MANUAL_TEST_2026-08-12.md +++ b/docs/MANUAL_TEST_2026-08-12.md @@ -1,6 +1,7 @@ -# What to test by hand — `fix/feedback-2026-08-12` +# What to test by hand — `release/sync-feedback` -Five defects were fixed, and one that I thought I had fixed is corrected below in section 3. Each one below names the exact thing to do, what you should see, and what the +Six defects were fixed, one is withdrawn (section 3), and the mobile inference runtime moved — which is the +riskiest thing in here and has its own section. Each one below names the exact thing to do, what you should see, and what the failure looked like before, so a partial fix cannot pass as a whole one. Automated coverage already holds the logic: shared 461, mobile 3129, desktop pro sync 573, renderer 204. @@ -50,7 +51,46 @@ targets — the record names the peer as it was, the row asks for the peer as it Repeat 1 and 2 for a **message attachment** from desktop, which has its own switch for the same reason. -## 3. Pairing — NOT FIXED, but worth reproducing precisely +## 3. Copy in another app on Android, paste on the desktop + +The headline fix from your list, and the one that needs a permission you have never granted before. + +1. Install the new build. Open **Sync settings** and turn **Clipboard sync** ON. + +- **Expect:** a sheet appears saying Android hides the clipboard from apps that are not on screen, with a + button to open Accessibility settings. +- **Expect:** nothing asked for this at launch — only when you switched the feature on. + +2. Tap the button, and turn **Off Grid AI** on in the Accessibility list. + +- Read that screen's description while you are there. It says what is read and what is not, and it is what + a user decides on. + +3. Come back to the app. Open **Chrome**, select some text, copy it. + +- **Expect:** it reaches the desktop clipboard. +- **Before:** nothing outside the Off Grid app was ever captured — the phone had never sent one clipboard + entry, in either build. + +4. Copy something **inside** the Off Grid app. + +- **Expect:** still works. That path already worked and must not have regressed. + +5. Now turn the Accessibility service **off** in Android settings, return, and toggle Clipboard sync off + and on again. + +- **Expect:** the sheet appears again, because the grant is genuinely missing. It is not a one-time + explainer. + +6. Copy an image or a file in another app. + +- **Expect:** nothing syncs. Clipboard sync is text-only by design, so this is a check that it fails + quietly rather than doing something surprising. + +**The honest limit:** capture rides on a text selection. A long-press "Copy" with no highlight may still +come back empty. If you find a copy that does not travel, tell me whether you had text selected. + +## 4. Pairing — NOT FIXED, but worth reproducing precisely I had this wrong and the test suite caught me. My first fix hid a cancelled attempt, and `deviceManagement.integration.test.tsx` disproved the premise: that journey cancels, reads "Pairing @@ -72,7 +112,7 @@ still `waiting_for_confirmation`, which is a different state and a different cod A screen recording of those three steps would settle it in one pass. -## 4. A fresh phone can find the desktop +## 5. A fresh phone can find the desktop This is the one that needs the awkward setup, and it is worth it — it silenced the desktop completely. @@ -95,7 +135,7 @@ This is the one that needs the awkward setup, and it is worth it — it silenced - **This is new behaviour.** Desktop never followed its own address before, so it is the most likely place for a regression. Please try it twice. -## 5. A generated image previews on Windows +## 6. A generated image previews on Windows 1. On **Windows**, generate an image. @@ -109,7 +149,7 @@ This is the one that needs the awkward setup, and it is worth it — it silenced 3. Then confirm sync: that image should reach the phone (this is also test 2). -## 6. Nothing regressed on macOS previews +## 7. Nothing regressed on macOS previews The preview fix touched a path shared by every locally served image. @@ -118,6 +158,29 @@ The preview fix touched a path shared by every locally served image. - **Expect:** all render as before. macOS never had the Windows fault, so this is purely a no-regression check on the same code. +## 8. The mobile inference runtime moved — the riskiest change here + +`llama.rn` went from 0.12.9 to 0.13.0-rc.0. It is a release candidate, and it changed the TTS API. + +1. Load a text model you use often and send a few messages. + +- **Expect:** loads and streams as before. Try one on the Hexagon/NPU backend too, since those kernels come + from this runtime — they are byte-identical to what we shipped, but worth one pass. + +2. **Speak a reply with OuteTTS.** This is the one I would test first. + +- **Expect:** speech sounds as it did. +- **Why:** 0.13 removed the guide-token API OuteTTS used and moved that job into native. I adapted the + engine, but guide tokens are what kept the spoken output tied to the text, so a regression would show as + drifting or wrong words rather than an error. + +3. Try **Nemotron 3.5** — new in this runtime, and it should now load where it could not before. + +4. Voice mode: switch text → voice mid-conversation. + +- **Expect:** it may still say "LLM is busy". That is Pat's open defect, not this bump. Note if it got + worse. + --- ## Not fixed, so do not test for a fix @@ -129,8 +192,10 @@ Recorded in `FEEDBACK_2026-08-12.md` with the reason each one waits: - **"LLM is busy" on a text-to-voice switch** (Pat). Cause found: the send is refused after a 15-second wait while this codebase documents a 74-second prefill. The fix should wait on progress rather than elapsed time, and wants a device round of its own. -- **Muse Glimmer 30B will not load.** Confirmed: the architecture is absent from the llama.cpp that - llama.rn 0.12.9 bundles. Needs the dependency moved, not a setting changed. +- **Muse Glimmer 30B on Android.** Still absent from llama.rn 0.13.0-rc.0. Upstream PR #379 carries it and + is open; we check again immediately before the release. It IS testable on **desktop**, which moved to + llama.cpp b10369 — and by Meta's own numbers it is a desktop model anyway: under 20 GB at 4-bit, needing + a 24-32 GB envelope. - **The persona text opening a reply.** The route is real — `systemPrompt` is a synced setting and that sentence exists only in mobile — but the evidence was overwritten before it could be read. If you see it again, run the query in the feedback doc **before** changing any setting. From 2fe04e3faf275a358904465cc2d25ba5fc5b6d18 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:02:01 +0530 Subject: [PATCH 019/229] fix(fs): one safe way to ask about a file, so a stale path cannot abort the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app died three seconds after every launch, on every launch. `RNFS.stat` builds its result dictionary and inserts the file type UNGUARDED: @{ @"ctime": …, @"mtime": …, @"size": …, @"type": [attrs objectForKey:NSURLFileResourceTypeKey] } iOS omits a key it cannot determine rather than failing, so `type` arrives nil and NSDictionary raises `NSInvalidArgumentException: attempt to insert nil object from objects[3]`. The size directly above it has a nil guard. The startup model scan stats every stored path, and an absolute container path goes stale on reinstall - so a scan of the user's own models killed the process. A JS try/catch cannot save this. The exception is raised on the module's own queue and rethrown natively, so the process is gone before any promise settles, which is why `RNFS.stat(p).catch(…)` reads as safe in 22 places and is safe in none. `readDir` answers the same question and cannot fail that way: it guards nil attributes, defaults a missing size, and derives the type from booleans. This asks the PARENT for the entry instead of asking the path about itself, and returns null for a missing file - the ANSWER, not an error. Bytes come through the existing size rule rather than a second conversion. --- .../ModelsScreen/imageDownloadResume.ts | 9 +-- src/services/documentService.ts | 9 ++- src/services/llmSafetyChecks.ts | 5 +- src/services/modelManager/copyFile.ts | 7 +- src/services/modelManager/scan.ts | 5 +- .../modelManager/transferAdmission.ts | 14 ++-- src/services/whisperModelFiles.ts | 4 +- src/utils/debugLogFile.ts | 5 +- src/utils/fileStat.ts | 78 +++++++++++++++++++ 9 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 src/utils/fileStat.ts diff --git a/src/screens/ModelsScreen/imageDownloadResume.ts b/src/screens/ModelsScreen/imageDownloadResume.ts index 183a6f33a..ac31737d7 100644 --- a/src/screens/ModelsScreen/imageDownloadResume.ts +++ b/src/screens/ModelsScreen/imageDownloadResume.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; import { unzip } from 'react-native-zip-archive'; import { modelManager, backgroundDownloadService } from '../../services'; import { resolveCoreMLModelDir } from '../../utils/coreMLModelUtils'; @@ -39,13 +40,7 @@ async function validateModelDir(modelDir: string, backend?: string): Promise { if (!(await RNFS.exists(zipPath))) return false; - let actualSize = 0; - try { - const zipStat = await RNFS.stat(zipPath); - actualSize = Number(zipStat.size); - } catch { - return false; - } + const actualSize = (await statFile(zipPath))?.size ?? 0; if (!Number.isFinite(actualSize) || actualSize <= 0) { return false; diff --git a/src/services/documentService.ts b/src/services/documentService.ts index 15f8f0c1a..018fef70b 100644 --- a/src/services/documentService.ts +++ b/src/services/documentService.ts @@ -5,6 +5,7 @@ import { Platform } from 'react-native'; import RNFS from 'react-native-fs'; +import { statFile } from '../utils/fileStat'; import { MediaAttachment } from '../types'; import { pdfExtractor } from './pdfExtractor'; import { useAppStore } from '../stores/appStore'; @@ -250,9 +251,9 @@ class DocumentService { throw new Error(`File not found: ${name}`); } - const stat = await RNFS.stat(resolvedPath); - console.log(`[DocumentService] File size: ${stat.size} bytes`); - if (stat.size > MAX_FILE_SIZE) { + const fileSize = (await statFile(resolvedPath))?.size ?? 0; + console.log(`[DocumentService] File size: ${fileSize} bytes`); + if (fileSize > MAX_FILE_SIZE) { throw new Error( `File is too large. Maximum size is ${ MAX_FILE_SIZE / (1024 * 1024) @@ -281,7 +282,7 @@ class DocumentService { uri, fileName: name, textContent, - fileSize: stat.size, + fileSize, }; } catch (error: any) { throw error; diff --git a/src/services/llmSafetyChecks.ts b/src/services/llmSafetyChecks.ts index dfeba2a8c..2f1f24143 100644 --- a/src/services/llmSafetyChecks.ts +++ b/src/services/llmSafetyChecks.ts @@ -1,7 +1,9 @@ import { LlamaContext } from 'llama.rn'; import RNFS from 'react-native-fs'; +import { statFile } from '../utils/fileStat'; import logger from '../utils/logger'; import { OverridableMemoryError } from '../utils/modelLoadErrors'; +import { sizeToBytes } from '../utils/fileSize'; /** * GGUF magic number — first 4 bytes of every valid GGUF file. @@ -24,8 +26,7 @@ function decodeLittleEndianUint32(bytes: string): number | null { */ export async function validateModelFile(modelPath: string): Promise<{ valid: boolean; reason?: string }> { try { - const stat = await RNFS.stat(modelPath); - const fileSize = typeof stat.size === 'string' ? Number.parseInt(stat.size, 10) : stat.size; + const fileSize = (await statFile(modelPath))?.size ?? 0; const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(1); logger.log(`[LLM] Validating model: ${modelPath}`); logger.log(`[LLM] Model file size: ${fileSizeMB}MB (${fileSize} bytes)`); diff --git a/src/services/modelManager/copyFile.ts b/src/services/modelManager/copyFile.ts index 03d5236d0..cce309577 100644 --- a/src/services/modelManager/copyFile.ts +++ b/src/services/modelManager/copyFile.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; type CopyProgressOpts = { knownTotalBytes: number | null; onProgress?: (fraction: number) => void }; @@ -14,8 +15,7 @@ export async function copyFileWithProgress( let totalBytes = knownTotalBytes ?? 0; if (totalBytes === 0) { try { - const sourceStat = await RNFS.stat(source); - totalBytes = parseSizeInt(sourceStat.size); + totalBytes = (await statFile(source))?.size ?? 0; } catch { // stat failed — progress will be indeterminate (stuck at 0%), non-fatal } @@ -28,8 +28,7 @@ export async function copyFileWithProgress( try { const exists = await RNFS.exists(dest); if (exists && totalBytes > 0) { - const stat = await RNFS.stat(dest); - const written = parseSizeInt(stat.size); + const written = (await statFile(dest))?.size ?? 0; const pct = Math.min(written / totalBytes, 0.99); onProgress?.(pct); } diff --git a/src/services/modelManager/scan.ts b/src/services/modelManager/scan.ts index e14473d24..5580b0887 100644 --- a/src/services/modelManager/scan.ts +++ b/src/services/modelManager/scan.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; import { unzip } from 'react-native-zip-archive'; import { DownloadedModel, LlamaDownloadedModel, ONNXImageModel } from '../../types'; import { loadDownloadedModels, saveModelsList } from './storage'; @@ -119,8 +120,8 @@ export interface ReconcileImageModelsOpts { async function isValidZip(zipPath: string): Promise { if (!(await RNFS.exists(zipPath))) return false; try { - const stat = await RNFS.stat(zipPath); - const size = parseSizeInt(stat.size); + const stat = await statFile(zipPath); + const size = stat?.size ?? 0; if (!Number.isFinite(size) || size <= 0) return false; } catch { return false; diff --git a/src/services/modelManager/transferAdmission.ts b/src/services/modelManager/transferAdmission.ts index d18aa853a..eed86a9ed 100644 --- a/src/services/modelManager/transferAdmission.ts +++ b/src/services/modelManager/transferAdmission.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; import { ogamModelTransferBlocker, type TransferredModelManifest, @@ -9,6 +10,7 @@ import { determineCredibility, persistDownloadedModel, } from './storage'; +import { sizeToBytes } from '../../utils/fileSize'; export async function registerTransferredModelFile( manifest: TransferredModelManifest, @@ -29,12 +31,8 @@ export async function registerTransferredModelFile( for (const file of manifest.files) { const filePath = `${modelsDir}/${file.name}`; - const stat = await RNFS.stat(filePath); - const actualSize = - typeof stat.size === 'string' - ? Number.parseInt(stat.size, 10) - : stat.size; - if (!stat.isFile() || actualSize !== file.sizeBytes) { + const stat = await statFile(filePath); + if (!stat?.isFile || stat.size !== file.sizeBytes) { throw new Error('Transferred model file does not match its manifest'); } } @@ -66,6 +64,10 @@ export async function registerTransferredModelFile( file: pseudoFile, resolvedLocalPath: primaryPath, mmProjPath: projectorPath, + // The sender's provenance, when it had any. A received package has no download URL of its own + // to parse, so this is the only way the copy keeps a repairable source; without it a + // transferred vision model missing its projector had nowhere to fetch one from. + origin: manifest.origin, }); const author = manifest.source === 'local' diff --git a/src/services/whisperModelFiles.ts b/src/services/whisperModelFiles.ts index d732f2c55..3e2e1232b 100644 --- a/src/services/whisperModelFiles.ts +++ b/src/services/whisperModelFiles.ts @@ -6,6 +6,7 @@ * functions; their signatures and behavior are unchanged. */ import RNFS from 'react-native-fs'; +import { statFile } from '../utils/fileStat'; import logger from '../utils/logger'; /** @@ -75,8 +76,7 @@ export async function validateModelFile(modelPath: string): Promise { throw new Error(`Whisper model file not found at: ${modelPath}`); } - const stat = await RNFS.stat(modelPath); - const fileSize = Number(stat.size); + const fileSize = (await statFile(modelPath))?.size ?? 0; if (Number.isNaN(fileSize) || fileSize < MIN_MODEL_FILE_SIZE) { // Remove the corrupted file so the user can re-download await RNFS.unlink(modelPath).catch(() => {}); diff --git a/src/utils/debugLogFile.ts b/src/utils/debugLogFile.ts index bb600a7d0..64000d10d 100644 --- a/src/utils/debugLogFile.ts +++ b/src/utils/debugLogFile.ts @@ -18,6 +18,7 @@ * unbounded. Logging must NEVER throw, so every FS call is best-effort. */ import RNFS from 'react-native-fs'; +import { statFile } from './fileStat'; const LOG_PATH = `${RNFS.DocumentDirectoryPath}/offgrid-debug.log`; /** Rotate (keep the tail) once the file exceeds this, so it can't grow forever. */ @@ -64,8 +65,8 @@ async function flush(): Promise { buffer = []; try { await RNFS.appendFile(LOG_PATH, chunk, 'utf8'); - const stat = await RNFS.stat(LOG_PATH).catch(() => null); - if (stat && Number(stat.size) > MAX_BYTES) { + const stat = await statFile(LOG_PATH); + if (stat && stat.size > MAX_BYTES) { // Keep only the most recent half so the file stays bounded but useful. const content = await RNFS.readFile(LOG_PATH, 'utf8').catch(() => ''); await RNFS.writeFile(LOG_PATH, content.slice(-Math.floor(MAX_BYTES / 2)), 'utf8').catch(() => {}); diff --git a/src/utils/fileStat.ts b/src/utils/fileStat.ts new file mode 100644 index 000000000..cb9c67cca --- /dev/null +++ b/src/utils/fileStat.ts @@ -0,0 +1,78 @@ +import RNFS from 'react-native-fs'; +import { sizeToBytes } from './fileSize'; + +/** + * What the filesystem knows about one path, asked in a way that cannot kill the app. + * + * `RNFS.stat` ABORTS the process on iOS for a path whose resource type cannot be resolved - which is + * what a stale absolute path is, and app container paths go stale on every reinstall. The native + * method builds its result dictionary and inserts the type unguarded: + * + * @{ @"ctime": …, @"mtime": …, @"size": …, @"type": [attrs objectForKey:NSURLFileResourceTypeKey] } + * + * `resourceValuesForKeys` OMITS a key it cannot determine rather than failing, so `type` arrives nil + * and NSDictionary raises `NSInvalidArgumentException: attempt to insert nil object from objects[3]`. + * The size directly above it has a nil guard; the type has none. + * + * A JS `try/catch` cannot save this. The exception is raised on the module's own queue and rethrown + * natively, so the process is gone before any promise settles - which is why `RNFS.stat(p).catch(…)` + * appears safe everywhere in this codebase and is not. It crashed the app three seconds after launch, + * on every launch, because the startup model scan stats each stored path. + * + * `readDir` answers the same question and is safe: it guards `attrs != nil`, defaults a missing size, + * and derives the type from booleans, so no value it inserts can be nil. This asks the PARENT for the + * entry instead of asking the path about itself. + */ +export interface FileFacts { + /** Bytes. Never a string, unlike the value RNFS reports. */ + size: number; + isFile: boolean; + isDirectory: boolean; + /** Milliseconds since the epoch, when the platform reported one. */ + mtimeMs?: number; +} + +/** `file:///a/b` and `/a/b` name the same thing; the filesystem wants the second. */ +function withoutScheme(path: string): string { + return path.startsWith('file://') ? decodeURIComponent(path.slice(7)) : path; +} + +function splitParent(path: string): { parent: string; name: string } | null { + const cleaned = withoutScheme(path).replace(/\/+$/, ''); + const cut = cleaned.lastIndexOf('/'); + if (cut <= 0) return null; + return { parent: cleaned.slice(0, cut), name: cleaned.slice(cut + 1) }; +} + +/** + * The facts about `path`, or null when it is not there. + * + * Null is the ANSWER for a missing file, not an error: every caller of the old `stat` had to guess + * whether a rejection meant "absent" or "broken", and most guessed by catching everything. + */ +export async function statFile(path: string): Promise { + const split = splitParent(path); + if (!split) return null; + try { + const entries = await RNFS.readDir(split.parent); + const entry = entries.find(item => item.name === split.name); + if (!entry) return null; + return { + // Through the one rule for a filesystem size. RNFS reports it as a number on one platform and + // a string on the other, and `sizeToBytes` already owns that difference. + size: sizeToBytes(entry.size), + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + ...(entry.mtime ? { mtimeMs: new Date(entry.mtime).getTime() } : {}), + }; + } catch { + // An unreadable or absent PARENT is also "not there", and is the common case for a stale + // container path - the whole directory went with the old install. + return null; + } +} + +/** The size in bytes, or 0 when the file is absent. For callers that only need the number. */ +export async function fileSizeBytes(path: string): Promise { + return (await statFile(path))?.size ?? 0; +} From 4f1ea02ae805e4ec15b4ae8cd9022139301096c7 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:02:12 +0530 Subject: [PATCH 020/229] fix(llm): every path that builds model input drops images that are not there A plain text message failed with "File does not exist or cannot be opened" because PHOTOS from earlier turns pointed into app containers that no longer exist. The runtime refuses the whole turn over one bad media path, and the model reads images from the entire conversation, not just the message sent. The existence check lived in `completion` - one caller of three. The tool path and the capped-token path converted messages directly, so stale attachments reached llama.rn there. A guard a caller has to remember is a guard the next caller forgets, so it now lives in the one conversion from our messages to model input, and no path can skip it. `isModelVisibleImage` is the single rule for what the model may see: not pending, and has a URI. Six call sites each filtered `type === 'image'` by hand, and a rule about model input has to hold at all of them or it holds nowhere. An announced-but-unarrived attachment has an empty URI, so without this the loader row became a media path pointing at nothing. A file still arriving is also no longer logged as "file gone" - saying a transfer in flight was deleted sends the next reader looking for something that never happened. --- .../unit/services/llmToolGeneration.test.ts | 5 ++- src/services/llm.ts | 37 +++++++++++++----- src/services/llmMessages.ts | 38 ++++++++++++++----- src/services/llmToolGeneration.ts | 5 ++- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/__tests__/unit/services/llmToolGeneration.test.ts b/__tests__/unit/services/llmToolGeneration.test.ts index 0aa604ad3..206da1537 100644 --- a/__tests__/unit/services/llmToolGeneration.test.ts +++ b/__tests__/unit/services/llmToolGeneration.test.ts @@ -31,7 +31,8 @@ function createMockDeps(overrides: Partial = {}): ToolGenera isGemma4Model: false, disableCtxShift: false, manageContextWindow: jest.fn(async (msgs: Message[]) => msgs), - convertToOAIMessages: jest.fn((msgs: Message[]) => + // Async, matching the real converter: it drops images whose file is gone before building. + convertToOAIMessages: jest.fn(async (msgs: Message[]) => msgs.map(m => ({ role: m.role, content: m.content })), ), setPerformanceStats: jest.fn(), @@ -220,7 +221,7 @@ describe('generateWithToolsImpl', () => { it('delegates to manageContextWindow and convertToOAIMessages', async () => { const managed = [createUserMessage('managed')]; const manageContextWindow = jest.fn(async () => managed); - const convertToOAIMessages = jest.fn(() => [{ role: 'user', content: 'managed' }]); + const convertToOAIMessages = jest.fn(async () => [{ role: 'user', content: 'managed' }]); const completion = jest.fn(async (_params: any, _cb: any) => ({})); const deps = createMockDeps({ diff --git a/src/services/llm.ts b/src/services/llm.ts index 9643fa6a6..28d72504c 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -1,6 +1,7 @@ import { LlamaContext, RNLlamaOAICompatibleMessage } from 'llama.rn'; import { Platform } from 'react-native'; import RNFS from 'react-native-fs'; +import { statFile } from '../utils/fileStat'; import { Message, INFERENCE_BACKENDS } from '../types'; import { APP_CONFIG } from '../constants'; import { useAppStore } from '../stores/appStore'; @@ -16,13 +17,14 @@ import { import { awaitMemoryReclaim, effectiveAvailableMB } from './memoryBudget'; import { modelResidencyManager } from './modelResidency'; import { hardwareService } from './hardware'; -import { formatLlamaMessages, buildOAIMessages } from './llmMessages'; +import { formatLlamaMessages, buildOAIMessages, modelImageAttachments } from './llmMessages'; import { generateWithToolsImpl } from './llmToolGeneration'; import type { ToolCall } from './tools/types'; import type { MultimodalSupport, LLMPerformanceSettings, LLMPerformanceStats } from './llmTypes'; import logger from '../utils/logger'; import { resolveSpeculative } from './mtpDetection'; import type { StreamToken } from './llmStreamTypes'; +import { sizeToBytes } from '../utils/fileSize'; export type { StreamToken }; type StreamCallback = (data: StreamToken) => void; type CompleteCallback = (result: { content: string; reasoningContent: string }) => void; @@ -73,8 +75,7 @@ class LLMService { const speculativeDecoding = await resolveSpeculative(modelPath, settings.speculativeDecoding); const params = buildModelParams(modelPath, { ...settings, nThreads: effectiveNThreads, speculativeDecoding }); logger.log(`[LLM] Resolved params: threads=${params.nThreads}, batch=${params.nBatch}, ctx=${params.ctxLen}, gpuLayers=${params.nGpuLayers}`); - const fileStat = await RNFS.stat(modelPath); - const fileSize = typeof fileStat.size === 'string' ? Number.parseInt(fileStat.size, 10) : fileStat.size; + const fileSize = (await statFile(modelPath))?.size ?? 0; // Use the EFFECTIVE cache type, not the raw setting: OpenCL/HTP coerce the KV cache // to f16 (see buildModelParams), so keying off settings.cacheType alone would let the // guard use the cheaper quantized estimate and approve a context that then OOMs. @@ -216,7 +217,7 @@ class LLMService { /** Multimodal init on a NOT-YET-PUBLISHED context (the load pipeline) — no instance-state writes. */ private async deriveMultimodalFromProjector(context: LlamaContext, modelPath: string, mmProjPath: string): Promise<{ initialized: boolean; support: MultimodalSupport }> { try { - const sizeMB = Number((await RNFS.stat(mmProjPath)).size) / (1024 * 1024); + const sizeMB = ((await statFile(mmProjPath))?.size ?? 0) / (1024 * 1024); logger.log(`[LLM] mmproj file size: ${sizeMB.toFixed(1)} MB`); if (sizeMB < 100) console.warn(`[LLM] WARNING: mmproj file seems too small (${sizeMB.toFixed(1)} MB)`); } catch (statErr) { console.error('[LLM] Failed to stat mmproj file:', statErr); } @@ -288,11 +289,11 @@ class LLMService { this.isGenerating = true; const ctx = this.context; const completionWork = (async () => { - const managed = await this.dropMissingImageAttachments(await this.manageContextWindow(messages)); - const hasImages = managed.some(m => m.attachments?.some(a => a.type === 'image')); + const managed = await this.manageContextWindow(messages); + const hasImages = managed.some(m => modelImageAttachments(m.attachments).length > 0); if (hasImages && !this.multimodalInitialized) logger.warn('[LLM] Images attached but multimodal not initialized - falling back to text-only'); logger.log('[LLM] Generation mode:', this.hasVisionInputs(managed) ? 'VISION' : 'TEXT-ONLY'); - const oaiMessages = this.convertToOAIMessages(managed); + const oaiMessages = await this.convertToOAIMessages(managed); const { settings } = useAppStore.getState(); const startTime = Date.now(); let firstTokenMs = 0, tokenCount = 0, firstReceived = false; @@ -375,6 +376,9 @@ class LLMService { const kept: typeof attachments = []; for (const a of attachments) { if (a.type !== 'image') { kept.push(a); continue; } + // Not yet ARRIVED is not the same as gone, and saying "file gone" for a transfer still in + // flight sends the next person looking for a deletion that never happened. + if (a.pending) { logger.log(`[LLM] skipping an attachment still arriving: ${a.fileName ?? a.id}`); continue; } const path = (a.uri || '').replace(/^file:\/\//, ''); const exists = path.length > 0 && await RNFS.exists(path).catch(() => false); if (exists) kept.push(a); @@ -397,14 +401,14 @@ class LLMService { */ private hasVisionInputs(messages: Message[]): boolean { if (!this.multimodalInitialized) return false; - return messages.some(m => m.attachments?.some(a => a.type === 'image')); + return messages.some(m => modelImageAttachments(m.attachments).length > 0); } /** Generate a completion with a hard token cap (used for summarization, not user-facing). */ async generateWithMaxTokens(messages: Message[], maxTokens: number): Promise { if (!this.context) throw new Error('No model loaded'); if (this.isGenerating) throw new Error('Generation already in progress'); this.isGenerating = true; - const oaiMessages = this.convertToOAIMessages(messages); + const oaiMessages = await this.convertToOAIMessages(messages); const { settings } = useAppStore.getState(); let fullResponse = ''; const ctx = this.context; @@ -459,7 +463,20 @@ class LLMService { } isCurrentlyGenerating(): boolean { return this.isGenerating; } private formatMessages(messages: Message[]): string { return formatLlamaMessages(messages, this.supportsVision(), this.multimodalSupport?.audio ?? false); } - private convertToOAIMessages(messages: Message[]): RNLlamaOAICompatibleMessage[] { return buildOAIMessages(messages, this.multimodalSupport?.audio ?? false); } + /** + * The ONE conversion from our messages to model input, and therefore the one place that can + * guarantee every image handed to the runtime exists. + * + * The existence check used to live in `completion`, one caller of three. The tool path and the + * capped-token path converted directly, so a stale attachment reached llama.rn there and it + * refused the whole turn with "File does not exist or cannot be opened" - a plain text message + * failed because a PHOTO from an earlier turn pointed into an app container that no longer exists. + * A guard a caller has to remember is a guard the next caller forgets, so it lives here now. + */ + private async convertToOAIMessages(messages: Message[]): Promise { + const usable = await this.dropMissingImageAttachments(messages); + return buildOAIMessages(usable, this.multimodalSupport?.audio ?? false); + } async getModelInfo() { return this.context ? { contextLength: APP_CONFIG.maxContextLength, vocabSize: 0 } : null; } async tokenize(text: string) { if (!this.context) throw new Error('No model loaded'); diff --git a/src/services/llmMessages.ts b/src/services/llmMessages.ts index 3b7d170b2..e2d61ca1e 100644 --- a/src/services/llmMessages.ts +++ b/src/services/llmMessages.ts @@ -15,6 +15,29 @@ function modelAudioAttachments(_attachments: MediaAttachment[] | undefined): Med return []; } +/** + * The image attachments the MODEL may see. + * + * An attachment can exist on a message before its bytes do: a peer ANNOUNCES a file, the row is drawn + * with a loader, and its `uri` is empty until the transfer lands. Handing that to the runtime is a + * media path pointing at nothing, and llama.rn refuses the whole turn with "File does not exist or + * cannot be opened" - so one unfinished transfer broke every generation in the conversation. + * + * Asked in one place for the same reason `modelAudioAttachments` is: five call sites each filtered + * `type === 'image'` by hand, and a rule about what the model may see has to hold at all five or it + * holds nowhere. An empty `uri` is refused too, so a row that lost its file cannot reach the runtime + * either. + */ +export function isModelVisibleImage(attachment: MediaAttachment): boolean { + return ( + attachment.type === 'image' && !attachment.pending && !!attachment.uri + ); +} + +export function modelImageAttachments(attachments: MediaAttachment[] | undefined): MediaAttachment[] { + return (attachments ?? []).filter(isModelVisibleImage); +} + export function formatLlamaMessages(messages: Message[], supportsVision: boolean, supportsAudio = false): string { let prompt = ''; for (const message of messages.filter(m => !m.isSystemInfo)) { @@ -24,7 +47,7 @@ export function formatLlamaMessages(messages: Message[], supportsVision: boolean let content = message.content; if (message.attachments && message.attachments.length > 0) { const imageMarkers = supportsVision - ? message.attachments.filter(a => a.type === 'image').map(() => '<__media__>').join('') + ? modelImageAttachments(message.attachments).map(() => '<__media__>').join('') : ''; const audioMarkers = supportsAudio ? modelAudioAttachments(message.attachments).map(() => '<__media__>').join('') @@ -43,12 +66,8 @@ export function formatLlamaMessages(messages: Message[], supportsVision: boolean export function extractImageUris(messages: Message[]): string[] { const uris: string[] = []; for (const message of messages) { - if (message.attachments) { - for (const attachment of message.attachments) { - if (attachment.type === 'image') { - uris.push(attachment.uri); - } - } + for (const attachment of modelImageAttachments(message.attachments)) { + uris.push(attachment.uri); } } return uris; @@ -71,7 +90,7 @@ function toFileUrl(uri: string, requireFilePrefix = false): string { function buildMediaParts(message: Message, supportsAudio: boolean): RNLlamaMessagePart[] { const parts: RNLlamaMessagePart[] = []; - for (const a of message.attachments?.filter(att => att.type === 'image') ?? []) { + for (const a of modelImageAttachments(message.attachments)) { parts.push({ type: 'image_url', image_url: { url: toFileUrl(a.uri) } }); } if (supportsAudio) { @@ -93,7 +112,8 @@ export function buildOAIMessages(messages: Message[], supportsAudio = false): RN const toolCallText = message.toolCalls.map(formatToolCallAsText).join('\n'); return { role: 'assistant' as const, content: message.content ? `${message.content}\n${toolCallText}` : toolCallText }; } - const hasImage = message.role === 'user' && message.attachments?.some(a => a.type === 'image'); + const hasImage = + message.role === 'user' && modelImageAttachments(message.attachments).length > 0; const hasAudio = supportsAudio && message.role === 'user' && modelAudioAttachments(message.attachments).length > 0; if (!hasImage && !hasAudio) return { role: message.role, content: message.content }; return { role: message.role, content: buildMediaParts(message, supportsAudio) }; diff --git a/src/services/llmToolGeneration.ts b/src/services/llmToolGeneration.ts index 1f85da96f..960b919cc 100644 --- a/src/services/llmToolGeneration.ts +++ b/src/services/llmToolGeneration.ts @@ -128,7 +128,8 @@ export interface ToolGenerationDeps { isGemma4Model: boolean; disableCtxShift: boolean; manageContextWindow: (messages: Message[], extraReserve?: number) => Promise; - convertToOAIMessages: (messages: Message[]) => any[]; + /** Async because it also drops images whose file is gone — see LLMService.convertToOAIMessages. */ + convertToOAIMessages: (messages: Message[]) => Promise; setPerformanceStats: (stats: any) => void; setIsGenerating: (v: boolean) => void; } @@ -149,7 +150,7 @@ export async function generateWithToolsImpl( // Reserve context space for tool schemas (~100 tokens per tool) const toolTokenReserve = options.tools.length * 100; const managed = await deps.manageContextWindow(messages, toolTokenReserve); - const oaiMessages = deps.convertToOAIMessages(managed); + const oaiMessages = await deps.convertToOAIMessages(managed); const { settings } = useAppStore.getState(); const startTime = Date.now(); let firstTokenMs = 0; From d297b19ff5b2643a368a51f8af8f0e2336832385 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:02:23 +0530 Subject: [PATCH 021/229] feat(chat): a file still arriving shows on its own message bubble A synced file is announced before it is sent, and the gap showed nothing at all - so a generated image on its way from another device was indistinguishable from one that was never coming, and the only way to learn which was to restart the app. The row renders the three-dot loader and the real file name from the announcement. Checked BEFORE every other branch, because a pending attachment has no local file and each branch below reads `uri`. The loader is the shared one, imported directly rather than through the barrel. --- .../components/MessageAttachments.tsx | 34 +++++++++++++++++- src/types/index.ts | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/components/ChatMessage/components/MessageAttachments.tsx b/src/components/ChatMessage/components/MessageAttachments.tsx index bf59727a9..f931bc144 100644 --- a/src/components/ChatMessage/components/MessageAttachments.tsx +++ b/src/components/ChatMessage/components/MessageAttachments.tsx @@ -12,6 +12,9 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Feather'; +// Imported directly, not through the barrel: a component that reaches its sibling via the index +// resolves undefined at render time. +import { LoadingDots } from '../../LoadingDots'; import { MediaAttachment } from '../../../types'; import { viewDocument } from '@react-native-documents/viewer'; import logger from '../../../utils/logger'; @@ -90,7 +93,36 @@ export function MessageAttachments({ return ( {attachments.map((attachment, index) => - attachment.type === 'audio' ? ( + // Announced, not yet here. Checked FIRST, before any branch that reads `uri`: a pending + // attachment has no local file, and every branch below assumes one. The name and size come + // from the announcement, so the row reads as the file it will become. + attachment.pending ? ( + + + + {attachment.fileName || 'Arriving'} + + + ) : attachment.type === 'audio' ? ( Date: Thu, 13 Aug 2026 10:02:44 +0530 Subject: [PATCH 022/229] fix(ui): one loader everywhere, and it is never a ring spinner The animation had two homes: inside ThinkingIndicator, and a platform ActivityIndicator inside Button. A ring spinner on a button reads as a retry glyph rather than work in progress, so pairing a device and sharing a file both looked like they had failed the moment they started. Every busy state now renders the one component, and a button does not change height when it flips to loading. --- src/components/Button.tsx | 32 +++++++---- src/components/CustomAlert.tsx | 4 +- src/components/LoadingDots.tsx | 85 ++++++++++++++++++++++++++++ src/components/ThinkingIndicator.tsx | 59 ++----------------- 4 files changed, 114 insertions(+), 66 deletions(-) create mode 100644 src/components/LoadingDots.tsx diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 3e4646a8d..a78230572 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { - TouchableOpacity, - Text, - ActivityIndicator, - ViewStyle, - TextStyle, -} from 'react-native'; +import { TouchableOpacity, Text, ViewStyle, TextStyle } from 'react-native'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; import { SPACING, TYPOGRAPHY } from '../constants'; +import { LoadingDots } from './LoadingDots'; + +/** Height of a rendered text line as a multiple of its font size, on both platforms. */ +const LOADER_LINE_BOX = 1.4; interface ButtonProps { title: string; @@ -68,9 +66,11 @@ export const Button: React.FC = ({ testID={testID} > {loading ? ( - ) : ( <> @@ -165,4 +165,16 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ text_disabled: { color: colors.textDisabled, }, + // A 6pt row of dots is shorter than the label it replaces, so a button would shrink the + // instant it started working. The loader claims the same line box as the text it stands in + // for - same token font size, same 1.4 line-box ratio - so the button holds its height. + loader_small: { + minHeight: TYPOGRAPHY.h3.fontSize * LOADER_LINE_BOX, + }, + loader_medium: { + minHeight: TYPOGRAPHY.body.fontSize * LOADER_LINE_BOX, + }, + loader_large: { + minHeight: TYPOGRAPHY.h2.fontSize * LOADER_LINE_BOX, + }, }); diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index e18e891cb..a084f232a 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -3,8 +3,8 @@ import { View, Text, TouchableOpacity, - ActivityIndicator, } from 'react-native'; +import { LoadingDots } from './LoadingDots'; import { AppSheet } from './AppSheet'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; @@ -58,7 +58,7 @@ export const CustomAlert: React.FC = ({ > {loading ? ( - + ) : null} {message ? {message} : null} diff --git a/src/components/LoadingDots.tsx b/src/components/LoadingDots.tsx new file mode 100644 index 000000000..224864664 --- /dev/null +++ b/src/components/LoadingDots.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, Animated, ViewStyle } from 'react-native'; +import { useTheme } from '../theme'; + +interface LoadingDotsProps { + /** Dot colour. Defaults to the accent, which is what a surface uses on its own background. */ + color?: string; + /** Diameter in points. The dots stay circular at any size. */ + size?: number; + style?: ViewStyle; + testID?: string; +} + +/** + * The three-dot busy animation - the ONE loader in this app. It exists as its own component + * because it had two homes: the animation inside ThinkingIndicator, and a platform + * ActivityIndicator inside Button. A ring spinner on a button reads as a retry glyph, not as + * work in progress, so a paired device and a shared file both looked like they had failed. + * Every busy state renders this, and the animation is defined once. + */ +export const LoadingDots: React.FC = ({ + color, + size = 6, + style, + testID, +}) => { + const { colors } = useTheme(); + const dot1Anim = useRef(new Animated.Value(0.3)).current; + const dot2Anim = useRef(new Animated.Value(0.3)).current; + const dot3Anim = useRef(new Animated.Value(0.3)).current; + + useEffect(() => { + const duration = 400; + // Each dot runs the same fade, offset by 150ms, so the brightness travels left to right. + const loops = [dot1Anim, dot2Anim, dot3Anim].map((anim, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 150), + Animated.timing(anim, { toValue: 1, duration, useNativeDriver: true }), + Animated.timing(anim, { + toValue: 0.3, + duration, + useNativeDriver: true, + }), + ]), + ), + ); + loops.forEach(loop => loop.start()); + + return () => loops.forEach(loop => loop.stop()); + }, [dot1Anim, dot2Anim, dot3Anim]); + + const dotStyle = { + width: size, + height: size, + borderRadius: size / 2, + backgroundColor: color ?? colors.primary, + }; + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + dots: { + flexDirection: 'row', + alignItems: 'center', + // The dots are a fixed width. Without this they give up width to a sibling label on a + // narrow screen and the animation collapses. + flexShrink: 0, + }, + dot: { + marginHorizontal: 2, + }, +}); diff --git a/src/components/ThinkingIndicator.tsx b/src/components/ThinkingIndicator.tsx index c6cfed622..b5b1176df 100644 --- a/src/components/ThinkingIndicator.tsx +++ b/src/components/ThinkingIndicator.tsx @@ -1,62 +1,23 @@ -import React, { useEffect, useRef } from 'react'; -import { View, Text, StyleSheet, Animated } from 'react-native'; +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; import { useTheme } from '../theme'; +import { LoadingDots } from './LoadingDots'; interface ThinkingIndicatorProps { text?: string; textStyle?: any; } +/** The three-dot loader with a label beside it. The dots themselves live in LoadingDots. */ export const ThinkingIndicator: React.FC = ({ text = 'Thinking...', textStyle }) => { const { colors } = useTheme(); - const dot1Anim = useRef(new Animated.Value(0.3)).current; - const dot2Anim = useRef(new Animated.Value(0.3)).current; - const dot3Anim = useRef(new Animated.Value(0.3)).current; - - useEffect(() => { - const duration = 400; - const sequence = Animated.loop( - Animated.sequence([ - Animated.timing(dot1Anim, { toValue: 1, duration, useNativeDriver: true }), - Animated.timing(dot1Anim, { toValue: 0.3, duration, useNativeDriver: true }), - ]) - ); - const sequence2 = Animated.loop( - Animated.sequence([ - Animated.delay(150), - Animated.timing(dot2Anim, { toValue: 1, duration, useNativeDriver: true }), - Animated.timing(dot2Anim, { toValue: 0.3, duration, useNativeDriver: true }), - ]) - ); - const sequence3 = Animated.loop( - Animated.sequence([ - Animated.delay(300), - Animated.timing(dot3Anim, { toValue: 1, duration, useNativeDriver: true }), - Animated.timing(dot3Anim, { toValue: 0.3, duration, useNativeDriver: true }), - ]) - ); - sequence.start(); - sequence2.start(); - sequence3.start(); - - return () => { - sequence.stop(); - sequence2.stop(); - sequence3.stop(); - }; - - }, []); return ( - - - - - + {text} ); @@ -68,17 +29,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, thinkingDots: { - flexDirection: 'row', marginRight: 8, - // The dots are a fixed 30pt. Without this they give up width to the text on a narrow - // screen and the animation collapses. - flexShrink: 0, - }, - thinkingDot: { - width: 6, - height: 6, - borderRadius: 3, - marginHorizontal: 2, }, thinkingText: { fontSize: 12, From 3dea1a091638ddd7cc985e9460cd34c2f9a53754 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:02:44 +0530 Subject: [PATCH 023/229] fix(fs): one rule for reading a file size off the filesystem `RNFS.stat` and `readDir` report a size as a NUMBER on one platform and a STRING on the other. Ten call sites had each written their own ternary for that - ten chances to get a byte count wrong in a place the user reads it: a size, a free-space check, a "does this file match its manifest" guard. --- src/utils/fileSize.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/utils/fileSize.ts diff --git a/src/utils/fileSize.ts b/src/utils/fileSize.ts new file mode 100644 index 000000000..081ea512f --- /dev/null +++ b/src/utils/fileSize.ts @@ -0,0 +1,19 @@ +/** + * One rule for reading a file size off the filesystem. + * + * `RNFS.stat` and `RNFS.readDir` report a size as a NUMBER on one platform and a STRING on the + * other. Ten call sites had each written their own ternary for that, which is ten chances to get a + * byte count wrong in a place the user reads it - a size, a free-space check, a "does this file + * match its manifest" guard. One platform difference deserves one answer. + * + * @param size the raw size a filesystem API reported + * @param fallback what an absent size means to the caller (0 for a running total) + */ +export function sizeToBytes( + size: string | number | undefined | null, + fallback = 0, +): number { + if (typeof size === 'number') return size; + if (typeof size === 'string') return Number.parseInt(size, 10); + return fallback; +} From f8d6d6d89f00be11d346164c7b3395ccc4b9f3fe Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:02:58 +0530 Subject: [PATCH 024/229] feat(vision): repair a model's missing projector from the chat that needs it A vision model transferred from another device can arrive carrying its vision tag and no projector: it advertises sight it does not have, the composer refuses the photo, and nothing on screen explains why. The only repair lived in a screen the user had no reason to open, and it answered with a raw 401. The chat now offers the repair where the refusal happens, and both surfaces read one message rule so they cannot describe the same model differently. A model with no upstream says so - an imported model has no repo to fetch from, and saying that is correct. Several matching repos refuse rather than guess: a projector from a different quantisation loads and then reads images wrongly. A repo path is now read from where the model came from rather than rebuilt out of its display id. --- src/components/ModelCardContent.tsx | 5 +- src/components/VisionRepairAdviceCard.tsx | 147 +++++++++++ src/components/index.ts | 2 + src/screens/ChatScreen/ChatMessageArea.tsx | 244 ++++++++++++------ .../ChatScreen/ChatScreenComponents.tsx | 4 +- src/services/huggingface.ts | 43 +++ src/services/modelManager/download.ts | 5 +- src/services/modelManager/downloadHelpers.ts | 3 +- src/services/modelManager/importLocalModel.ts | 10 +- src/services/modelManager/index.ts | 125 +++------ src/services/modelManager/storage.ts | 118 +++++++-- .../modelManager/visionRepairMessage.ts | 53 ++++ .../modelManager/visionRepairService.ts | 205 +++++++++++++++ .../modelManager/visionRepairSource.ts | 75 ++++++ src/utils/modelHelpers.ts | 11 + src/utils/modelOrigin.ts | 30 +++ src/utils/visionRepair.ts | 15 +- 17 files changed, 880 insertions(+), 215 deletions(-) create mode 100644 src/components/VisionRepairAdviceCard.tsx create mode 100644 src/services/modelManager/visionRepairMessage.ts create mode 100644 src/services/modelManager/visionRepairService.ts create mode 100644 src/services/modelManager/visionRepairSource.ts create mode 100644 src/utils/modelOrigin.ts diff --git a/src/components/ModelCardContent.tsx b/src/components/ModelCardContent.tsx index a5c5c1862..0b5434557 100644 --- a/src/components/ModelCardContent.tsx +++ b/src/components/ModelCardContent.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { LoadingDots } from './LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import { useThemedStyles, useTheme } from '../theme'; @@ -395,7 +396,7 @@ function DownloadedActions({ isActive, testID, colors, styles, onSelect, onDelet <> {isRepairingVision ? ( - + ) : ( onRepairVision && diff --git a/src/components/VisionRepairAdviceCard.tsx b/src/components/VisionRepairAdviceCard.tsx new file mode 100644 index 000000000..04ba7b15a --- /dev/null +++ b/src/components/VisionRepairAdviceCard.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { View, Text } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../theme'; +import type { ThemeColors } from '../theme'; +import { TYPOGRAPHY, SPACING } from '../constants'; +import { AnimatedPressable } from './AnimatedPressable'; +import { LoadingDots } from './LoadingDots'; +import { useAppStore } from '../stores'; +import { modelManager } from '../services'; +import { needsVisionRepair } from '../utils/visionRepair'; +import { visionRepairMessage } from '../services/modelManager/visionRepairMessage'; + +/** + * In-chat notice: the model you are talking to was built to read images and cannot right now. + * + * It lives in the CHAT because that is where the loss is discovered. A vision model whose projector + * is missing still carries its vision label, so the composer refuses the attachment while the model + * looks capable - and the repair sits in the Download Manager, which nobody visits to solve a + * problem they have just been told does not exist. The user's own words: they may not know it can + * be fixed from there at all. + * + * Repair happens HERE rather than sending the user somewhere else, and the outcome is reported + * through the shared message rule so this card and the Download Manager cannot describe the same + * event differently. + */ +export const VisionRepairAdviceCard: React.FC<{ onRepaired?: () => void }> = ({ + onRepaired, +}) => { + const styles = useThemedStyles(createStyles); + const { colors } = useTheme(); + const [dismissed, setDismissed] = useState(false); + const [repairing, setRepairing] = useState(false); + const [result, setResult] = useState(null); + const { downloadedModels, activeModelId, setDownloadedModels } = + useAppStore(); + + const activeModel = downloadedModels.find(m => m.id === activeModelId); + const broken = + activeModel?.engine === 'llama' && + needsVisionRepair({ + isVisionModel: activeModel.isVisionModel, + mmProjPath: activeModel.mmProjPath, + mmProjFileName: activeModel.mmProjFileName, + name: activeModel.name, + fileName: activeModel.fileName, + }); + + if (!broken || dismissed || !activeModel) return null; + + const repair = async (): Promise => { + setRepairing(true); + try { + const outcome = await modelManager.repairVision(activeModel); + setDownloadedModels(await modelManager.getDownloadedModels()); + const [, body] = visionRepairMessage(outcome, activeModel.name); + setResult(body); + // Only a repair that actually landed is worth reloading for; the other outcomes leave the + // model exactly as it was and the card keeps its explanation on screen. + if (outcome.kind === 'repaired' || outcome.kind === 'linked') + onRepaired?.(); + } catch (e) { + setResult(e instanceof Error ? e.message : 'Could not repair vision.'); + } finally { + setRepairing(false); + } + }; + + return ( + + + + This model can't see images + setDismissed(true)} + hitSlop={8} + accessibilityLabel="Dismiss" + testID="vision-repair-advice-dismiss" + > + + + + + Its vision file is missing. Get it without re-downloading the model. + + {result ? ( + + {result} + + ) : ( + + {repairing ? ( + + ) : ( + + )} + + {repairing ? 'Fetching…' : 'Get vision file'} + + + )} + + ); +}; + +const createStyles = (colors: ThemeColors) => ({ + card: { + marginHorizontal: SPACING.md, + marginBottom: SPACING.sm, + padding: SPACING.md, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + headerRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + marginBottom: SPACING.xs, + }, + leadIcon: { marginRight: SPACING.xs }, + title: { ...TYPOGRAPHY.h3, color: colors.text, flex: 1 }, + intro: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + marginBottom: SPACING.sm, + }, + action: { flexDirection: 'row' as const, alignItems: 'center' as const }, + tipIcon: { marginRight: SPACING.xs }, + actionText: { ...TYPOGRAPHY.meta, color: colors.primary, flex: 1 }, + result: { ...TYPOGRAPHY.meta, color: colors.textSecondary }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index 82642a027..3b6fae0d5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,8 @@ export { ModelFailureCard } from './ModelFailureCard'; export { ImageGenAdviceCard } from './ImageGenAdviceCard'; export { MtpAdviceCard } from './MtpAdviceCard'; export { ThinkingIndicator } from './ThinkingIndicator'; +export { LoadingDots } from './LoadingDots'; +export { VisionRepairAdviceCard } from './VisionRepairAdviceCard'; ; ; ; diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index 768b79797..e20c089d0 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -1,11 +1,25 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; -import { View, FlatList, Text, Keyboard, Platform, StyleSheet } from 'react-native'; +import { + View, + FlatList, + Text, + Keyboard, + Platform, + StyleSheet, +} from 'react-native'; import { useUiModeStore } from '../../stores/uiModeStore'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useKeyboardVisible } from '../../hooks/useKeyboardVisible'; import Icon from 'react-native-vector-icons/Feather'; import Animated, { FadeIn } from 'react-native-reanimated'; -import { ChatInput, ThinkingIndicator, ModelFailureCard, ImageGenAdviceCard, MtpAdviceCard } from '../../components'; +import { + ChatInput, + ThinkingIndicator, + ModelFailureCard, + ImageGenAdviceCard, + MtpAdviceCard, + VisionRepairAdviceCard, +} from '../../components'; import { AnimatedPressable } from '../../components/AnimatedPressable'; import { generationService } from '../../services'; import { EmptyChat, ImageProgressIndicator } from './ChatScreenComponents'; @@ -47,7 +61,10 @@ const FOOTER_SAFE_CAP = 4; // Home-indicator / gesture-nav overlays sit at ~24px or below on the devices we // target; a 3-button nav bar is taller. Above this, treat the inset as opaque. const OVERLAY_INSET_MAX = 24; -export const computeFooterPaddingBottom = (keyboardVisible: boolean, insetBottom: number): number => { +export const computeFooterPaddingBottom = ( + keyboardVisible: boolean, + insetBottom: number, +): number => { if (keyboardVisible) return 0; // Opaque nav bar (tall inset): pad the full inset so controls clear it. if (insetBottom > OVERLAY_INSET_MAX) return insetBottom; @@ -60,8 +77,11 @@ export const computeFooterPaddingBottom = (keyboardVisible: boolean, insetBottom // After a completed turn (text or image), the last message is an assistant reply, so // the bar hides — it read as misplaced when it lingered after a finished image turn. // Checking the tail role is modality-agnostic: any completed turn ends with 'assistant'. -export const shouldShowEvictedBar = (chat: ReturnType): boolean => { - if (!chat.textModelEvicted || chat.isModelLoading || chat.isCompacting) return false; +export const shouldShowEvictedBar = ( + chat: ReturnType, +): boolean => { + if (!chat.textModelEvicted || chat.isModelLoading || chat.isCompacting) + return false; if (chat.isGeneratingImage) return false; if (!chat.activeModelId || chat.activeModelInfo?.isRemote) return false; const last = chat.displayMessages[chat.displayMessages.length - 1]; @@ -70,15 +90,20 @@ export const shouldShowEvictedBar = (chat: ReturnType): bo // "Model unloaded to free memory — tap to continue": the active text model was evicted // (e.g. an image/TTS load in voice mode) but stays selected. Tapping reloads it. -const ModelEvictedBar: React.FC<{ visible: boolean; onPress: () => void; styles: any; colors: any }> = ({ - visible, onPress, styles, colors, -}) => { +const ModelEvictedBar: React.FC<{ + visible: boolean; + onPress: () => void; + styles: any; + colors: any; +}> = ({ visible, onPress, styles, colors }) => { if (!visible) return null; return ( - Model unloaded to free memory — tap to continue + + Model unloaded to free memory — tap to continue + @@ -87,9 +112,12 @@ const ModelEvictedBar: React.FC<{ visible: boolean; onPress: () => void; styles: // Small status bar above the input: classifying takes precedence over the // background model-load indicator. -const ModelStatusBar: React.FC<{ loading: boolean; classifying: boolean; modelName?: string; styles: any }> = ({ - loading, classifying, modelName, styles, -}) => { +const ModelStatusBar: React.FC<{ + loading: boolean; + classifying: boolean; + modelName?: string; + styles: any; +}> = ({ loading, classifying, modelName, styles }) => { // The app's OWN "working" indicator, not the platform ActivityIndicator. On Android that renders a // Material arc whose tapered end reads as a static rotate glyph — the bar looked like it was // offering a retry button rather than telling you a model was loading (device-confirmed). The @@ -98,7 +126,10 @@ const ModelStatusBar: React.FC<{ loading: boolean; classifying: boolean; modelNa if (classifying) { return ( - + ); } @@ -116,12 +147,18 @@ const ModelStatusBar: React.FC<{ loading: boolean; classifying: boolean; modelNa }; export const ChatMessageArea: React.FC = ({ - flatListRef, isNearBottomRef, chat, styles, colors, handleScroll, renderItem, + flatListRef, + isNearBottomRef, + chat, + styles, + colors, + handleScroll, + renderItem, }) => { // Hide FlatList until initial layout + scroll is complete to prevent visible scroll jump const [isListReady, setIsListReady] = useState(false); const hasScrolledRef = React.useRef(false); - const interfaceMode = useUiModeStore((s) => s.interfaceMode); + const interfaceMode = useUiModeStore(s => s.interfaceMode); const tabNav = useNavigation>(); const { toolCountHintDismissed } = useAppStore(); // Subscribe to Pro activation so this re-renders the moment a license is @@ -133,16 +170,25 @@ export const ChatMessageArea: React.FC = ({ useIsProActive(); // extToolCount is the live MCP tool count (the email/calendar extension reports 0 // here because those live in settings.enabledTools — see EmailCalendarExtension). - const extToolCount = getToolExtensions().reduce((n, e) => n + e.enabledToolCount(), 0); + const extToolCount = getToolExtensions().reduce( + (n, e) => n + e.enabledToolCount(), + 0, + ); // Pro tools (email/calendar) are toggled through settings.enabledTools, so count // how many of them are on and fold MCP in — this is the "Pro Tools" badge. - const proToolIds = getToolExtensions().flatMap(e => (e.getToolDefinitions?.() ?? []).map(t => t.id)); - const proToolsActiveCount = proToolIds.filter(id => chat.enabledTools.includes(id)).length; + const proToolIds = getToolExtensions().flatMap(e => + (e.getToolDefinitions?.() ?? []).map(t => t.id), + ); + const proToolsActiveCount = proToolIds.filter(id => + chat.enabledTools.includes(id), + ).length; const proToolsCount = proToolsActiveCount + extToolCount; // The free Tools page lists only AVAILABLE_TOOLS, so its badge counts just those // (pro email/calendar ids are surfaced under Pro Tools instead, not double-counted). const freeToolIds = new Set(AVAILABLE_TOOLS.map(t => t.id)); - const freeToolsCount = chat.enabledTools.filter(id => freeToolIds.has(id)).length; + const freeToolsCount = chat.enabledTools.filter(id => + freeToolIds.has(id), + ).length; const totalToolCount = freeToolsCount + proToolsCount; const handleProToolsPress = useOpenProTools(); const showSettingsDot = totalToolCount > 3 && !toolCountHintDismissed; @@ -157,13 +203,19 @@ export const ChatMessageArea: React.FC = ({ // Platform.OS layout branching. const insets = useSafeAreaInsets(); const keyboardVisible = useKeyboardVisible(); - const footerPaddingBottom = computeFooterPaddingBottom(keyboardVisible, insets.bottom); + const footerPaddingBottom = computeFooterPaddingBottom( + keyboardVisible, + insets.bottom, + ); const isStreaming = chat.isStreaming || chat.isThinking; const prevIsStreamingRef = useRef(isStreaming); useEffect(() => { prevIsStreamingRef.current = isStreaming; }, [isStreaming]); - const activeModelRepoId = chat.activeModelId?.split('/').slice(0, 2).join('/'); + const activeModelRepoId = chat.activeModelId + ?.split('/') + .slice(0, 2) + .join('/'); const handleRepairVision = activeModelRepoId ? () => tabNav.navigate('DownloadManager') : undefined; @@ -178,14 +230,17 @@ export const ChatMessageArea: React.FC = ({ // builds / chat mode fall back to the standard empty chat. (() => { const AudioEmpty = getSlot(SLOTS.chatEmptyAudio); - return AudioEmpty && interfaceMode === 'audio' ? : ( - + return AudioEmpty && interfaceMode === 'audio' ? ( + + ) : ( + ); })() ) : ( @@ -194,7 +249,7 @@ export const ChatMessageArea: React.FC = ({ style={isListReady ? undefined : hiddenStyle.hidden} data={chat.displayMessages} renderItem={renderItem} - keyExtractor={(item) => item.id} + keyExtractor={item => item.id} extraData={interfaceMode} contentContainerStyle={styles.messageList} onScroll={handleScroll} @@ -211,32 +266,46 @@ export const ChatMessageArea: React.FC = ({ flatListRef.current?.scrollToEnd({ animated: false }); } }} - onLayout={(e) => { + onLayout={e => { const newHeight = e.nativeEvent.layout.height; const prevHeight = flatListHeightRef.current; flatListHeightRef.current = newHeight; if (prevHeight > 0 && newHeight < prevHeight) { - setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 50); + setTimeout( + () => flatListRef.current?.scrollToEnd({ animated: true }), + 50, + ); } }} scrollEventThrottle={16} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" onTouchStart={() => Keyboard.dismiss()} - maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 100 }} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 100, + }} removeClippedSubviews={Platform.OS !== 'android'} /> )} {chat.showScrollToBottom && chat.displayMessages.length > 0 && ( - - flatListRef.current?.scrollToEnd({ animated: true })}> + + flatListRef.current?.scrollToEnd({ animated: true })} + > )} {chat.isGeneratingImage && ( = ({ styles={styles} /> {chat.isCompacting && ( - + )} @@ -269,17 +341,26 @@ export const ChatMessageArea: React.FC = ({ {/* Reload through the SAME seam the reload banner uses — one owner of "reload the text model". */} - {chat.hasPendingSettings && !chat.isCompacting && !chat.activeModelInfo?.isRemote && ( - - - - - Settings changed — tap to reload model - - - - - )} + {/* A vision model missing its projector: repairable from here, because this is where the + user finds out they cannot attach a photo. */} + + {chat.hasPendingSettings && + !chat.isCompacting && + !chat.activeModelInfo?.isRemote && ( + + + + + Settings changed — tap to reload model + + + + + )} {/* Text model evicted to free RAM (e.g. voice-mode image/TTS load) but still selected — reload it on demand, even a large model. This flat "tap to continue" snackbar sits directly above the composer, BELOW the rounded cards. */} @@ -290,39 +371,42 @@ export const ChatMessageArea: React.FC = ({ colors={colors} /> setInputHeight(e.nativeEvent.layout.height)} - style={{ backgroundColor: colors.background, paddingBottom: footerPaddingBottom }} + onLayout={e => setInputHeight(e.nativeEvent.layout.height)} + style={{ + backgroundColor: colors.background, + paddingBottom: footerPaddingBottom, + }} > - chat.setShowSettingsPanel(true)} - queueCount={chat.queueCount} - queuedTexts={chat.queuedTexts} - onClearQueue={() => generationService.clearQueue()} - placeholder={getPlaceholderText({ - hasModel: chat.hasActiveModel, - isModelLoading: chat.isModelLoading, - supportsVision: chat.supportsVision, - imageOnly: chat.imageModelLoaded && !chat.hasTextModel, - })} - onToolsPress={() => tabNav.navigate('Tools')} - enabledToolCount={freeToolsCount} - showSettingsDot={showSettingsDot} - mcpToolCount={proToolsCount} - onMcpPress={handleProToolsPress} - supportsToolCalling={chat.supportsToolCalling} - supportsThinking={chat.supportsThinking} - onRepairVision={handleRepairVision} - isRemote={chat.activeModelInfo.isRemote} - onImagePress={chat.handleImagePress} - /> + chat.setShowSettingsPanel(true)} + queueCount={chat.queueCount} + queuedTexts={chat.queuedTexts} + onClearQueue={() => generationService.clearQueue()} + placeholder={getPlaceholderText({ + hasModel: chat.hasActiveModel, + isModelLoading: chat.isModelLoading, + supportsVision: chat.supportsVision, + imageOnly: chat.imageModelLoaded && !chat.hasTextModel, + })} + onToolsPress={() => tabNav.navigate('Tools')} + enabledToolCount={freeToolsCount} + showSettingsDot={showSettingsDot} + mcpToolCount={proToolsCount} + onMcpPress={handleProToolsPress} + supportsToolCalling={chat.supportsToolCalling} + supportsThinking={chat.supportsThinking} + onRepairVision={handleRepairVision} + isRemote={chat.activeModelInfo.isRemote} + onImagePress={chat.handleImagePress} + /> ); diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx index 7aaf63da6..2fc8a1791 100644 --- a/src/screens/ChatScreen/ChatScreenComponents.tsx +++ b/src/screens/ChatScreen/ChatScreenComponents.tsx @@ -5,8 +5,8 @@ import { TouchableOpacity, Modal, Image, - ActivityIndicator, } from 'react-native'; +import { LoadingDots } from '../../components/LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ModelSelectorModal } from '../../components'; @@ -53,7 +53,7 @@ export const NoModelScreen: React.FC<{ // remain with no feedback — the user thinks nothing happened. Show a loading // indicator instead of the "Select Model" prompt. <> - + Loading Model Getting your model ready. This can take a moment. diff --git a/src/services/huggingface.ts b/src/services/huggingface.ts index 62d75f943..3448e5dd4 100644 --- a/src/services/huggingface.ts +++ b/src/services/huggingface.ts @@ -25,6 +25,49 @@ class HuggingFaceService { return results.map(this.transformModelResult); } + /** + * Repos that publish a file of this name, each reduced to its file list with exact byte sizes. + * + * This is a CANDIDATE generator, not an identification: several repos publish the same file + * name (`SmolVLM-500M-Instruct-GGUF` matches three, one an `i1` requantisation). The caller + * decides which is ours by comparing sizes - see resolveVisionRepairSource. + */ + async findReposPublishing( + fileName: string, + limit = 10, + ): Promise<{ repoId: string; files: { name: string; sizeBytes?: number }[] }[]> { + // The repo is usually named after the model, so the file name minus its quant/extension is the + // best query we have. `.gguf` and the trailing quant tag never appear in a repo id. + const query = fileName.replace(/\.gguf$/i, '').replace(/[._-](Q\d+[_\w]*|f16|f32|i1)$/i, ''); + const results = await this.searchModels(query, { limit }); + const listings = await Promise.all( + results.map(async result => ({ + repoId: result.id, + files: await this.listRepoFileSizes(result.id), + })), + ); + return listings.filter(listing => listing.files.length > 0); + } + + /** Every file in a repo with its exact size - the only field that identifies a build. */ + private async listRepoFileSizes( + modelId: string, + ): Promise<{ name: string; sizeBytes?: number }[]> { + try { + const result = await this.fetchJson<{ + siblings?: { rfilename: string; size?: number; lfs?: { size?: number } }[]; + }>(`${this.apiUrl}/models/${modelId}?blobs=true`); + return (result.siblings ?? []).map(sibling => ({ + name: sibling.rfilename, + sizeBytes: sibling.lfs?.size ?? sibling.size, + })); + } catch { + // An unreachable or private repo is simply not a candidate. HF answers an unknown repo with + // 401, so a throw here means "cannot confirm", never "this is the one". + return []; + } + } + async getModelDetails(modelId: string): Promise { const result = await this.fetchJson(`${this.apiUrl}/models/${modelId}`); return this.transformModelResult(result); diff --git a/src/services/modelManager/download.ts b/src/services/modelManager/download.ts index bad01613f..c407ffd39 100644 --- a/src/services/modelManager/download.ts +++ b/src/services/modelManager/download.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; import { ModelFile, BackgroundDownloadInfo } from '../../types'; import { huggingFaceService } from '../huggingface'; import { backgroundDownloadService } from '../backgroundDownloadService'; @@ -138,6 +139,7 @@ export { getOrphanedImageDirs, syncCompletedBackgroundDownloads, } from './downloadHelpers'; +import { sizeToBytes } from '../../utils/fileSize'; ; export interface PerformBackgroundDownloadOpts { @@ -174,8 +176,7 @@ async function checkMmProjExists(path: string | null, expectedSize?: number): Pr const exists = await RNFS.exists(path); if (!exists || !expectedSize) return exists; try { - const stat = await RNFS.stat(path); - const actualSize = typeof stat.size === 'string' ? Number.parseInt(stat.size, 10) : stat.size; + const actualSize = (await statFile(path))?.size ?? 0; if (actualSize < expectedSize) { logger.warn(`[ModelManager] mmproj partial (${actualSize}/${expectedSize}), re-downloading`); await RNFS.unlink(path).catch(() => {}); diff --git a/src/services/modelManager/downloadHelpers.ts b/src/services/modelManager/downloadHelpers.ts index b96633356..c8c10eef1 100644 --- a/src/services/modelManager/downloadHelpers.ts +++ b/src/services/modelManager/downloadHelpers.ts @@ -6,6 +6,7 @@ import RNFS from 'react-native-fs'; import { DownloadedModel, ModelFile, PersistedDownloadInfo } from '../../types'; import { backgroundDownloadService } from '../backgroundDownloadService'; import { buildDownloadedModel, persistDownloadedModel } from './storage'; +import { sizeToBytes } from '../../utils/fileSize'; export async function getOrphanedTextFiles( modelsDir: string, @@ -29,7 +30,7 @@ export async function getOrphanedTextFiles( orphaned.push({ name: file.name, path: file.path, - size: typeof file.size === 'string' ? Number.parseInt(file.size, 10) : file.size, + size: sizeToBytes(file.size), }); } } diff --git a/src/services/modelManager/importLocalModel.ts b/src/services/modelManager/importLocalModel.ts index 5a3ca3c35..2b5b4b816 100644 --- a/src/services/modelManager/importLocalModel.ts +++ b/src/services/modelManager/importLocalModel.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { statFile } from '../../utils/fileStat'; import { DownloadedModel, LiteRTDownloadedModel, @@ -9,6 +10,7 @@ import { import { buildDownloadedModel, persistDownloadedModel } from './storage'; import { copyFileWithProgress } from './copyFile'; import { parseSizeInt } from './scan'; +import { isLiteRTFileName } from '../../utils/modelHelpers'; export interface ImportLocalModelOpts { sourceUri: string; @@ -44,7 +46,7 @@ function resolveUri(uri: string): string { export async function importLocalModel(opts: ImportLocalModelOpts): Promise { // NOSONAR const { sourceUri, fileName, modelsDir, sourceSize, engine: _engine, liteRTVision, onProgress, mmProjSourceUri, mmProjFileName, mmProjSourceSize } = opts; - const isLitert = fileName.toLowerCase().endsWith('.litertlm'); + const isLitert = isLiteRTFileName(fileName); if (!fileName.toLowerCase().endsWith('.gguf') && !isLitert) { throw new Error('Only .gguf and .litertlm files can be imported'); } @@ -69,8 +71,7 @@ export async function importLocalModel(opts: ImportLocalModelOpts): Promise onProgress({ fraction: 0.5 + fraction * 0.5, fileName: mmProjFileName }) : undefined, }); - const mmProjStat = await RNFS.stat(mmProjDestPath); llamaModel.mmProjPath = mmProjDestPath; llamaModel.mmProjFileName = mmProjFileName; - llamaModel.mmProjFileSize = parseSizeInt(mmProjStat.size); + llamaModel.mmProjFileSize = (await statFile(mmProjDestPath))?.size ?? 0; llamaModel.isVisionModel = true; } diff --git a/src/services/modelManager/index.ts b/src/services/modelManager/index.ts index 0bdf5d579..8909d40b0 100644 --- a/src/services/modelManager/index.ts +++ b/src/services/modelManager/index.ts @@ -27,7 +27,6 @@ import { getOrphanedTextFiles, getOrphanedImageDirs, mmProjLocalName, - performMmProjRepairDownload, } from './download'; import { syncCompletedImageDownloads as syncCompletedImageDownloadsHelper } from './imageSync'; import { restoreInProgressDownloads } from './restore'; @@ -43,11 +42,11 @@ import { importLocalModel as scanImportLocalModel, type ImportLocalModelOpts, } from './importLocalModel'; -import { mmProjBelongsToModel, pickMmProjForModel } from '../mmproj'; import { resolveStoredPath, determineCredibility } from './storage'; +import * as visionRepair from './visionRepairService'; +import type { RepairOpts, VisionRepairContext } from './visionRepairService'; -; -; +export type { VisionRepairOutcome } from './visionRepairService'; class ModelManager { private readonly modelsDir: string; @@ -72,50 +71,23 @@ class ModelManager { exclude(`${RNFS.DocumentDirectoryPath}/${APP_CONFIG.whisperStorageDir}`)]); } - async linkOrphanMmProj(): Promise { - const models = await this.getDownloadedModels(); - let dirFiles: RNFS.ReadDirResItemT[] = []; - try { - dirFiles = await RNFS.readDir(this.modelsDir); - } catch { - return; - } - const mmProjFiles = dirFiles.filter(f => f.isFile() && this.isMMProjFile(f.name)); - if (mmProjFiles.length === 0) return; - - const toSave: typeof models = []; - for (const m of models) { - if (m.engine !== 'llama') continue; - // Strict match (shared rule): the projector must belong to THIS model by name+variant. This is the - // SAME rule the loader uses, so link-time and load-time can no longer disagree (the E2B↔E4B split). - const chosenName = pickMmProjForModel(m.fileName, mmProjFiles.map(f => f.name)); - const match = chosenName ? mmProjFiles.find(f => f.name === chosenName) : undefined; - - if (m.mmProjPath) { - // Clear the link if the stored file no longer exists OR doesn't belong to this model (strict). - const belongs = mmProjBelongsToModel(m.fileName, m.mmProjPath.split('/').pop() ?? ''); - const fileExists = await RNFS.exists(m.mmProjPath).catch(() => false); - if (!fileExists || !belongs) { - logger.log(`[linkOrphanMmProj] ${m.id} — clearing bad link: ${m.mmProjPath}`); - // Clear only the dead/wrong on-disk pointer — KEEP isVisionModel + mmProjFileName so the model is - // still recognized as a vision model that NEEDS REPAIR (needsVisionRepair → true → the wrench and - // the "download the vision file" prompt appear). Wiping the vision flag made it look like a plain - // text model, hiding the repair path entirely (device 2026-07-14). - toSave.push({ ...m, mmProjPath: undefined, mmProjFileSize: undefined, isVisionModel: true }); - } - // If link is valid, leave it alone - } else if (match) { - logger.log(`[linkOrphanMmProj] ${m.id} — linking ${match.path}`); - await this.saveModelWithMmproj(m.id, match.path); - } - } + /** + * What the projector lifecycle needs from the registry. Every re-entrant call routes back through + * this object's own methods, so the manager stays the single owner of the model list. + */ + private visionContext(): VisionRepairContext { + return { + modelsDir: this.modelsDir, + initialize: () => this.initialize(), + getDownloadedModels: () => this.getDownloadedModels(), + saveModelWithMmproj: (id, path) => this.saveModelWithMmproj(id, path), + linkOrphanMmProj: () => this.linkOrphanMmProj(), + repairMmProj: (target, opts) => this.repairMmProj(target.modelId, target.file, opts), + }; + } - if (toSave.length > 0) { - const current = await this.getDownloadedModels(); - const updated = current.map(m => toSave.find(s => s.id === m.id) ?? m); - await saveModelsList(updated); - useAppStore.getState().setDownloadedModels(updated); - } + async linkOrphanMmProj(): Promise { + return visionRepair.linkOrphanMmProj(this.visionContext()); } async getDownloadedModels(): Promise { @@ -318,60 +290,28 @@ class ModelManager { stopBackgroundDownloadPolling(): void { if (this.isBackgroundDownloadSupported()) backgroundDownloadService.stopProgressPolling(); } - async repairMmProj( - modelId: string, - file: ModelFile, - opts?: { onProgress?: DownloadProgressCallback; onDownloadIdReady?: (id: string) => void }, - ): Promise { - if (!file.mmProjFile) throw new Error('Model file has no associated mmproj'); - await this.initialize(); - // download.ts owns background-download orchestration: it starts the sidecar, - // drives the SAME download-store rows the normal download writes (so the existing - // determinate progress bar lights up during the ~900MB fetch — BUG OD2), moves the - // file, and tears the transient row down. We just persist the resolved path. - const resolvedPath = await performMmProjRepairDownload({ - modelId, file, modelsDir: this.modelsDir, ...opts, - }); - await this.saveModelWithMmproj(`${modelId}/${file.name}`, resolvedPath); + /** @see visionRepairService.repairVision - the one rule every surface repairs a model through. */ + async repairVision( + model: DownloadedModel, + opts?: RepairOpts, + ): Promise { + return visionRepair.repairVision(this.visionContext(), model, opts); + } + + async repairMmProj(modelId: string, file: ModelFile, opts?: RepairOpts): Promise { + return visionRepair.repairMmProj(this.visionContext(), { modelId, file }, opts); } - /** - * Heal the DURABLE vision flag on a record from the authoritative catalog (the repo ships an mmproj). - * The old link cleanup wiped isVisionModel on some records, so the Download Manager — which has no catalog — - * showed them as plain text. Persisting the truth here makes the record the SINGLE source both surfaces - * read. No-op if already set (so it's safe to call on render/focus). Returns true if it changed anything. - */ async markVisionModel(modelId: string): Promise { - const models = await this.getDownloadedModels(); - const target = models.find(m => m.id === modelId); - if (!target || target.engine !== 'llama' || target.isVisionModel) return false; - const updated = models.map(m => (m.id === modelId ? { ...m, isVisionModel: true } : m)); - await saveModelsList(updated); - useAppStore.getState().setDownloadedModels(updated); - return true; + return visionRepair.markVisionModel(this.visionContext(), modelId); } async saveModelWithMmproj(modelId: string, mmProjPath: string): Promise { - const mmProjFileName = mmProjPath.split('/').pop() || mmProjPath; - const stat = await RNFS.stat(mmProjPath); - const mmProjFileSize = typeof stat.size === 'string' ? Number.parseInt(stat.size, 10) : stat.size; - - const models = await this.getDownloadedModels(); - const updated = models.map(m => - m.id === modelId ? { ...m, mmProjPath, mmProjFileName, mmProjFileSize, isVisionModel: true } : m - ); - await saveModelsList(updated); - // Also update the in-memory Zustand store so UI reflects the change immediately. - useAppStore.getState().setDownloadedModels(updated); + return visionRepair.saveModelWithMmproj(this.visionContext(), modelId, mmProjPath); } async clearMmProjLink(modelId: string): Promise { - const models = await this.getDownloadedModels(); - const updated = models.map(m => - m.id === modelId ? { ...m, mmProjPath: undefined, mmProjFileName: undefined, mmProjFileSize: undefined, isVisionModel: false } : m - ); - await saveModelsList(updated); - useAppStore.getState().setDownloadedModels(updated); + return visionRepair.clearMmProjLink(this.visionContext(), modelId); } async cleanupMMProjEntries(): Promise { @@ -470,4 +410,3 @@ class ModelManager { } export const modelManager = new ModelManager(); -; diff --git a/src/services/modelManager/storage.ts b/src/services/modelManager/storage.ts index e4a8cafe2..d8f5b46a3 100644 --- a/src/services/modelManager/storage.ts +++ b/src/services/modelManager/storage.ts @@ -1,11 +1,16 @@ import RNFS from 'react-native-fs'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { DownloadedModel, LlamaDownloadedModel, LiteRTDownloadedModel, ModelFile, ModelCredibility, ONNXImageModel } from '../../types'; +import { DownloadedModel, LlamaDownloadedModel, LiteRTDownloadedModel, ModelFile, ModelCredibility, ModelOrigin, ONNXImageModel } from '../../types'; import { LMSTUDIO_AUTHORS, OFFICIAL_MODEL_AUTHORS, VERIFIED_QUANTIZERS } from '../../constants'; import { getCuratedLiteRTEntry } from '../curatedLiteRTRegistry'; import logger from '../../utils/logger'; +import { statFile } from '../../utils/fileStat'; +import { parseHuggingFaceUrl } from '../../utils/modelOrigin'; import { collapseDuplicateFileRows, collapseDuplicateImageRows } from './collapseDuplicateFileRows'; import { reconcilePrimaryPaths, resolveStoredPath } from './reconcileStoredPaths'; +import { sizeToBytes } from '../../utils/fileSize'; +import { useAppStore } from '../../stores'; +import { isLiteRTFileName } from '../../utils/modelHelpers'; // Re-exported because this module was the published home of these helpers before they were extracted // into the one place both registries share. @@ -53,6 +58,22 @@ export async function saveModelsList(models: DownloadedModel[]): Promise { await AsyncStorage.setItem(MODELS_STORAGE_KEY, JSON.stringify(models)); } +/** + * Change the model registry: persist the list AND publish it, in that order. + * + * Every writer needs both halves - a list saved but not published leaves the screens reading a + * model that no longer exists until something forces a refetch, which is how a repaired model kept + * showing "no vision file" until the app restarted. Four call sites each wrote the pair by hand; + * this is the one that cannot forget the second line. + * + * `saveModelsList` stays for the read path only, where a self-heal rewrite must NOT publish (it is + * running inside the very load the store is waiting on). + */ +export async function commitModelsList(models: DownloadedModel[]): Promise { + await saveModelsList(models); + useAppStore.getState().setDownloadedModels(models); +} + export async function saveImageModelsList(models: ONNXImageModel[]): Promise { await AsyncStorage.setItem(IMAGE_MODELS_STORAGE_KEY, JSON.stringify(models)); } @@ -182,39 +203,81 @@ export interface BuildModelOpts { mmProjPath?: string; /** Kept even when mmProjPath is absent (download failed) so needsVisionRepair can detect the gap */ expectedMmProjFileName?: string; + /** Provenance the caller already knows (a device transfer carries the sender's). Wins over the URL. */ + origin?: ModelOrigin; +} + +/** + * The projector's size on disk, falling back to the size the catalog advertised. + * + * The file wins when it is there: a resumed or repaired sidecar can differ from the metadata, and + * the storage figures the user reads come from this number. + */ +async function resolveMmProjFileSize( + mmProjPath: string | undefined, + mmProjFile: ModelFile['mmProjFile'], +): Promise { + if (!mmProjPath) return undefined; + try { + return (await statFile(mmProjPath))?.size ?? mmProjFile?.size; + } catch { + // Keep the fallback size from metadata. + return mmProjFile?.size; + } +} + +/** + * mmProjFileName is written even when mmProjPath is absent (e.g. sidecar download failed). + * This sentinel lets needsVisionRepair detect the gap without any name-based heuristic: + * model.mmProjFileName is set → model was supposed to have vision + * model.mmProjPath is absent → file is missing, show "Repair Vision" + */ +function resolveMmProjFileName( + mmProjPath: string | undefined, + mmProjFile: ModelFile['mmProjFile'], + expectedMmProjFileName: string | undefined, +): string | undefined { + if (mmProjPath) return mmProjFile?.name ?? mmProjPath.split('/').pop(); + return expectedMmProjFileName ?? mmProjFile?.name; +} + +/** + * Registry wins for curated LiteRT artifacts: the display name comes from a single source of truth + * keyed by fileName. Falls back to the file's own name for locally-imported .litertlm files, then to + * the modelId basename for everything else. + */ +function resolveDisplayName( + modelId: string, + file: ModelFile, + curatedDisplayName: string | undefined, +): string { + if (curatedDisplayName) return curatedDisplayName; + if (isLiteRTFileName(file.name)) return file.name.replace(/\.litertlm$/i, ''); + return modelId.split('/').pop() || modelId; } export async function buildDownloadedModel(opts: BuildModelOpts): Promise { const { modelId, file, resolvedLocalPath, mmProjPath, expectedMmProjFileName } = opts; - const stat = await RNFS.stat(resolvedLocalPath); + // Through the safe reader. `RNFS.stat` here ABORTED the app three seconds after every launch: the + // startup scan calls this for each stored model, an absolute container path goes stale on reinstall, + // and iOS raises an uncatchable NSInvalidArgumentException for a path whose type it cannot resolve. + // A missing file is now a size of 0 - the model reads as present-but-empty, which the caller already + // handles - instead of taking the process down. See utils/fileStat. + const stat = await statFile(resolvedLocalPath); const author = modelId.split('/')[0] || 'Unknown'; - const isLiteRT = file.name.toLowerCase().endsWith('.litertlm'); + const isLiteRT = isLiteRTFileName(file.name); const mmProjFile = file.mmProjFile; - let mmProjFileSize = mmProjPath ? mmProjFile?.size : undefined; - if (mmProjPath) { - try { - const mmStat = await RNFS.stat(mmProjPath); - mmProjFileSize = typeof mmStat.size === 'string' ? Number.parseInt(mmStat.size, 10) : mmStat.size; - } catch { - // Keep fallback size from metadata. - } - } + const mmProjFileSize = await resolveMmProjFileSize(mmProjPath, mmProjFile); + const mmProjFileName = resolveMmProjFileName(mmProjPath, mmProjFile, expectedMmProjFileName); - // mmProjFileName is written even when mmProjPath is absent (e.g. sidecar download failed). - // This sentinel lets needsVisionRepair detect the gap without any name-based heuristic: - // model.mmProjFileName is set → model was supposed to have vision - // model.mmProjPath is absent → file is missing, show "Repair Vision" - const mmProjFileName = mmProjPath - ? (mmProjFile?.name ?? mmProjPath.split('/').pop()) - : (expectedMmProjFileName ?? mmProjFile?.name); - - // Registry wins for curated LiteRT artifacts: display name and capability bits - // come from a single source of truth keyed by fileName. Falls back to the - // file's metadata for locally-imported .litertlm files, then to modelId basename - // for everything else. const curatedLiteRT = isLiteRT ? getCuratedLiteRTEntry(file.name) : undefined; - const derivedName = curatedLiteRT?.displayName - ?? (isLiteRT ? file.name.replace(/\.litertlm$/i, '') : (modelId.split('/').pop() || modelId)); + const derivedName = resolveDisplayName(modelId, file, curatedLiteRT?.displayName); + + // Provenance, taken from the URL we actually fetched. This is the ONE moment it is known for + // certain; everything downstream (vision repair) reads it instead of guessing from the id. + // `opts.origin` wins when a caller already knows better - a device transfer passes on the + // origin the sending device recorded, which the receiver cannot derive from the bytes alone. + const origin = opts.origin ?? parseHuggingFaceUrl(file.downloadUrl) ?? undefined; const commonFields = { id: `${modelId}/${file.name}`, @@ -222,10 +285,11 @@ export async function buildDownloadedModel(opts: BuildModelOpts): Promise void; +} + +/** + * What this module needs from the model registry. The re-entrant members are passed rather than + * imported so every call still goes through the manager's own method - one owner of the registry, + * and one place a caller can observe. + */ +export interface VisionRepairContext { + modelsDir: string; + initialize(): Promise; + getDownloadedModels(): Promise; + saveModelWithMmproj(modelId: string, mmProjPath: string): Promise; + linkOrphanMmProj(): Promise; + repairMmProj(target: MmProjTarget, opts?: RepairOpts): Promise; +} + +export async function linkOrphanMmProj(ctx: VisionRepairContext): Promise { + const models = await ctx.getDownloadedModels(); + let dirFiles: RNFS.ReadDirResItemT[] = []; + try { + dirFiles = await RNFS.readDir(ctx.modelsDir); + } catch { + return; + } + const mmProjFiles = dirFiles.filter(f => f.isFile() && isMMProjFile(f.name)); + if (mmProjFiles.length === 0) return; + + const toSave: DownloadedModel[] = []; + for (const m of models) { + if (m.engine !== 'llama') continue; + // Strict match (shared rule): the projector must belong to THIS model by name+variant. This is the + // SAME rule the loader uses, so link-time and load-time can no longer disagree (the E2B↔E4B split). + const chosenName = pickMmProjForModel(m.fileName, mmProjFiles.map(f => f.name)); + const match = chosenName ? mmProjFiles.find(f => f.name === chosenName) : undefined; + + if (m.mmProjPath) { + // Clear the link if the stored file no longer exists OR doesn't belong to this model (strict). + const belongs = mmProjBelongsToModel(m.fileName, m.mmProjPath.split('/').pop() ?? ''); + const fileExists = await RNFS.exists(m.mmProjPath).catch(() => false); + if (!fileExists || !belongs) { + logger.log(`[linkOrphanMmProj] ${m.id} — clearing bad link: ${m.mmProjPath}`); + // Clear only the dead/wrong on-disk pointer — KEEP isVisionModel + mmProjFileName so the model is + // still recognized as a vision model that NEEDS REPAIR (needsVisionRepair → true → the wrench and + // the "download the vision file" prompt appear). Wiping the vision flag made it look like a plain + // text model, hiding the repair path entirely (device 2026-07-14). + toSave.push({ ...m, mmProjPath: undefined, mmProjFileSize: undefined, isVisionModel: true }); + } + // If link is valid, leave it alone + } else if (match) { + logger.log(`[linkOrphanMmProj] ${m.id} — linking ${match.path}`); + await ctx.saveModelWithMmproj(m.id, match.path); + } + } + + if (toSave.length > 0) { + const current = await ctx.getDownloadedModels(); + await commitModelsList(current.map(m => toSave.find(s => s.id === m.id) ?? m)); + } +} + +/** + * The ONE way any surface repairs a vision model - Download Manager, Models screen and Chat all + * call this. Each of them used to rebuild a Hugging Face repo id by splitting the model's DISPLAY + * id at its last slash, which is three copies of one rule and wrong for every model that did not + * come from a repo of that exact name. A transferred or imported model produced a repo id HF has + * never seen, and HF answers an unknown repo with 401 - so the user was shown an auth error for a + * file that never had an upstream. + * + * Resolution order, most certain first: recorded provenance, then a projector already sitting on + * disk, then a size-verified HF match. Anything else is reported, never guessed at. + */ +export async function repairVision( + ctx: VisionRepairContext, + model: DownloadedModel, + opts?: RepairOpts, +): Promise { + if (model.engine !== 'llama') return { kind: 'unsupported' }; + + // A projector already next to the weights needs no network and no identification at all. + await ctx.linkOrphanMmProj(); + const relinked = (await ctx.getDownloadedModels()).find(m => m.id === model.id); + if (relinked?.engine === 'llama' && relinked.mmProjPath) return { kind: 'linked' }; + + const source = await resolveVisionRepairSource( + { origin: model.origin, fileName: model.fileName, fileSize: model.fileSize }, + fileName => huggingFaceService.findReposPublishing(fileName), + ); + if (source.kind === 'ambiguous') return { kind: 'ambiguous', candidates: source.candidates }; + if (source.kind === 'unknown') return { kind: 'unknown' }; + + const files = await huggingFaceService.getModelFiles(source.origin.repoId); + const file = files.find(f => f.name === model.fileName); + if (!file?.mmProjFile) return { kind: 'noProjectorPublished', repoId: source.origin.repoId }; + + await ctx.repairMmProj({ modelId: source.origin.repoId, file }, opts); + return { kind: 'repaired', repoId: source.origin.repoId }; +} + +/** The repo and the weights file the projector belongs to - they are never meaningful apart. */ +export interface MmProjTarget { + modelId: string; + file: ModelFile; +} + +export async function repairMmProj( + ctx: VisionRepairContext, + { modelId, file }: MmProjTarget, + opts?: RepairOpts, +): Promise { + if (!file.mmProjFile) throw new Error('Model file has no associated mmproj'); + await ctx.initialize(); + // download.ts owns background-download orchestration: it starts the sidecar, + // drives the SAME download-store rows the normal download writes (so the existing + // determinate progress bar lights up during the ~900MB fetch — BUG OD2), moves the + // file, and tears the transient row down. We just persist the resolved path. + const resolvedPath = await performMmProjRepairDownload({ + modelId, file, modelsDir: ctx.modelsDir, ...opts, + }); + await ctx.saveModelWithMmproj(`${modelId}/${file.name}`, resolvedPath); +} + +/** + * Heal the DURABLE vision flag on a record from the authoritative catalog (the repo ships an mmproj). + * The old link cleanup wiped isVisionModel on some records, so the Download Manager — which has no catalog — + * showed them as plain text. Persisting the truth here makes the record the SINGLE source both surfaces + * read. No-op if already set (so it's safe to call on render/focus). Returns true if it changed anything. + */ +export async function markVisionModel( + ctx: VisionRepairContext, + modelId: string, +): Promise { + const models = await ctx.getDownloadedModels(); + const target = models.find(m => m.id === modelId); + if (!target || target.engine !== 'llama' || target.isVisionModel) return false; + await commitModelsList(models.map(m => (m.id === modelId ? { ...m, isVisionModel: true } : m))); + return true; +} + +export async function saveModelWithMmproj( + ctx: VisionRepairContext, + modelId: string, + mmProjPath: string, +): Promise { + const mmProjFileName = mmProjPath.split('/').pop() || mmProjPath; + const mmProjFileSize = (await statFile(mmProjPath))?.size ?? 0; + + const models = await ctx.getDownloadedModels(); + await commitModelsList( + models.map(m => + m.id === modelId ? { ...m, mmProjPath, mmProjFileName, mmProjFileSize, isVisionModel: true } : m + ), + ); +} + +export async function clearMmProjLink( + ctx: VisionRepairContext, + modelId: string, +): Promise { + const models = await ctx.getDownloadedModels(); + await commitModelsList( + models.map(m => + m.id === modelId + ? { ...m, mmProjPath: undefined, mmProjFileName: undefined, mmProjFileSize: undefined, isVisionModel: false } + : m + ), + ); +} diff --git a/src/services/modelManager/visionRepairSource.ts b/src/services/modelManager/visionRepairSource.ts new file mode 100644 index 000000000..c3dcb3c4b --- /dev/null +++ b/src/services/modelManager/visionRepairSource.ts @@ -0,0 +1,75 @@ +import type { ModelOrigin } from '../../types'; + +/** + * Where a missing mmproj can be fetched from, and how sure we are. + * + * `recorded` - the model carries its own provenance. Free and certain. + * `matched` - no provenance, but exactly one Hugging Face repo publishes a file of this name at + * byte-identical size. Verified, not guessed. + * `ambiguous` - several repos publish that file name and we cannot tell them apart. A wrong + * projector loads and produces nonsense, so this is a question for the user, not a + * coin toss. (`SmolVLM-500M-Instruct-GGUF` matches three repos, one of them an `i1` + * requantisation whose projector does NOT match ours.) + * `unknown` - nothing upstream. A local import has no repo, and neither does anything HF has + * never published. The honest answer, and the reason this is a union rather than a + * nullable repo id: the UI must be able to say WHY. + */ +export type VisionRepairSource = + | { kind: 'recorded'; origin: ModelOrigin } + | { kind: 'matched'; origin: ModelOrigin } + | { kind: 'ambiguous'; candidates: string[] } + | { kind: 'unknown' }; + +/** One candidate repo, reduced to what identifies a file: its name and its exact size. */ +export interface RepoFileCandidate { + repoId: string; + files: { name: string; sizeBytes?: number }[]; +} + +/** Injected so the decision below is pure and testable with no network. */ +export interface HuggingFaceSearch { + (fileName: string): Promise; +} + +export interface RepairSourceInput { + origin?: ModelOrigin; + /** The primary file we hold locally - the thing a candidate repo has to match. */ + fileName: string; + fileSize: number; +} + +/** + * Resolve where this model's projector can come from. + * + * Search finds CANDIDATES; size identifies the FILE. Matching on name alone picks one of several + * repos at random, which is how a model ends up with a projector built for a different + * quantisation. A candidate only survives if it publishes our exact file name at our exact byte + * size, and the answer is only used when exactly one survives. + */ +export async function resolveVisionRepairSource( + input: RepairSourceInput, + search: HuggingFaceSearch, +): Promise { + if (input.origin) return { kind: 'recorded', origin: input.origin }; + + const candidates = await search(input.fileName); + const matches = candidates.filter(candidate => + candidate.files.some( + file => file.name === input.fileName && file.sizeBytes === input.fileSize, + ), + ); + + if (matches.length === 1) { + return { + kind: 'matched', + // The search told us nothing about which commit these bytes came from, and pinning a guess + // would be a second invention. `main` is the honest read of "whatever that repo publishes + // now", and the size check already proved the file we want is there. + origin: { repoId: matches[0].repoId, revision: 'main', path: input.fileName }, + }; + } + if (matches.length > 1) { + return { kind: 'ambiguous', candidates: matches.map(m => m.repoId) }; + } + return { kind: 'unknown' }; +} diff --git a/src/utils/modelHelpers.ts b/src/utils/modelHelpers.ts index 9f7dba143..62ac9d5c6 100644 --- a/src/utils/modelHelpers.ts +++ b/src/utils/modelHelpers.ts @@ -2,3 +2,14 @@ import { DownloadedModel } from '../types'; export const getMmProjFileSize = (m?: DownloadedModel): number => m?.engine === 'llama' ? (m.mmProjFileSize ?? 0) : 0; + +/** + * The ONE test for "is this a LiteRT model file". + * + * Five call sites each spelled the extension out — the import guard, the import display name, the + * registry row builder, the multi-file picker and the acceleration check. A format is one fact + * about a file, so it gets one answer; adding a second LiteRT extension used to mean finding all + * five. + */ +export const isLiteRTFileName = (fileName: string): boolean => + fileName.toLowerCase().endsWith('.litertlm'); diff --git a/src/utils/modelOrigin.ts b/src/utils/modelOrigin.ts new file mode 100644 index 000000000..d26d18f5c --- /dev/null +++ b/src/utils/modelOrigin.ts @@ -0,0 +1,30 @@ +import type { ModelOrigin } from '../types'; + +/** + * Reads a Hugging Face resolve URL back into the provenance it encodes. + * + * https://huggingface.co/{owner}/{name}/resolve/{revision}/{path} + * + * Every download path in this app builds exactly that shape - the catalog, the HF browser, the + * CoreML browser, the curated LiteRT registry and the Whisper models - so one parser covers every + * model on Hugging Face, whether or not it is in our catalog. + * + * Returns null for anything else: a local import has no upstream, and inventing one is what turned + * a missing field into a 401 in a dialog. + */ +export function parseHuggingFaceUrl(url: string | undefined): ModelOrigin | null { + if (!url) return null; + const match = /^https?:\/\/huggingface\.co\/(.+?)\/resolve\/([^/]+)\/(.+)$/.exec( + url.split('?')[0], + ); + if (!match) return null; + const [, repoId, revision, path] = match; + // A repo id is always `owner/name`; anything shallower is a URL we do not understand. + if (repoId.split('/').length < 2) return null; + return { repoId, revision, path }; +} + +/** The download URL for a sibling file in the SAME repo and at the SAME revision. */ +export function siblingDownloadUrl(origin: ModelOrigin, fileName: string): string { + return `https://huggingface.co/${origin.repoId}/resolve/${origin.revision}/${fileName}`; +} diff --git a/src/utils/visionRepair.ts b/src/utils/visionRepair.ts index cc79b29cd..eccfe55f2 100644 --- a/src/utils/visionRepair.ts +++ b/src/utils/visionRepair.ts @@ -1,4 +1,5 @@ import { ModelFile } from '../types'; +import { predictGgufCapabilities } from './ggufCapabilities'; interface VisionRepairCandidate { isVisionModel?: boolean; @@ -11,8 +12,13 @@ interface VisionRepairCandidate { function looksLikeVisionByName(model: VisionRepairCandidate): boolean { const name = (model.name ?? '').toLowerCase(); const file = (model.fileName ?? '').toLowerCase(); - return name.includes('vl') || name.includes('vision') || name.includes('smolvlm') || - file.includes('vl') || file.includes('vision'); + return ( + name.includes('vl') || + name.includes('vision') || + name.includes('smolvlm') || + file.includes('vl') || + file.includes('vision') + ); } /** @@ -27,7 +33,10 @@ export function needsVisionRepair( catalogFile?: ModelFile, ): boolean { if (!model) return false; - if (model.mmProjPath) return false; + // "Can it see right now" has ONE owner - the same predictor deriveEngineCapabilities falls back + // to, which reads the projector rather than the name. Re-testing mmProjPath here would be a + // second copy of that rule, and the two would eventually disagree about the same model. + if (predictGgufCapabilities(model).vision) return false; // Primary signal: mmProjFileName metadata indicates this model should have vision const hasVisionMetadata = !!model.mmProjFileName; From 7563cf91771d02f2336fbc5ab0ab17ef9db915fb Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:03:18 +0530 Subject: [PATCH 025/229] refactor(downloads): the screen renders rows, the mapping decides what they are Turning a download into a row was decided inside the hook, mixed with the orchestration around it, so the rule could not be read or exercised on its own. --- .../useDownloadManager.branches.test.ts | 76 ++-- .../downloadItemMapping.ts | 156 ++++++++ src/screens/DownloadManagerScreen/items.tsx | 5 +- .../useDownloadManager.ts | 353 ++++++++---------- 4 files changed, 366 insertions(+), 224 deletions(-) create mode 100644 src/screens/DownloadManagerScreen/downloadItemMapping.ts diff --git a/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts b/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts index d69076aa2..c52dcd63a 100644 --- a/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts +++ b/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts @@ -14,6 +14,7 @@ import { renderHook, act } from '@testing-library/react-native'; import { useDownloadManager } from '../../../../src/screens/DownloadManagerScreen/useDownloadManager'; +import { visionRepairMessage } from '../../../../src/services/modelManager/visionRepairMessage'; // ── mocks ───────────────────────────────────────────────────────────── const mockUseAppStore = jest.fn(); @@ -22,6 +23,7 @@ const mockDownloadStoreGetState = jest.fn(); const mockModelManager = { getDownloadedModels: jest.fn(), + repairVision: jest.fn(), repairMmProj: jest.fn(), getModelFiles: jest.fn(), }; @@ -113,6 +115,7 @@ beforeEach(() => { downloads = {}; mockModelManager.getDownloadedModels.mockResolvedValue([]); mockModelManager.repairMmProj.mockResolvedValue(undefined); + mockModelManager.repairVision.mockResolvedValue({ kind: 'unsupported' }); mockBackgroundDownloadService.getActiveDownloads.mockResolvedValue([]); configureStores(); }); @@ -224,47 +227,68 @@ describe('handleDeleteItem', () => { }); // ── handleRepairVision (still owned by the hook) ────────────────────── +// +// The hook no longer decides anything about a repair: the service resolves where the projector can +// come from and returns an OUTCOME, and one shared rule (visionRepairMessage) turns that outcome +// into words. So these assert the two things the hook is still responsible for — asking the service +// about a model it actually holds, and saying exactly what the shared rule says. The wording itself +// is read from that rule, so the Download Manager and the chat card cannot drift apart. describe('handleRepairVision', () => { - it('returns early when modelId has no slash', () => { + const REPAIR_ITEM = { modelId: 'org/repo/m.gguf', fileName: 'm.gguf' } as any; + + function withRepairableModel() { + appState.downloadedModels = [{ id: 'org/repo/m.gguf', fileName: 'm.gguf', engine: 'llama' }]; + } + + async function repair(result: { current: { handleRepairVision: (i: any) => void } }) { + await act(async () => { + result.current.handleRepairVision(REPAIR_ITEM); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + }); + } + + it('does nothing for a model this device does not hold', () => { const { result } = renderHook(() => useDownloadManager()); - act(() => { result.current.handleRepairVision({ modelId: 'noslash' } as any); }); + act(() => { result.current.handleRepairVision({ modelId: 'not/here' } as any); }); + expect(mockModelManager.repairVision).not.toHaveBeenCalled(); expect(mockSetRepairingVision).not.toHaveBeenCalled(); }); - it('alerts when no separate vision file is published', async () => { - mockHuggingFaceService.getModelFiles.mockResolvedValue([{ name: 'm.gguf' }]); + it.each([ + ['repaired', { kind: 'repaired', repoId: 'org/repo' }], + ['linked', { kind: 'linked' }], + ['ambiguous', { kind: 'ambiguous', candidates: ['a/b', 'c/d'] }], + ['noProjectorPublished', { kind: 'noProjectorPublished', repoId: 'org/repo' }], + ['unknown', { kind: 'unknown' }], + ['unsupported', { kind: 'unsupported' }], + ])('says exactly what the shared message rule says for %s', async (_kind, outcome) => { + withRepairableModel(); + mockModelManager.repairVision.mockResolvedValue(outcome); const { result } = renderHook(() => useDownloadManager()); - await act(async () => { - result.current.handleRepairVision({ modelId: 'org/repo/m.gguf', fileName: 'm.gguf' } as any); - await Promise.resolve(); await Promise.resolve(); - }); - expect(mockSetRepairingVision).toHaveBeenCalledWith('org/repo/m.gguf', true); - expect(shownAlertTitles).toContain('No Vision File Available'); - expect(mockSetRepairingVision).toHaveBeenCalledWith('org/repo/m.gguf', false); + await repair(result); + + const [expectedTitle] = visionRepairMessage(outcome as any, REPAIR_ITEM.fileName); + expect(shownAlertTitles).toContain(expectedTitle); + expect(mockSetRepairingVision).toHaveBeenCalledWith(REPAIR_ITEM.modelId, true); + expect(mockSetRepairingVision).toHaveBeenCalledWith(REPAIR_ITEM.modelId, false); }); - it('repairs and refreshes when a vision file exists', async () => { - mockHuggingFaceService.getModelFiles.mockResolvedValue([{ name: 'm.gguf', mmProjFile: { name: 'mm.gguf' } }]); + it('republishes the model list so the repaired model reloads', async () => { + withRepairableModel(); + mockModelManager.repairVision.mockResolvedValue({ kind: 'repaired', repoId: 'org/repo' }); mockModelManager.getDownloadedModels.mockResolvedValue([{ id: 'x' }]); const { result } = renderHook(() => useDownloadManager()); - await act(async () => { - result.current.handleRepairVision({ modelId: 'org/repo/m.gguf', fileName: 'm.gguf' } as any); - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); - }); - expect(mockModelManager.repairMmProj).toHaveBeenCalledWith('org/repo', { name: 'm.gguf', mmProjFile: { name: 'mm.gguf' } }, {}); + await repair(result); expect(setDownloadedModels).toHaveBeenCalledWith([{ id: 'x' }]); - expect(shownAlertTitles).toContain('Vision Repaired'); }); - it('shows Repair Failed when getModelFiles rejects', async () => { - mockHuggingFaceService.getModelFiles.mockRejectedValue(new Error('hf down')); + it('shows Repair Failed when the service itself throws', async () => { + withRepairableModel(); + mockModelManager.repairVision.mockRejectedValue(new Error('hf down')); const { result } = renderHook(() => useDownloadManager()); - await act(async () => { - result.current.handleRepairVision({ modelId: 'org/repo/m.gguf', fileName: 'm.gguf' } as any); - await Promise.resolve(); await Promise.resolve(); - }); + await repair(result); expect(shownAlertTitles).toContain('Repair Failed'); - expect(mockSetRepairingVision).toHaveBeenCalledWith('org/repo/m.gguf', false); + expect(mockSetRepairingVision).toHaveBeenCalledWith(REPAIR_ITEM.modelId, false); }); }); diff --git a/src/screens/DownloadManagerScreen/downloadItemMapping.ts b/src/screens/DownloadManagerScreen/downloadItemMapping.ts new file mode 100644 index 000000000..8f4394925 --- /dev/null +++ b/src/screens/DownloadManagerScreen/downloadItemMapping.ts @@ -0,0 +1,156 @@ +import { DownloadEntry } from '../../stores/downloadStore'; +import { hardwareService } from '../../services'; +import { DownloadedModel, ONNXImageModel } from '../../types'; +import { DownloadItem } from './items'; +import { parseEntryMetadata } from './retryHandlers'; +import { imageBackendLabel } from '../../utils/imageBackend'; + +/** + * How a download store row, a queued start, or a finished model becomes one Download Manager row. + * + * Pure projection: no hooks, no store reads, no IO. The screen decides WHEN to build a row; this + * module decides WHAT the row says, so the naming, the id and the dedup key can be reasoned about + * (and read back) without a rendered screen. + */ + +function getActiveItemModelId(entry: DownloadEntry, isImage: boolean): string { + if (isImage && entry.modelId.startsWith('image:')) { + return entry.modelId.replace('image:', ''); + } + // Text canonical id = the modelKey (repo/file), which is exactly what the finished + // model's id is (buildDownloadedModel: `${modelId}/${fileName}`). Keying the in-flight + // row by the bare repo produced a DIFFERENT uniform id than the completed model, so the + // dedup + reconcile never collapsed them → phantom "100%" rows and Active+Downloaded + // duplicates for one model. Image/STT already normalize to one id per model. + if (entry.modelType === 'text') return entry.modelKey; + return entry.modelId; +} + +function getActiveItemFileName( + entry: DownloadEntry, + isImage: boolean, + metadata: Record | null, +): string { + return isImage && metadata?.imageModelName + ? metadata.imageModelName + : entry.fileName; +} + +function getImageAuthor(backend?: string): string { + return imageBackendLabel(backend, 'Image Generation'); +} + +function getActiveItemAuthor( + entry: DownloadEntry, + isImage: boolean, + metadata: Record | null, +): string { + if (isImage) return getImageAuthor(metadata?.imageModelBackend); + return entry.modelId.split('/')[0] ?? 'Unknown'; +} + +function getActiveItemQuantization( + entry: DownloadEntry, + isImage: boolean, + metadata: Record | null, +): string { + if (!isImage) return entry.quantization; + return metadata?.imageModelBackend === 'coreml' ? 'Core ML' : ''; +} + +/** A start waiting for a concurrency slot (no native downloadId yet) → a "Queued" + * active item. status 'pending' renders as "Queued" in the item row. */ +export function queuedToActiveItem(q: { + modelKey: string; + modelId: string; + fileName: string; + modelType: string; + totalBytes: number; +}): DownloadItem { + return { + type: 'active', + modelType: q.modelType as DownloadItem['modelType'], + modelKey: q.modelKey, + // Match getActiveItemModelId: text routes/dedups on the modelKey (repo/file), the + // same id the finished model carries; other types pass the modelId through. + modelId: q.modelType === 'text' ? q.modelKey : q.modelId, + fileName: q.fileName, + author: '', + quantization: '', + fileSize: q.totalBytes, + bytesDownloaded: 0, + progress: 0, + status: 'pending', + }; +} + +export function entryToActiveItem(entry: DownloadEntry): DownloadItem { + const metadata = parseEntryMetadata(entry); + const isImage = entry.modelType === 'image'; + + return { + type: 'active', + modelType: entry.modelType, + downloadId: entry.downloadId, + modelKey: entry.modelKey, + modelId: getActiveItemModelId(entry, isImage), + fileName: getActiveItemFileName(entry, isImage, metadata), + author: getActiveItemAuthor(entry, isImage, metadata), + quantization: getActiveItemQuantization(entry, isImage, metadata), + fileSize: entry.combinedTotalBytes || entry.totalBytes, + bytesDownloaded: entry.bytesDownloaded + (entry.mmProjBytesDownloaded ?? 0), + progress: entry.progress, + status: entry.status, + reason: entry.errorMessage, + reasonCode: entry.errorCode as + | import('../../types').BackgroundDownloadReasonCode + | undefined, + }; +} + +/** Map the text + image model stores into completed Download Manager items. */ +export function modelStoreCompletedItems( + downloadedModels: DownloadedModel[], + downloadedImageModels: ONNXImageModel[], +): DownloadItem[] { + return [ + ...downloadedModels.map((model): DownloadItem => { + const totalSize = hardwareService.getModelTotalSize(model); + return { + type: 'completed', + modelType: 'text', + modelId: model.id, + fileName: model.fileName, + author: model.author, + quantization: model.quantization, + fileSize: totalSize, + bytesDownloaded: totalSize, + progress: 1, + status: 'completed', + downloadedAt: model.downloadedAt, + filePath: model.filePath, + isVisionModel: + model.engine === 'llama' ? model.isVisionModel : undefined, + mmProjPath: model.engine === 'llama' ? model.mmProjPath : undefined, + mmProjFileName: + model.engine === 'llama' ? model.mmProjFileName : undefined, + name: model.name, + }; + }), + ...downloadedImageModels.map( + (model): DownloadItem => ({ + type: 'completed', + modelType: 'image', + modelId: model.id, + fileName: model.name, + author: 'Image Generation', + quantization: '', + fileSize: model.size, + bytesDownloaded: model.size, + progress: 1, + status: 'completed', + filePath: model.modelPath, + }), + ), + ]; +} diff --git a/src/screens/DownloadManagerScreen/items.tsx b/src/screens/DownloadManagerScreen/items.tsx index 785ce359c..21db284cd 100644 --- a/src/screens/DownloadManagerScreen/items.tsx +++ b/src/screens/DownloadManagerScreen/items.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { LoadingDots } from '../../components/LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { Card } from '../../components'; import { useTheme, useThemedStyles } from '../../theme'; @@ -254,7 +255,7 @@ export const CompletedDownloadCard: React.FC = ({ it )} {isRepairingVision && ( - + Repairing )} diff --git a/src/screens/DownloadManagerScreen/useDownloadManager.ts b/src/screens/DownloadManagerScreen/useDownloadManager.ts index 33ec8bb4d..d51455397 100644 --- a/src/screens/DownloadManagerScreen/useDownloadManager.ts +++ b/src/screens/DownloadManagerScreen/useDownloadManager.ts @@ -1,22 +1,31 @@ import { useState } from 'react'; -import { AlertState, showAlert, hideAlert, initialAlertState } from '../../components/CustomAlert'; +import { + AlertState, + showAlert, + hideAlert, + initialAlertState, +} from '../../components/CustomAlert'; import { useAppStore } from '../../stores'; -import { useDownloadStore, DownloadEntry } from '../../stores/downloadStore'; +import { useDownloadStore } from '../../stores/downloadStore'; import { modelManager, hardwareService, - huggingFaceService, backgroundDownloadService, } from '../../services'; +import { visionRepairMessage } from '../../services/modelManager/visionRepairMessage'; import { useVoiceDownloadItems } from './useVoiceDownloadItems'; import { DownloadedModel, ONNXImageModel } from '../../types'; import { DownloadItem, formatBytes } from './items'; +import { + entryToActiveItem, + modelStoreCompletedItems, + queuedToActiveItem, +} from './downloadItemMapping'; import logger from '../../utils/logger'; import { cancelSyntheticImageDownload } from '../ModelsScreen/imageDownloadActions'; -import { parseEntryMetadata, retryImageDownload } from './retryHandlers'; +import { retryImageDownload } from './retryHandlers'; import { modelDownloadService } from '../../services/modelDownloadService'; import { uniformDownloadId } from '../../services/modelDownloadService/uniformId'; -import { imageBackendLabel } from '../../utils/imageBackend'; import { setImageDownloadOps } from '../../services/modelDownloadService/providers/imageProvider'; import { useEffect } from 'react'; @@ -33,129 +42,12 @@ export interface UseDownloadManagerResult { totalStorageUsed: number; } -function getActiveItemModelId(entry: DownloadEntry, isImage: boolean): string { - if (isImage && entry.modelId.startsWith('image:')) { - return entry.modelId.replace('image:', ''); - } - // Text canonical id = the modelKey (repo/file), which is exactly what the finished - // model's id is (buildDownloadedModel: `${modelId}/${fileName}`). Keying the in-flight - // row by the bare repo produced a DIFFERENT uniform id than the completed model, so the - // dedup + reconcile never collapsed them → phantom "100%" rows and Active+Downloaded - // duplicates for one model. Image/STT already normalize to one id per model. - if (entry.modelType === 'text') return entry.modelKey; - return entry.modelId; -} - -function getActiveItemFileName( - entry: DownloadEntry, - isImage: boolean, - metadata: Record | null, -): string { - return isImage && metadata?.imageModelName - ? metadata.imageModelName - : entry.fileName; -} - -function getImageAuthor(backend?: string): string { - return imageBackendLabel(backend, 'Image Generation'); -} - -function getActiveItemAuthor( - entry: DownloadEntry, - isImage: boolean, - metadata: Record | null, -): string { - if (isImage) return getImageAuthor(metadata?.imageModelBackend); - return entry.modelId.split('/')[0] ?? 'Unknown'; -} - -function getActiveItemQuantization( - entry: DownloadEntry, - isImage: boolean, - metadata: Record | null, -): string { - if (!isImage) return entry.quantization; - return metadata?.imageModelBackend === 'coreml' ? 'Core ML' : ''; -} - -/** A start waiting for a concurrency slot (no native downloadId yet) → a "Queued" - * active item. status 'pending' renders as "Queued" in the item row. */ -function queuedToActiveItem(q: { modelKey: string; modelId: string; fileName: string; modelType: string; totalBytes: number }): DownloadItem { - return { - type: 'active', - modelType: q.modelType as DownloadItem['modelType'], - modelKey: q.modelKey, - // Match getActiveItemModelId: text routes/dedups on the modelKey (repo/file), the - // same id the finished model carries; other types pass the modelId through. - modelId: q.modelType === 'text' ? q.modelKey : q.modelId, - fileName: q.fileName, - author: '', - quantization: '', - fileSize: q.totalBytes, - bytesDownloaded: 0, - progress: 0, - status: 'pending', - }; -} - -function entryToActiveItem(entry: DownloadEntry): DownloadItem { - const metadata = parseEntryMetadata(entry); - const isImage = entry.modelType === 'image'; - - return { - type: 'active', - modelType: entry.modelType, - downloadId: entry.downloadId, - modelKey: entry.modelKey, - modelId: getActiveItemModelId(entry, isImage), - fileName: getActiveItemFileName(entry, isImage, metadata), - author: getActiveItemAuthor(entry, isImage, metadata), - quantization: getActiveItemQuantization(entry, isImage, metadata), - fileSize: entry.combinedTotalBytes || entry.totalBytes, - bytesDownloaded: entry.bytesDownloaded + (entry.mmProjBytesDownloaded ?? 0), - progress: entry.progress, - status: entry.status, - reason: entry.errorMessage, - reasonCode: entry.errorCode as import('../../types').BackgroundDownloadReasonCode | undefined, - }; -} - -/** Map the text + image model stores into completed Download Manager items. */ -function modelStoreCompletedItems( - downloadedModels: DownloadedModel[], - downloadedImageModels: ONNXImageModel[], -): DownloadItem[] { - return [ - ...downloadedModels.map((model): DownloadItem => { - const totalSize = hardwareService.getModelTotalSize(model); - return { - type: 'completed', modelType: 'text', modelId: model.id, fileName: model.fileName, - author: model.author, quantization: model.quantization, fileSize: totalSize, - bytesDownloaded: totalSize, progress: 1, status: 'completed', - downloadedAt: model.downloadedAt, filePath: model.filePath, - isVisionModel: model.engine === 'llama' ? model.isVisionModel : undefined, - mmProjPath: model.engine === 'llama' ? model.mmProjPath : undefined, - mmProjFileName: model.engine === 'llama' ? model.mmProjFileName : undefined, - name: model.name, - }; - }), - ...downloadedImageModels.map((model): DownloadItem => ({ - type: 'completed', modelType: 'image', modelId: model.id, fileName: model.name, - author: 'Image Generation', quantization: '', fileSize: model.size, - bytesDownloaded: model.size, progress: 1, status: 'completed', filePath: model.modelPath, - })), - ]; -} - export function useDownloadManager(): UseDownloadManagerResult { const [alertState, setAlertState] = useState(initialAlertState); const repairingVisionIds = useDownloadStore(s => s.repairingVisionIds); const setRepairingVision = useDownloadStore(s => s.setRepairingVision); - const { - downloadedModels, - setDownloadedModels, - downloadedImageModels, - } = useAppStore(); + const { downloadedModels, setDownloadedModels, downloadedImageModels } = + useAppStore(); const downloads = useDownloadStore(state => state.downloads); const removeDownloadEntry = useDownloadStore(state => state.remove); @@ -165,17 +57,26 @@ export function useDownloadManager(): UseDownloadManagerResult { // on store changes (a completing download drains the queue) and on a light poll. const [queuedItems, setQueuedItems] = useState([]); useEffect(() => { - const refresh = () => setQueuedItems(backgroundDownloadService.getQueuedItems().map(queuedToActiveItem)); + const refresh = () => + setQueuedItems( + backgroundDownloadService.getQueuedItems().map(queuedToActiveItem), + ); // On the light poll, also reconcile the concurrency accounting against the native // truth so a leaked slot (e.g. a folded mmproj sidecar) is reclaimed and a stuck // Queued download starts — without waiting for a new start to trigger it. - const reconcileAndRefresh = () => { backgroundDownloadService.reconcileActiveIds().catch(() => {}); refresh(); }; + const reconcileAndRefresh = () => { + backgroundDownloadService.reconcileActiveIds().catch(() => {}); + refresh(); + }; refresh(); // The service owns the queue and notifies on every control op (incl. cancelling a // queued start), so a cancel drops the "Queued" row immediately, not on the poll. const unsubscribe = modelDownloadService.subscribe(refresh); const t = setInterval(reconcileAndRefresh, 1000); - return () => { unsubscribe(); clearInterval(t); }; + return () => { + unsubscribe(); + clearInterval(t); + }; // Mount once: the subscription already fires on every store change (that's what // drains the queue) and the interval covers the rest. Depending on `downloads` here // tore down + rebuilt the subscription and interval on EVERY progress tick — pure @@ -183,7 +84,8 @@ export function useDownloadManager(): UseDownloadManagerResult { }, []); // Voice (TTS) + transcription (STT) downloaded models, loaded from disk. - const { voiceItems, buildDeleteAlert: buildVoiceDeleteAlert } = useVoiceDownloadItems(() => setAlertState(hideAlert())); + const { voiceItems, buildDeleteAlert: buildVoiceDeleteAlert } = + useVoiceDownloadItems(() => setAlertState(hideAlert())); // Inject the UI-coupled image cancel/retry into the image provider so control ops // route through the single download service (which logs every [DL-SM] action). @@ -195,15 +97,30 @@ export function useDownloadManager(): UseDownloadManagerResult { removeDownloadEntry(entry.modelKey); if (entry.downloadId.startsWith('image-multi:')) { await cancelSyntheticImageDownload(modelId).catch(() => {}); - const rows = await backgroundDownloadService.getActiveDownloads().catch(() => [] as any[]); - await Promise.all(rows.filter(r => r.modelId === `image:${modelId}`) - .map(r => backgroundDownloadService.cancelDownload(r.downloadId).catch(() => {}))); + const rows = await backgroundDownloadService + .getActiveDownloads() + .catch(() => [] as any[]); + await Promise.all( + rows + .filter(r => r.modelId === `image:${modelId}`) + .map(r => + backgroundDownloadService + .cancelDownload(r.downloadId) + .catch(() => {}), + ), + ); } else { - await backgroundDownloadService.cancelDownload(entry.downloadId).catch(() => {}); + await backgroundDownloadService + .cancelDownload(entry.downloadId) + .catch(() => {}); } }, retry: async (_modelId, entry) => { - await retryImageDownload(entryToActiveItem(entry), entry, setAlertState); + await retryImageDownload( + entryToActiveItem(entry), + entry, + setAlertState, + ); }, }); }, [removeDownloadEntry]); @@ -215,7 +132,8 @@ export function useDownloadManager(): UseDownloadManagerResult { * the store keys whisper rows `whisper-` but the provider lists them as the bare * `stt:`, so the raw id missed and the service REFUSED it as not-found. */ - const idOf = (item: DownloadItem): string => uniformDownloadId(item.modelType, item.modelId); + const idOf = (item: DownloadItem): string => + uniformDownloadId(item.modelType, item.modelId); // voiceItems (TTS/STT) carries BOTH finished and in-flight rows: a completed model // is type:'completed', while a downloading or failed one is type:'active'. Route by @@ -251,10 +169,19 @@ export function useDownloadManager(): UseDownloadManagerResult { // Include the in-flight/failed voice rows here so they render in Active Downloads // (ActiveDownloadCard shows their live progress bar / Retry). Dedup against // completedIds so a voice model that also has a completed row can't double-list. - const voiceActiveDeduped = voiceActive.filter(item => !completedIds.has(idOf(item))); - const activeItems: DownloadItem[] = [...startedItems, ...queuedActive, ...voiceActiveDeduped]; + const voiceActiveDeduped = voiceActive.filter( + item => !completedIds.has(idOf(item)), + ); + const activeItems: DownloadItem[] = [ + ...startedItems, + ...queuedActive, + ...voiceActiveDeduped, + ]; - const totalStorageUsed = completedItems.reduce((sum, item) => sum + item.fileSize, 0); + const totalStorageUsed = completedItems.reduce( + (sum, item) => sum + item.fileSize, + 0, + ); const executeRemoveDownload = async (item: DownloadItem) => { setAlertState(hideAlert()); @@ -278,22 +205,32 @@ export function useDownloadManager(): UseDownloadManagerResult { await modelDownloadService.retry(idOf(item)); } catch (error: any) { logger.error('[DownloadManager] Failed to retry download:', error); - const errorMessage = error?.message || 'Retry failed. Please remove and re-download.'; - if (item.downloadId) useDownloadStore.getState().setStatus(item.downloadId, 'failed', { - message: errorMessage, - }); + const errorMessage = + error?.message || 'Retry failed. Please remove and re-download.'; + if (item.downloadId) + useDownloadStore.getState().setStatus(item.downloadId, 'failed', { + message: errorMessage, + }); } }; const handleRemoveDownload = (item: DownloadItem) => { - setAlertState(showAlert( - 'Remove Download', - 'Are you sure you want to remove this download?', - [ - { text: 'No', style: 'cancel' }, - { text: 'Yes', style: 'destructive', onPress: () => { executeRemoveDownload(item); } }, - ], - )); + setAlertState( + showAlert( + 'Remove Download', + 'Are you sure you want to remove this download?', + [ + { text: 'No', style: 'cancel' }, + { + text: 'Yes', + style: 'destructive', + onPress: () => { + executeRemoveDownload(item); + }, + }, + ], + ), + ); }; const executeDeleteModel = async (model: DownloadedModel) => { @@ -328,68 +265,92 @@ export function useDownloadManager(): UseDownloadManagerResult { if (item.modelType === 'image') { const model = downloadedImageModels.find(m => m.id === item.modelId); if (!model) return; - setAlertState(showAlert( - 'Delete Image Model', - `Are you sure you want to delete "${model.name}"? This will free up ${formatBytes(model.size)}.`, - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: () => { executeDeleteImageModel(model); } }, - ], - )); + setAlertState( + showAlert( + 'Delete Image Model', + `Are you sure you want to delete "${ + model.name + }"? This will free up ${formatBytes(model.size)}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + executeDeleteImageModel(model); + }, + }, + ], + ), + ); } else { const model = downloadedModels.find(m => m.id === item.modelId); if (!model) return; const totalSize = hardwareService.getModelTotalSize(model); - setAlertState(showAlert( - 'Delete Model', - `Are you sure you want to delete "${model.fileName}"? This will free up ${formatBytes(totalSize)}.`, - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: () => { executeDeleteModel(model); } }, - ], - )); + setAlertState( + showAlert( + 'Delete Model', + `Are you sure you want to delete "${ + model.fileName + }"? This will free up ${formatBytes(totalSize)}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + executeDeleteModel(model); + }, + }, + ], + ), + ); } }; + /** + * Repair a vision model's missing projector. + * + * This used to rebuild a Hugging Face repo id by splitting the LOCAL display id at its last + * slash, which only works for a model whose id happens to be a repo path. A model that arrived + * by device transfer, or was imported from storage, produced a repo id Hugging Face has never + * seen - and HF answers an unknown repo with 401, so the user was shown a raw auth error for a + * file that never had an upstream at all. + * + * The service decides where the projector can come from now (recorded provenance, then a + * projector already on disk, then a size-verified HF match) and reports which, so every outcome + * here says something true rather than leaking a status code. + */ const handleRepairVision = (item: DownloadItem): void => { - const lastSlash = item.modelId.lastIndexOf('/'); - if (lastSlash < 0) return; - const repoId = item.modelId.substring(0, lastSlash); - const fileName = item.modelId.substring(lastSlash + 1); + const model = downloadedModels.find(m => m.id === item.modelId); + if (!model) return; setRepairingVision(item.modelId, true); logger.log('[DownloadDebug] Repair vision requested', { modelId: item.modelId, - fileName, currentMmProjPath: item.mmProjPath, currentMmProjFileName: item.mmProjFileName, }); - huggingFaceService.getModelFiles(repoId).then(async (files) => { - const file = files.find(f => f.name === fileName); - if (!file?.mmProjFile) { - setAlertState(showAlert( - 'No Vision File Available', - 'This model does not publish a separate vision projection file. Re-download the original (non-i1) variant if vision support is required.', - )); - return; - } - await modelManager.repairMmProj(repoId, file, {}); - const models = await modelManager.getDownloadedModels(); - setDownloadedModels(models); - logger.log('[DownloadDebug] Repair vision completed', { - modelId: item.modelId, - fileName, + modelManager + .repairVision(model) + .then(async outcome => { + setDownloadedModels(await modelManager.getDownloadedModels()); + logger.log('[DownloadDebug] Repair vision outcome', { + modelId: item.modelId, + outcome: outcome.kind, + }); + const [title, body] = visionRepairMessage(outcome, item.fileName); + setAlertState(showAlert(title, body)); + }) + .catch((e: Error) => { + logger.error('[DownloadDebug] Repair vision failed', { + modelId: item.modelId, + error: e.message, + }); + setAlertState(showAlert('Repair Failed', e.message)); + }) + .finally(() => { + setRepairingVision(item.modelId, false); }); - setAlertState(showAlert('Vision Repaired', `Vision file restored for ${item.fileName}. Reload the model to enable vision.`)); - }).catch((e: Error) => { - logger.error('[DownloadDebug] Repair vision failed', { - modelId: item.modelId, - fileName, - error: e.message, - }); - setAlertState(showAlert('Repair Failed', e.message)); - }).finally(() => { - setRepairingVision(item.modelId, false); - }); }; const isRepairingVision = (modelId: string) => !!repairingVisionIds[modelId]; From 219f162a4919c3c398fcbf439d2fa0b65b9e5a4b Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 13 Aug 2026 10:03:18 +0530 Subject: [PATCH 026/229] fix(models): a row stops advertising sight the model does not have The Vision badge came from a stored flag while the composer refused images - the two disagreed about the same model, which is what made a projector problem look like a chat bug. Sizes and busy states across these surfaces now read through the shared rules rather than being re-derived per screen. --- src/components/ModelRow/index.tsx | 5 +- .../ModelSelectorModal/ImageTab.tsx | 7 +- src/components/ModelSelectorModal/TextTab.tsx | 129 ++++++++-- src/components/models/ModelsManagerSheet.tsx | 9 +- src/components/models/ModelsSummaryRow.tsx | 5 +- src/components/models/WhisperPickerSheet.tsx | 5 +- .../components/ModelPickerSheet.tsx | 223 ++++++++++++++---- src/screens/ModelDownloadHelpers.tsx | 8 +- src/screens/ModelDownloadScreen.tsx | 4 +- src/screens/ModelsScreen/ImageModelsTab.tsx | 5 +- src/screens/ModelsScreen/TextModelsTab.tsx | 7 +- .../ModelsScreen/imageDownloadActions.ts | 5 +- src/screens/ModelsScreen/importHelpers.ts | 3 +- src/screens/ModelsScreen/useModelsScreen.ts | 3 +- src/screens/ModelsScreen/utils.ts | 3 +- src/screens/OrphanedFilesSection.tsx | 7 +- src/services/activeModelService/loaders.ts | 6 +- src/utils/acceleration.ts | 3 +- 18 files changed, 336 insertions(+), 101 deletions(-) diff --git a/src/components/ModelRow/index.tsx b/src/components/ModelRow/index.tsx index 8f98ee637..ba8c870df 100644 --- a/src/components/ModelRow/index.tsx +++ b/src/components/ModelRow/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { LoadingDots } from '../LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; import { createModelRowStyles } from './styles'; @@ -74,7 +75,7 @@ export const ModelRow: React.FC = ({ {!!ramHint && {ramHint}} {loading ? ( - + ) : isLoaded ? ( diff --git a/src/components/ModelSelectorModal/ImageTab.tsx b/src/components/ModelSelectorModal/ImageTab.tsx index eb17cc4d1..10624b72a 100644 --- a/src/components/ModelSelectorModal/ImageTab.tsx +++ b/src/components/ModelSelectorModal/ImageTab.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { LoadingDots } from '../LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; import { ONNXImageModel, RemoteModel } from '../../types'; @@ -61,7 +62,7 @@ export const ImageTab: React.FC = ({ {isLoadingImage ? ( - + ) : ( <> @@ -126,7 +127,7 @@ export const ImageTab: React.FC = ({ {isLoadingThis ? ( - + ) : (isCurrent && !loadInProgress) ? ( diff --git a/src/components/ModelSelectorModal/TextTab.tsx b/src/components/ModelSelectorModal/TextTab.tsx index 3cf416451..403ed6a9e 100644 --- a/src/components/ModelSelectorModal/TextTab.tsx +++ b/src/components/ModelSelectorModal/TextTab.tsx @@ -8,10 +8,15 @@ import { textOverheadMultiplier } from '../../services/activeModelService/types' import { useAppStore } from '../../stores'; import { ModelRow } from '../ModelRow'; import { createAllStyles } from './styles'; +import { predictGgufCapabilities } from '../../utils/ggufCapabilities'; export interface TextTabProps { downloadedModels: DownloadedModel[]; - remoteModels: Array<{ serverId: string; serverName: string; models: RemoteModel[] }>; + remoteModels: Array<{ + serverId: string; + serverName: string; + models: RemoteModel[]; + }>; currentModelPath: string | null; /** The SELECTED model's path (may differ from loaded under deferred loading). */ selectedModelPath?: string | null; @@ -27,7 +32,18 @@ export interface TextTabProps { } export const TextTab: React.FC = ({ - downloadedModels, remoteModels, currentModelPath, selectedModelPath = null, currentRemoteModelId, isAnyLoading, loadingModelId = null, onSelectModel, onUnloadModel, onSelectRemoteModel, onAddServer, onBrowseModels, + downloadedModels, + remoteModels, + currentModelPath, + selectedModelPath = null, + currentRemoteModelId, + isAnyLoading, + loadingModelId = null, + onSelectModel, + onUnloadModel, + onSelectRemoteModel, + onAddServer, + onBrowseModels, }) => { const { colors } = useTheme(); const styles = useThemedStyles(createAllStyles); @@ -35,7 +51,9 @@ export const TextTab: React.FC = ({ // activeModelService uses to register the resident's sizeMB, so this label and the residency // chip on the manager sheet agree for the identical loaded model (they diverged: fixed 1.5× // here vs 2.2× on a GPU/NPU backend there — device 2026-07-14). - const ramMultiplier = textOverheadMultiplier(useAppStore(s => s.settings?.inferenceBackend)); + const ramMultiplier = textOverheadMultiplier( + useAppStore(s => s.settings?.inferenceBackend), + ); // "Loaded" drives the Currently-Loaded + Unload section (only meaningful once a model // is actually in memory). "Active" also counts the selected-but-not-yet-loaded model // so the switcher reads "Switch Model" and highlights the active choice under deferred @@ -43,7 +61,9 @@ export const TextTab: React.FC = ({ const hasLoaded = currentModelPath !== null || currentRemoteModelId !== null; const activeLocalPath = currentModelPath ?? selectedModelPath; const hasActive = activeLocalPath !== null || currentRemoteModelId !== null; - const activeLocalModel = downloadedModels.find(m => m.filePath === currentModelPath); + const activeLocalModel = downloadedModels.find( + m => m.filePath === currentModelPath, + ); // Find active remote model info const activeRemoteModelInfo = useMemo(() => { @@ -65,16 +85,36 @@ export const TextTab: React.FC = ({ - - {activeLocalModel?.name || activeRemoteModelInfo?.model?.name || 'Unknown'} + + {activeLocalModel?.name || + activeRemoteModelInfo?.model?.name || + 'Unknown'} - + {activeLocalModel - ? `${activeLocalModel.quantization} • ${hardwareService.formatModelSize(activeLocalModel)} • ${hardwareService.formatModelRam(activeLocalModel, ramMultiplier)} RAM` + ? `${ + activeLocalModel.quantization + } • ${hardwareService.formatModelSize( + activeLocalModel, + )} • ${hardwareService.formatModelRam( + activeLocalModel, + ramMultiplier, + )} RAM` : `Remote • ${activeRemoteModelInfo?.serverName ?? 'Model'}`} - + Unload @@ -82,23 +122,51 @@ export const TextTab: React.FC = ({ )} - {hasActive ? 'Switch Model' : 'Available Models'} + + {hasActive ? 'Switch Model' : 'Available Models'} + {/* Empty state when no models at all */} {downloadedModels.length === 0 && remoteModels.length === 0 && ( No Text Models - Download models from the Models tab + + Download models from the Models tab + - + - Add Remote Server + + Add Remote Server + {onBrowseModels && ( - + - Browse Models + + Browse Models + )} @@ -112,19 +180,24 @@ export const TextTab: React.FC = ({ Local Models - {downloadedModels.map((model) => { + {downloadedModels.map(model => { const isLoaded = currentModelPath === model.filePath; // The selected-but-not-loaded model is highlighted as active, but stays // tappable so tapping it actually loads it (load-on-tap). // Don't highlight a deferred-local selection while a remote model is // current — otherwise both rows render active after a local→remote switch. - const isSelected = currentRemoteModelId === null && !currentModelPath && selectedModelPath === model.filePath; + const isSelected = + currentRemoteModelId === null && + !currentModelPath && + selectedModelPath === model.filePath; // While a load is in flight, the highlight + spinner + (suppressed) checkmark all follow the // row being loaded — not the model that's still resident. So tapping B moves the selection to // B immediately, instead of leaving A highlighted until the load finishes (device 2026-07-14). const isLoadingThis = loadingModelId === model.id; const loadInProgress = loadingModelId != null; - const isActive = loadInProgress ? isLoadingThis : (isLoaded || isSelected); + const isActive = loadInProgress + ? isLoadingThis + : isLoaded || isSelected; return ( = ({ name={model.name} size={hardwareService.formatModelSize(model)} quant={model.quantization} - isVision={model.engine === 'llama' && model.isVisionModel} + isVision={ + model.engine === 'llama' && + predictGgufCapabilities(model).vision + } isActive={isActive} isLoaded={isLoaded && !loadInProgress} loading={isLoadingThis} @@ -151,17 +227,26 @@ export const TextTab: React.FC = ({ {serverName} - {models.map((model) => { + {models.map(model => { const isCurrent = currentRemoteModelId === model.id; return ( onSelectRemoteModel(model, serverId)} disabled={isAnyLoading || isCurrent} > - + {model.name} diff --git a/src/components/models/ModelsManagerSheet.tsx b/src/components/models/ModelsManagerSheet.tsx index 839bd1163..126d82617 100644 --- a/src/components/models/ModelsManagerSheet.tsx +++ b/src/components/models/ModelsManagerSheet.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import { View, Text, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { LoadingDots } from '../LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { AppSheet } from '../../components/AppSheet'; import { AnimatedPressable } from '../../components/AnimatedPressable'; @@ -86,7 +87,7 @@ export const ModelsManagerSheet: React.FC = ({ {/* Fixed-width eject column right of the label so all four rows align; empty when not resident. */} {resident && (ejectingRow === row.type - ? + ? : ( = ({ )} {isLoading - ? + ? : } ); @@ -126,7 +127,7 @@ export const ModelsManagerSheet: React.FC = ({ onPress={onEject} > {isEjecting - ? + ? : } Eject All Models diff --git a/src/components/models/ModelsSummaryRow.tsx b/src/components/models/ModelsSummaryRow.tsx index 454d6c1b5..82d286c61 100644 --- a/src/components/models/ModelsSummaryRow.tsx +++ b/src/components/models/ModelsSummaryRow.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, ActivityIndicator } from 'react-native'; +import { View, Text } from 'react-native'; +import { LoadingDots } from '../LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { AnimatedPressable } from '../../components/AnimatedPressable'; import { useTheme, useThemedStyles } from '../../theme'; @@ -37,7 +38,7 @@ export const ModelsSummaryRow: React.FC = ({ labels, counts, isLoading, o Models {isLoading - ? + ? : } diff --git a/src/components/models/WhisperPickerSheet.tsx b/src/components/models/WhisperPickerSheet.tsx index be1229425..a99639ad8 100644 --- a/src/components/models/WhisperPickerSheet.tsx +++ b/src/components/models/WhisperPickerSheet.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; -import { View, Text, ActivityIndicator } from 'react-native'; +import { View, Text } from 'react-native'; +import { LoadingDots } from '../LoadingDots'; import Icon from 'react-native-vector-icons/Feather'; import { AppSheet } from '../../components/AppSheet'; import { AnimatedPressable } from '../../components/AnimatedPressable'; @@ -70,7 +71,7 @@ export const WhisperPickerSheet: React.FC = ({ visible, onClose }) => { if (dl?.downloading) return {Math.round(dl.progress * 100)}%; // selectModel sets downloadedModelId optimistically, so the active row IS the one loading — // show a spinner on it while it loads (not a premature checkmark), matching text/image. - if (active && isModelLoading) return ; + if (active && isModelLoading) return ; if (active) return ; if (present) { return ( diff --git a/src/screens/HomeScreen/components/ModelPickerSheet.tsx b/src/screens/HomeScreen/components/ModelPickerSheet.tsx index b3bd5d19d..7b3eebde8 100644 --- a/src/screens/HomeScreen/components/ModelPickerSheet.tsx +++ b/src/screens/HomeScreen/components/ModelPickerSheet.tsx @@ -1,5 +1,12 @@ import React, { useMemo } from 'react'; -import { View, Text, ScrollView, TouchableOpacity, Animated, StyleSheet } from 'react-native'; +import { + View, + Text, + ScrollView, + TouchableOpacity, + Animated, + StyleSheet, +} from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { AppSheet } from '../../../components/AppSheet'; import { Button, ModelRow } from '../../../components'; @@ -11,6 +18,7 @@ import { getMmProjFileSize } from '../../../utils/modelHelpers'; import { DownloadedModel, ONNXImageModel, RemoteModel } from '../../../types'; import { ModelPickerType, LoadingState } from '../hooks/useHomeScreen'; import { useRemoteServerStore } from '../../../stores'; +import { predictGgufCapabilities } from '../../../utils/ggufCapabilities'; type Props = { pickerType: ModelPickerType; @@ -39,29 +47,62 @@ type Props = { type ImageTabColors = ReturnType['colors']; type ImageTabStyles = ReturnType; -type ImageTabProps = Pick & { colors: ImageTabColors; styles: ImageTabStyles }; +type ImageTabProps = Pick< + Props, + | 'downloadedImageModels' + | 'activeImageModelId' + | 'memoryInfo' + | 'loadingState' + | 'onUnloadImageModel' + | 'onSelectImageModel' + | 'onBrowseModels' +> & { colors: ImageTabColors; styles: ImageTabStyles }; -const ImageTabContent: React.FC = ({ downloadedImageModels, activeImageModelId, memoryInfo, loadingState, onUnloadImageModel, onSelectImageModel, onBrowseModels, colors, styles }) => { +const ImageTabContent: React.FC = ({ + downloadedImageModels, + activeImageModelId, + memoryInfo, + loadingState, + onUnloadImageModel, + onSelectImageModel, + onBrowseModels, + colors, + styles, +}) => { if (downloadedImageModels.length === 0) { return ( No image models available -