diff --git a/.gitignore b/.gitignore
index 128382e1..24491c1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,6 +112,9 @@ weight.bin
# but must never be committed.
RunAnywhereAI/Resources/RunAnywhereLocalSecrets.plist
+# JetBrains project files.
+.idea/
+
# Claude / cross-agent tooling. `.claude/skills/` (if present) is the canonical, human-edited
# skill tree; `.agents/skills/` is a generated mirror for non-Claude tooling (e.g. Codex), kept
# local/ignored for now pending a decision on what is safe to publish from this public repo.
diff --git a/README.md b/README.md
index e3b2cdca..ef4b121f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# RunAnywhere AI iOS and macOS example
+# RunAnywhere AI for iOS and macOS
@@ -8,183 +8,156 @@
+
+
+
-A reference app for the [RunAnywhere Swift SDK](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/bindings/swift/README.md):
-LLM chat, speech, vision, voice agents, RAG, benchmarks, and model management, running
-on-device on iPhone, iPad, and Mac.
+
+
+
+
+
+
-## Requirements
+The RunAnywhere consumer app for iPhone, iPad, and Mac, written in Swift.
-| Item | Minimum |
-|---|---|
-| Xcode | 26+, with Swift 6.2 and iOS 17.5 simulator runtimes |
-| Platforms | iOS 17.5, macOS 14.5 |
-| Command line tools | Selected in Xcode, Settings, Locations |
-| Disk | Several GB for SDK artifacts and models |
-| Device | Apple Silicon recommended; MLX needs a physical device or native macOS |
+Ask it questions, talk to it, or show it what your camera sees. The models run on the device
+itself, so your prompts and photos never leave it, and everything still works in airplane
+mode.
-## Setup
+The one exception is Connect, described below, where you deliberately host a model on your
+own Mac and use it from your iPhone. In that mode the request travels to that Mac. It still
+reaches no third party.
-There is no monorepo checkout to build and no XCFramework to stage. SwiftPM downloads the
-checksum-verified native archives during resolve.
+## Get it
-```bash
-git clone https://github.com/RunanywhereAI/runanywhere-ios.git
-cd runanywhere-ios
-swift package resolve
-```
+| Platform | Where |
+| --- | --- |
+| iPhone, iPad | [App Store](https://apps.apple.com/us/app/runanywhere/id6756506307) |
+| Mac | [Signed `.dmg`](https://github.com/RunanywhereAI/runanywhere-ios/releases/latest), notarized by Apple, macOS 14.5 or newer |
-`Package.swift` declares one dependency, and the Xcode project mirrors it:
+To install on a Mac, open the `.dmg` and drag RunAnywhereAI to Applications.
-```swift
-.package(
- url: "https://github.com/RunanywhereAI/runanywhere-swift.git",
- from: "0.20.19"
-)
-```
+
-`runanywhere-swift` is a Swift-only SwiftPM distribution generated from the
-`runanywhere-sdks` monorepo. Consume it rather than the monorepo: it is a few MB instead
-of a few hundred, and it carries the generated proto sources that the monorepo no longer
-commits. Its tags are bare semver with no `v` prefix, which is what `from:` needs. The
-XCFramework binary targets still point at the checksum-verified release assets on
-`runanywhere-sdks`.
+## What it looks like
-The five products it publishes, all of which this app links:
+Captured on an iPhone 17 Pro simulator, running a small GGUF chat model through the
+llama.cpp backend.
-| Product | Role |
+| | |
|---|---|
-| `RunAnywhere` | Core SDK, always required |
-| `RunAnywhereLlamaCPP` | llama.cpp backend: LLM, VLM |
-| `RunAnywhereONNX` | Sherpa-ONNX backend: STT, TTS, VAD |
-| `RunAnywhereMLX` | Apple MLX backend, physical device or native macOS |
-| `RunAnywhereNeuRT` | Apple Neural Engine backend |
+|  |  |
+| Model loaded and ready. The header shows which one is active and that it is local. | An answer, with tokens per second and wall time under it. Nothing left the device. |
+|  |  |
+| Models are grouped by who published them. The picker recommends one for the device, and can pull any GGUF from Hugging Face. | Everything beyond chat lives here, grouped by what it does. |
+|  |  |
+| Segmentation outlines objects in a photo and labels them. | Settings covers the system prompt, sampling, tool calling, and local storage. |
-Three files have to agree on the version: `Package.swift` (`from:`), the Xcode project's
-package reference (`upToNextMajorVersion` from the same minimum), and `Package.resolved`,
-which records the exact version and commit resolve selected. `Package.resolved` is
-committed and CI fails if a fresh resolve leaves it dirty.
+The image files are in [`docs/screenshots/`](docs/screenshots).
-To take a newer SDK release within the same major, run `swift package update` and commit
-the refreshed `Package.resolved`. To require a newer minimum, bump the version in
-`Package.swift` and in the Xcode project's package reference, then resolve again. If
-resolution misbehaves, use File, Packages, Reset Package Caches first.
+## What you can do
-## Build and run
+| | |
+| --- | --- |
+| **Chat** | Streaming conversation with thinking mode, tool calling, and document attachments |
+| **Talk** | A spoken conversation. Speech in, model, speech back out |
+| **Vision** | Ask about a photo, or point the camera and ask about what it sees live |
+| **Transcribe** | Turn recordings or live speech into text |
+| **Read aloud** | Neural Piper voices speak any text you give them |
+| **Documents** | Drop in a file and ask questions about what is inside it |
+| **Voice keyboard** | Dictate into any app through the keyboard extension |
+| **Connect** | Host a model on your Mac and use it from your iPhone |
+| **Benchmarks** | Measure what your own hardware actually does |
-Open `RunAnywhereAI.xcodeproj` and press ⌘R, or:
+Chat is the app. Everything else sits behind an Advanced hub, reached from the chat on
+iPhone and from the sidebar on Mac.
-```bash
-./scripts/build_and_run_ios_sample.sh simulator "iPhone 16 Pro"
-./scripts/build_and_run_ios_sample.sh device
-./scripts/build_and_run_ios_sample.sh mac
-```
-
-`./scripts/verify.sh` resolves the package and runs a full simulator `xcodebuild`, which is
-the slow half of CI. `./scripts/smoke.sh` is the fast half: it greps the sources for SDK
-call patterns and checks the Parakeet CTC catalog entry, without compiling.
+## Models
-Runtime logs:
+The picker groups models by publisher, so you pick a name you recognise and then a size. It
+carries current-generation open models across chat, vision, speech, and embedding, from a
+230M model that answers instantly to larger ones a Mac can hold. Sizes shown are measured,
+not estimated, and the app checks each one against your device before recommending it.
-```bash
-log stream --predicate 'subsystem CONTAINS "com.runanywhere"' --info --debug
-```
-
-Most loggers use the `com.runanywhere.RunAnywhereAI` subsystem, a couple use plain
-`com.runanywhere`, and the SDK logs under its own, so match on the prefix.
+You can also paste any GGUF repo from Hugging Face and it will be fetched and registered
+alongside the rest.
-## Tests
+## Build it yourself
-Unit tests live in `RunAnywhereAIUnitTests/` and build into the `RunAnywhereAITests`
-target; the XCUITest launch test lives in `RunAnywhereAIUITests/`. Both need a booted
-simulator:
+There is no monorepo to clone and no XCFramework to stage. SwiftPM downloads the
+checksum-verified native archives when it resolves.
```bash
-xcodebuild test \
- -project RunAnywhereAI.xcodeproj \
- -scheme RunAnywhereAI \
- -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
- -only-testing:RunAnywhereAITests
+git clone https://github.com/RunanywhereAI/runanywhere-ios.git
+cd runanywhere-ios
+swift package resolve
+open RunAnywhereAI.xcodeproj # then press ⌘R
```
-Drop `-only-testing:` to run the UI test as well.
-
-## Continuous integration
-
-`.github/workflows/ci.yml` runs on pushes and pull requests against `main`. It checks out a
-clean clone on `macos-latest` (the macOS 26 arm64 image, the line carrying Xcode 26, which
-`swift-tools-version: 6.2` requires), then:
-
-1. resolves the SDK remotely, to prove no monorepo checkout is needed, and fails if the
- resolve left `Package.resolved` dirty (i.e. the committed pin was stale);
-2. builds the `RunAnywhereAI` scheme for `generic/platform=iOS Simulator`, which pulls in
- the keyboard and Live Activity extensions;
-3. runs `-only-testing:RunAnywhereAITests` on a booted simulator;
-4. runs `./scripts/smoke.sh`.
-
-Signing is off, since a simulator build needs no identity and hosted runners have no
-`DEVELOPMENT_TEAM`.
-
-## Features
-
-Chat is the app. Everything else sits behind an Advanced hub, reached from the chat on iOS
-and from the sidebar on macOS.
-
-| Feature | Description | Platforms |
-|---|---|---|
-| Chat | Streaming LLM with thinking mode, tool calling, document attachments, and LoRA adapters | iOS, macOS |
-| Speech to text | Batch, live, and hybrid transcription (Sherpa-ONNX, Whisper) | iOS, macOS |
-| Text to speech | Neural Piper voices | iOS, macOS |
-| Talk | Full STT, LLM, TTS voice agent with a Metal particle UI | iOS, macOS |
-| Vision | Camera and photo-library image understanding, including a live mode | iOS, macOS |
-| Diarization | Who spoke when in a recording | iOS |
-| Segmentation | Labelled photo regions | iOS |
-| Computer use | The model reads a screenshot and acts on it | iOS, macOS |
-| Connect | Host a model on a Mac and use it from your other devices | Host: macOS. Client: iOS |
-| Benchmarks | Deterministic LLM, STT, TTS, and VLM performance tests | iOS, macOS |
-| Voice keyboard | Keyboard extension with a cross-process dictation flow | iOS |
-| Model management | Download, load, storage, and deletion, plus Hugging Face import | iOS, macOS |
-
-MLX-backed models run on physical iOS devices and native macOS. On the arm64 simulator
-`MLX.register()` returns false, so the build validates packaging and startup but runs no
-MLX inference and seeds no MLX catalog entries.
-
-## Layout
+Or from the command line:
-`RunAnywhereAI/` holds the app: `App/` (entry point and platform shells), `Features/`,
-`Core/` (design system, services, models), and `Helpers/`. `RunAnywhereKeyboard/` and
-`RunAnywhereActivityExtension/` are the two extension targets. The app and the keyboard
-deploy to iOS 17.5; the Live Activity extension needs iOS 26.2, so on older systems it
-simply does not load.
-
-Architecture is MVVM with Swift Observation, one `RunAnywhere.*` entry point per modality,
-and centralized design tokens around brand orange `#FF6900`. `AGENTS.md` has the full
-reference.
-
-## Troubleshooting
-
-| Symptom | Fix |
-|---|---|
-| Missing XCFramework errors | Reset package caches and rerun `swift package resolve` so SwiftPM re-downloads the release archives |
-| Package resolution failures | Same: reset caches, resolve again |
-| Sandbox or derived-data issues | Clean the build folder (⇧⌘K), delete DerivedData if it persists |
-| MLX unavailable | Use a physical device or native macOS; MLX reports unavailable on the simulator |
+```bash
+./scripts/build_and_run_ios_sample.sh simulator "iPhone 17 Pro"
+./scripts/build_and_run_ios_sample.sh device
+./scripts/build_and_run_ios_sample.sh mac
+```
-## Links
+You need Xcode 26 or newer with Swift 6.2, and a few GB of disk for the SDK artifacts plus
+whichever models you download. MLX models need a physical device or a native Mac. On the
+simulator `MLX.register()` returns false, so the build validates packaging and startup but
+runs no MLX inference.
+
+[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) covers version pinning, tests, CI, and
+troubleshooting.
+
+## Architecture
+
+One dependency supplies everything. The app links five products from
+[`runanywhere-swift`](https://github.com/RunanywhereAI/runanywhere-swift), the Swift-only
+SwiftPM distribution generated from the SDK monorepo, currently pinned at `0.20.24`.
+
+```text
+ RunAnywhereAI
+ SwiftUI, MVVM + Observation
+ │
+ ┌───────────┴────────────┐
+ │ runanywhere-swift │ one package, five products
+ └───────────┬────────────┘
+ │
+ ┌────────────┬───┴────┬─────────────┬──────────────┐
+ │ │ │ │ │
+RunAnywhere LlamaCPP ONNX MLX NeuRT
+ core LLM · VLM STT·TTS·VAD device or Apple Neural
+ native Mac Engine
+ │
+ ▼
+ C++ commons, one core
+ shared with Kotlin, Web, and Electron
+```
-| Resource | Link |
-|---|---|
-| Swift SDK | [bindings/swift](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/bindings/swift/README.md) |
-| Android example | [runanywhere-android](https://github.com/RunanywhereAI/runanywhere-android) |
-| Web example | [runanywhere-web](https://github.com/RunanywhereAI/runanywhere-web) |
-| Electron example | [runanywhere-electron](https://github.com/RunanywhereAI/runanywhere-electron) |
-| React Native example | [bindings/react-native/example](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/bindings/react-native/example/README.md) |
-| Flutter example | [bindings/flutter/example](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/bindings/flutter/example/README.md) |
-| App Store | [RunAnywhere](https://apps.apple.com/us/app/runanywhere/id6756506307) |
+Business logic lives in the SDK rather than here. The app is SwiftUI views, view models, and
+thin `RunAnywhere.*` calls, one entry point per modality.
+
+| Reference | |
+| --- | --- |
+| Per-feature behavior and SDK surface | [`docs/reference/FEATURES.md`](docs/reference/FEATURES.md) |
+| Navigation and shell structure | [`docs/reference/ARCHITECTURE.md`](docs/reference/ARCHITECTURE.md) |
+| Building, pinning, tests, CI | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) |
+| Contributor conventions | [`AGENTS.md`](AGENTS.md) |
+
+## The other apps
+
+| Platform | Repo |
+| --- | --- |
+| Android, Kotlin | [runanywhere-android](https://github.com/RunanywhereAI/runanywhere-android) |
+| Windows, Electron | [runanywhere-electron](https://github.com/RunanywhereAI/runanywhere-electron) |
+| Web, TypeScript | [runanywhere-web](https://github.com/RunanywhereAI/runanywhere-web) |
+| SDK monorepo | [runanywhere-sdks](https://github.com/RunanywhereAI/runanywhere-sdks) |
+| Documentation | [docs.runanywhere.ai](https://docs.runanywhere.ai) |
| Discord | [discord.gg/N359FBbDVd](https://discord.gg/N359FBbDVd) |
-| Issues | [GitHub Issues](https://github.com/RunanywhereAI/runanywhere-ios/issues) |
-| Email | founders@runanywhere.ai |
## License
diff --git a/RunAnywhereAI/Core/Services/ModelCatalogBootstrap.swift b/RunAnywhereAI/Core/Services/ModelCatalogBootstrap.swift
index f7a1ae0e..f463abf6 100644
--- a/RunAnywhereAI/Core/Services/ModelCatalogBootstrap.swift
+++ b/RunAnywhereAI/Core/Services/ModelCatalogBootstrap.swift
@@ -31,56 +31,6 @@ enum ModelCatalogBootstrap {
logger.info("Registering modules with their models...")
#if canImport(LlamaCPPRuntime)
- // --- LLM models (LlamaCpp backend) ------------------------------------
- await registerLLM(
- id: "smollm2-360m-q8_0",
- name: "SmolLM2 360M Q8_0",
- url: "https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf",
- framework: .llamaCpp,
- memoryRequirement: 386_404_416
- )
- await registerLLM(
- id: "llama-2-7b-chat-q4_k_m",
- name: "Llama 2 7B Chat Q4_K_M",
- url: "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 4_000_000_000
- )
- await registerLLM(
- id: "mistral-7b-instruct-q4_k_m",
- name: "Mistral 7B Instruct Q4_K_M",
- url: "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 4_000_000_000
- )
- await registerLLM(
- id: "qwen2.5-0.5b-instruct-q6_k",
- name: "Qwen 2.5 0.5B Instruct Q6_K",
- url: "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf",
- framework: .llamaCpp,
- memoryRequirement: 600_000_000,
- // Base model of the seeded abliterated adapter
- // (qwen2.5-0.5b-abliterated-lora-f16.gguf) — matches Android.
- supportsLora: true
- )
- await registerLLM(
- id: "qwen2.5-1.5b-instruct-q4_k_m",
- name: "Qwen 2.5 1.5B Instruct Q4_K_M",
- url: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf",
- framework: .llamaCpp,
- memoryRequirement: 2_500_000_000
- )
- // ONE quantization per model. The Q8_0 sibling of this row was removed
- // deliberately: two quants of the same 350M model differ only in bytes
- // (229 MB vs 379 MB), so the second row costs a catalog slot and a
- // "which one do I pick?" decision without adding a capability.
- await registerLLM(
- id: "lfm2-350m-q4_k_m",
- name: "LiquidAI LFM2 350M Q4_K_M",
- url: "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 250_000_000
- )
// LFM2.5-230M on the CPU. Q4_K_M, not the fractionally smaller Q4_0
// (149 MB vs 153 MB): 4 MB buys K-quant mixed precision on the
// attention/embedding tensors, and Q4_K_M is the quantization every
@@ -100,28 +50,6 @@ enum ModelCatalogBootstrap {
framework: .llamaCpp,
memoryRequirement: 900_000_000
)
- await registerLLM(
- id: "lfm2-1.2b-tool-q4_k_m",
- name: "LiquidAI LFM2 1.2B Tool Q4_K_M",
- url: "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 800_000_000
- )
- await registerLLM(
- id: "lfm2-1.2b-tool-q8_0",
- name: "LiquidAI LFM2 1.2B Tool Q8_0",
- url: "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q8_0.gguf",
- framework: .llamaCpp,
- memoryRequirement: 1_400_000_000
- )
- await registerLLM(
- id: "qwen3-0.6b-q4_k_m",
- name: "Qwen3 0.6B Q4_K_M",
- url: "https://huggingface.co/unsloth/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 500_000_000,
- supportsThinking: true
- )
// unsloth, not bartowski: the bartowski repo prefixes every artifact with
// the org (`Qwen_Qwen3.5-0.8B-Q4_K_M.gguf`), so the un-prefixed filename
// this row used to point at 404'd — the row was offered in the picker and
@@ -135,28 +63,12 @@ enum ModelCatalogBootstrap {
memoryRequirement: 620_000_000,
supportsThinking: true
)
- await registerLLM(
- id: "qwen3-1.7b-q4_k_m",
- name: "Qwen3 1.7B Q4_K_M",
- url: "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 1_200_000_000,
- supportsThinking: true
- )
- await registerLLM(
- id: "qwen3-4b-q4_k_m",
- name: "Qwen3 4B Q4_K_M",
- url: "https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 2_800_000_000,
- supportsThinking: true
- )
// Qwen3.8-27B, the newest dense Qwen release (unsloth-published GGUF,
// matching the rest of the Qwen3.x rows in this catalog).
await registerLLM(
id: "qwen3.8-27b-q4_k_m",
name: "Qwen3.8 27B Q4_K_M",
- url: "https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-Q4_K_M.gguf",
+ url: "https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-Q4_K_M.gguf",
framework: .llamaCpp,
// 17,106,775,008 B of weights plus KV cache and runtime overhead.
memoryRequirement: 18_800_000_000,
@@ -270,18 +182,6 @@ enum ModelCatalogBootstrap {
memoryRequirement: 3_803_452_480,
supportsThinking: true
)
- // NOTE: Ternary-Bonsai GGUF (Q2_0/PQ2_0) is intentionally NOT registered.
- // Verified via rcli this session: the pinned PrismML llama.cpp fork
- // (prism-b9591-62061f9) rejects it — "invalid ggml type 142" — it only
- // added Q1_0 (plain Bonsai) support, not Ternary-Bonsai's tensor encoding.
- // Re-enable once the fork adds it. Ternary-Bonsai MLX (below) works fine.
- await registerLLM(
- id: "llama-3.2-3b-instruct-q4_k_m",
- name: "Llama 3.2 3B Instruct Q4_K_M (Tool Calling)",
- url: "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf",
- framework: .llamaCpp,
- memoryRequirement: 2_000_000_000
- )
// Gemma 4 family, text-only (unsloth GGUF, no mmproj). Distinct from
// the Gemma 4 E2B/E4B VLM rows above (`ggml-org` repo, decoder+mmproj
// pairs) — those are multimodal registrations; these are plain
@@ -332,14 +232,6 @@ enum ModelCatalogBootstrap {
// 18,323,733,440 B of weights plus KV cache and runtime overhead.
memoryRequirement: 20_200_000_000
)
- await registerLLM(
- id: "gemma-4-31b-it-q2_k_xl",
- name: "Gemma 4 31B IT Q2_K_XL",
- url: "https://huggingface.co/unsloth/gemma-4-31B-it-GGUF/resolve/main/gemma-4-31B-it-UD-Q2_K_XL.gguf",
- framework: .llamaCpp,
- // 11,774,991,296 B of weights plus KV cache and runtime overhead.
- memoryRequirement: 13_500_000_000
- )
// IBM Granite 4.1 family, dense, Apache 2.0 (confirmed via HF
// cardData.license). unsloth GGUF across all three sizes.
await registerLLM(
@@ -373,21 +265,36 @@ enum ModelCatalogBootstrap {
)
logger.info("LLM models registered")
#endif
-
- // --- MLX models (Apple Metal, Hugging Face repo-folder bundles) -------
- await registerLLM(
- id: "mlx-qwen3-0.6b-4bit",
- name: "MLX Qwen3 0.6B 4bit",
- url: "https://huggingface.co/mlx-community/Qwen3-0.6B-4bit",
- framework: .mlx,
- memoryRequirement: 650_000_000,
- supportsThinking: true
- )
// This conversion declares model_type=llama, which is implemented by
// the linked MLXLLM factory. Keep the complete download manifest pinned
// to the reviewed Hub revision; the byte total below is exact.
let nemotronNano8BMLXBaseURL =
"https://huggingface.co/bourn23/nvidia-llama-3.1-nemotron-nano-8b-v1-mlx-4bit/resolve/00378e66048eadf358aad0f66c09e5c3750f8243"
+ // --- MLX models (Apple Metal, Hugging Face repo-folder bundles) -------
+ await registerLLM(
+ id: "mlx-qwen3.5-2b-4bit",
+ name: "MLX Qwen3.5 2B 4bit",
+ url: "https://huggingface.co/mlx-community/Qwen3.5-2B-MLX-4bit",
+ framework: .mlx,
+ memoryRequirement: 2_000_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "mlx-qwen3.5-4b-4bit",
+ name: "MLX Qwen3.5 4B 4bit",
+ url: "https://huggingface.co/mlx-community/Qwen3.5-4B-MLX-4bit",
+ framework: .mlx,
+ memoryRequirement: 3_600_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "mlx-qwen3.5-9b-4bit",
+ name: "MLX Qwen3.5 9B 4bit",
+ url: "https://huggingface.co/mlx-community/Qwen3.5-9B-MLX-4bit",
+ framework: .mlx,
+ memoryRequirement: 7_000_000_000,
+ supportsThinking: true
+ )
await registerMultiFile(
id: "mlx-llama-3.1-nemotron-nano-8b-v1-4bit",
name: "MLX NVIDIA Llama 3.1 Nemotron Nano 8B 4bit",
@@ -519,41 +426,6 @@ enum ModelCatalogBootstrap {
memoryRequirement: 5_129_115_752,
supportsThinking: true
)
- // PrismML Ternary-Bonsai family at ternary/2-bit MLX. Same PrismML
- // mlx-swift fork (bits=2 quantization support already covered by
- // upstream MLX 0.31.6 — no additional patch needed beyond bits=1).
- await registerLLM(
- id: "mlx-ternary-bonsai-1.7b-2bit",
- name: "MLX Ternary-Bonsai-1.7B 2-bit",
- url: "https://huggingface.co/prism-ml/Ternary-Bonsai-1.7B-mlx-2bit",
- framework: .mlx,
- memoryRequirement: 484_049_216,
- supportsThinking: true
- )
- await registerLLM(
- id: "mlx-ternary-bonsai-4b-2bit",
- name: "MLX Ternary-Bonsai-4B 2-bit",
- url: "https://huggingface.co/prism-ml/Ternary-Bonsai-4B-mlx-2bit",
- framework: .mlx,
- memoryRequirement: 1_131_565_944,
- supportsThinking: true
- )
- await registerLLM(
- id: "mlx-ternary-bonsai-8b-2bit",
- name: "MLX Ternary-Bonsai-8B 2-bit",
- url: "https://huggingface.co/prism-ml/Ternary-Bonsai-8B-mlx-2bit",
- framework: .mlx,
- memoryRequirement: 2_303_661_704,
- supportsThinking: true
- )
- await registerLLM(
- id: "mlx-ternary-bonsai-27b-2bit",
- name: "MLX Ternary-Bonsai-27B 2-bit",
- url: "https://huggingface.co/prism-ml/Ternary-Bonsai-27B-mlx-2bit",
- framework: .mlx,
- memoryRequirement: 8_490_785_104,
- supportsThinking: true
- )
await registerLLM(
id: "mlx-qwen3.5-0.8b-mlx-4bit",
name: "MLX Qwen3.5 0.8B 4bit",
@@ -562,13 +434,6 @@ enum ModelCatalogBootstrap {
memoryRequirement: 622_000_000,
supportsThinking: true
)
- await registerLLM(
- id: "mlx-llama-3.2-1b-instruct-4bit",
- name: "MLX Llama 3.2 1B Instruct 4bit",
- url: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",
- framework: .mlx,
- memoryRequirement: 900_000_000
- )
// A PLAIN REPO ref, not a `/4bit` subfolder ref like LFM2.5-2.6B-MLX
// below. LiquidAI publishes one precision per repo here — the 4-bit
// weights sit at the repo ROOT alongside config.json and tokenizer.json
@@ -582,13 +447,6 @@ enum ModelCatalogBootstrap {
// model.safetensors) plus KV cache and Metal runtime overhead.
memoryRequirement: 200_000_000
)
- await registerLLM(
- id: "mlx-lfm2-350m",
- name: "MLX LFM2 350M",
- url: "https://huggingface.co/mlx-community/LFM2-350M-MLX",
- framework: .mlx,
- memoryRequirement: 709_000_000
- )
await registerLLM(
id: "mlx-lfm2.5-1.2b-instruct-4bit",
name: "MLX LFM2.5 1.2B Instruct 4bit",
@@ -596,14 +454,6 @@ enum ModelCatalogBootstrap {
framework: .mlx,
memoryRequirement: 628_000_000
)
- await registerLLM(
- id: "mlx-qwen3-4b-4bit",
- name: "MLX Qwen3 4B 4bit",
- url: "https://huggingface.co/mlx-community/Qwen3-4B-4bit",
- framework: .mlx,
- memoryRequirement: 2_400_000_000,
- supportsThinking: true
- )
await registerLLM(
id: "mlx-qwen3.8-27b-4bit",
name: "MLX Qwen3.8 27B 4bit",
@@ -648,49 +498,6 @@ enum ModelCatalogBootstrap {
// runtime overhead.
memoryRequirement: 19_800_000_000
)
- // NOTE: No MLX Gemma 4 checkpoint of ANY size (E2B/E4B/12B/26B-A4B/31B)
- // is registered here. Their attention layers use an asymmetric QK-norm
- // (some layers ship `self_attn.q_norm` without a matching
- // `self_attn.k_norm`), but mlx-swift-lm 3.31.4's `Gemma4TextAttention`
- // unconditionally loads `self_attn.k_norm.weight` and aborts with
- // `keyNotFound` on the first such layer — so they download fully and
- // then fail to load. This is architectural, not per-size, so it blocks
- // every Gemma 4 MLX checkpoint until mlx-swift-lm makes per-layer
- // k_norm optional. The GGUF (llama.cpp) Gemma 4 variants in the LLM
- // section above load fine and remain available at every size.
- //
- // NOTE: No MLX row for `mlx-community/Muse-Glimmer-30B-4bit` either.
- // Verified against the pinned mlx-swift-lm 3.31.4 checkout: this
- // repo's config.json declares `model_type: "muse_glimmer"`, which is
- // NOT among VLMModelFactory's registered types (paligemma, qwen2_vl,
- // qwen2_5_vl, qwen3_vl, qwen3_5[_moe], idefics3, gemma3, gemma4[_unified],
- // smolvlm, fastvlm, llava_qwen2, pixtral, mistral3, lfm2_vl, glm_ocr —
- // no "muse_glimmer" anywhere) — it would download in full and then
- // fail to load. Separately, this exact repo's advertised size is
- // wrong: the coordinator-supplied ~19.4 GB estimate does not match
- // what the HF blobs API reports for this repo (4 safetensors shards
- // totaling 39,443,521,145 B, ~39.4 GB — roughly 2x), most likely
- // because the vision tower and/or embeddings are kept at higher
- // precision even under a nominal "4bit" LLM quant. The GGUF
- // (llama.cpp) row below is unaffected and is the only Muse Glimmer
- // row in this catalog.
- //
- // NOTE: No MLX row for
- // `mlx-community/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-4bit` either,
- // for the same reason: its config.json declares
- // `model_type: "NemotronH_Nano_Omni_Reasoning_V3"`. VLMModelFactory
- // has zero Nemotron entries of any kind, and LLMModelFactory's only
- // Nemotron entry, `"nemotron_h"`, is a different, non-matching string
- // (and is text-only — it has no vision path regardless). The GGUF
- // (llama.cpp) row below is the only way this model is exposed here.
- await registerLLM(
- id: "mlx-qwen2-vl-2b-instruct-4bit",
- name: "MLX Qwen2-VL 2B Instruct 4bit",
- url: "https://huggingface.co/mlx-community/Qwen2-VL-2B-Instruct-4bit",
- framework: .mlx,
- modality: .multimodal,
- memoryRequirement: 2_200_000_000
- )
await registerLLM(
id: "mlx-qwen3-vl-4b-instruct-4bit",
name: "MLX Qwen3-VL 4B Instruct 4bit",
@@ -770,32 +577,6 @@ enum ModelCatalogBootstrap {
structure: .directoryBased,
memoryRequirement: 600_000_000
)
- await registerMultiFile(
- id: "qwen2-vl-2b-instruct-q4_k_m",
- name: "Qwen2-VL 2B Instruct",
- files: [
- ("https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf",
- "Qwen2-VL-2B-Instruct-Q4_K_M.gguf"),
- ("https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf",
- "mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf")
- ],
- framework: .llamaCpp,
- modality: .multimodal,
- memoryRequirement: 1_800_000_000
- )
- await registerMultiFile(
- id: "qwen2.5-vl-3b-instruct-q4_k_m",
- name: "Qwen2.5-VL 3B Instruct Q4_K_M",
- files: [
- ("https://huggingface.co/ggml-org/Qwen2.5-VL-3B-Instruct-GGUF/resolve/main/Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf",
- "Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf"),
- ("https://huggingface.co/ggml-org/Qwen2.5-VL-3B-Instruct-GGUF/resolve/main/mmproj-Qwen2.5-VL-3B-Instruct-Q8_0.gguf",
- "mmproj-Qwen2.5-VL-3B-Instruct-Q8_0.gguf")
- ],
- framework: .llamaCpp,
- modality: .multimodal,
- memoryRequirement: 2_800_000_000
- )
await registerMultiFile(
id: "gemma-4-e2b-it-q8_0",
name: "Gemma 4 E2B IT Q8_0 (Experimental)",
@@ -813,8 +594,9 @@ enum ModelCatalogBootstrap {
id: "gemma-4-e4b-it-q4_k_m",
name: "Gemma 4 E4B IT Q4_K_M (Experimental)",
files: [
- ("https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf",
- "gemma-4-E4B-it-Q4_K_M.gguf"),
+ // ggml-org publishes no Q4_K_M for this repo — Q4_0 is its only 4-bit build.
+ ("https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_0.gguf",
+ "gemma-4-E4B-it-Q4_0.gguf"),
("https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/mmproj-gemma-4-E4B-it-Q8_0.gguf",
"mmproj-gemma-4-E4B-it-Q8_0.gguf")
],
@@ -822,19 +604,6 @@ enum ModelCatalogBootstrap {
modality: .multimodal,
memoryRequirement: 5_500_000_000
)
- await registerMultiFile(
- id: "lfm2-vl-450m-q8_0",
- name: "LFM2-VL 450M",
- files: [
- ("https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/LFM2-VL-450M-Q8_0.gguf",
- "LFM2-VL-450M-Q8_0.gguf"),
- ("https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/mmproj-LFM2-VL-450M-Q8_0.gguf",
- "mmproj-LFM2-VL-450M-Q8_0.gguf")
- ],
- framework: .llamaCpp,
- modality: .multimodal,
- memoryRequirement: 600_000_000
- )
// LFM2.5-VL, the current generation of the LFM2-VL row above. Q4_K_M
// decoder paired with the Q8_0 mmproj — the same split the Qwen2-VL,
// Qwen2.5-VL and Gemma 4 rows use, and the repo's smallest mmproj.
@@ -1537,6 +1306,59 @@ enum ModelCatalogBootstrap {
)
logger.info("Embedding models registered")
+ // --- Added from the verified model list ---------------------------------
+ // Language models, not embeddings, and llama.cpp rather than MLX. Both facts
+ // were wrong here: the block sat unguarded under the embedding section, so a
+ // target that does not link LlamaCPPRuntime still saw five rows it cannot
+ // execute, and the log line beneath them claimed they were embeddings.
+ #if canImport(LlamaCPPRuntime)
+ await registerLLM(
+ id: "lfm2.5-1.2b-thinking-q4_k_m",
+ name: "LFM2.5 1.2B Thinking Q4_K_M",
+ url: "https://huggingface.co/LiquidAI/LFM2.5-1.2B-Thinking-GGUF/resolve/main/LFM2.5-1.2B-Thinking-Q4_K_M.gguf",
+ framework: .llamaCpp,
+ // 730_895_360 B of weights plus KV cache and runtime overhead.
+ memoryRequirement: 900_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "qwen3.5-2b-q4_k_m",
+ name: "Qwen3.5 2B Q4_K_M",
+ url: "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf",
+ framework: .llamaCpp,
+ // 1_280_835_840 B of weights plus KV cache and runtime overhead.
+ memoryRequirement: 1_550_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "qwen3.5-4b-q4_k_m",
+ name: "Qwen3.5 4B Q4_K_M",
+ url: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf",
+ framework: .llamaCpp,
+ // 2_740_937_888 B of weights plus KV cache and runtime overhead.
+ memoryRequirement: 3_350_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "qwen3.5-9b-q4_k_m",
+ name: "Qwen3.5 9B Q4_K_M",
+ url: "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf",
+ framework: .llamaCpp,
+ // 5_680_522_464 B of weights plus KV cache and runtime overhead.
+ memoryRequirement: 6_950_000_000,
+ supportsThinking: true
+ )
+ await registerLLM(
+ id: "maple-preview-tq1_0",
+ name: "Maple Preview 20B-A1B TQ1_0 (1-bit)",
+ url: "https://huggingface.co/deepgrove/maple-preview-GGUF/resolve/main/maple-preview-TQ1_0-head-Q4_K.gguf",
+ framework: .llamaCpp,
+ // 4_984_016_416 B of weights plus KV cache and runtime overhead.
+ memoryRequirement: 6_100_000_000,
+ supportsThinking: true
+ )
+ #endif
+
// QHexRT/HNPU bundles are Qualcomm-Android-only and are intentionally
// not registered on Apple platforms.
@@ -1704,8 +1526,9 @@ enum ModelCatalogBootstrap {
// --- LoRA adapters ------------------------------------------------------
// Mirrors Android `ModelBootstrap.seedLora` / `ModelCatalog.loraAdapters`.
#if canImport(LlamaCPPRuntime)
- await registerLoraAdapters()
- logger.info("LoRA adapters registered")
+ // LoRA adapters are not registered: the only adapter shipped is trained for
+ // qwen2.5-0.5b, which this catalog no longer carries. Re-add both together.
+ logger.info("LoRA adapters skipped: no adapter matches the current catalog")
#endif
// Diffusion (CoreML) backend is deferred scope for
@@ -1714,44 +1537,6 @@ enum ModelCatalogBootstrap {
logger.info("All modules and models registered")
}
- /// Seed the curated LoRA adapter catalog. `RALoraAdapterCatalogEntry` no
- /// longer carries url/filename/size/description (idl/lora_options.proto:
- /// "everything generic about the artifact ... lives on the ModelInfo
- /// record for this adapter"), so the downloadable bytes are described by
- /// a companion `RAModelInfo` artifact registered under the SDK's
- /// `lora-adapter:{id}` convention. `registerArtifact` registers both the
- /// catalog entry and that artifact record (no bytes fetched); safe to
- /// re-run on every cold launch.
- private static func registerLoraAdapters() async {
- var adapter = RALoraAdapterCatalogEntry()
- adapter.id = "abliterated-lora"
- adapter.name = "Abliterated LoRA (F16)"
- adapter.compatibleModels = ["qwen2.5-0.5b-instruct-q6_k"]
- adapter.defaultScale = 1.0
-
- let downloadURL = URL(
- string: "https://huggingface.co/Void2377/qwen-lora-gguf/resolve/main/qwen2.5-0.5b-abliterated-lora-f16.gguf"
- )
- let artifact = RAModelInfo.make(
- id: adapter.loraArtifactModelID,
- name: adapter.name,
- category: .language,
- format: .gguf,
- framework: .llamaCpp,
- downloadURL: downloadURL,
- downloadSizeBytes: 17_620_224,
- description: "Removes refusal behavior — model answers directly without disclaimers"
- )
-
- do {
- _ = try await RunAnywhere.lora.registerArtifact(adapter, artifact: artifact)
- } catch {
- logger.warning(
- "Failed to register LoRA adapter: \(error.localizedDescription, privacy: .public)"
- )
- }
- }
-
// MARK: - Registration helpers
private struct CatalogModelFile: Sendable {
diff --git a/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift b/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
index fe8201bb..e67c5fd5 100644
--- a/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
+++ b/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
@@ -27,6 +27,13 @@ final class LLMViewModel {
private(set) var messages: [Message] = []
private(set) var isGenerating = false
+ /// Cancellation has been asked for but the stream has not unwound yet.
+ ///
+ /// A separate flag from `isGenerating` because `stopGeneration` deliberately
+ /// leaves that one alone — the in-flight turn owns its true->false edge.
+ /// Without this the composer has nothing to say during the gap, and a Stop
+ /// that takes a second looks like a button that did nothing.
+ private(set) var isStopping = false
private(set) var error: Error?
private(set) var isModelLoaded = false
private(set) var loadedModelName: String?
@@ -175,6 +182,7 @@ final class LLMViewModel {
func setIsGenerating(_ value: Bool) {
isGenerating = value
+ if !value { isStopping = false }
}
/// True while the generation started for `generatingConversationId` still
@@ -296,8 +304,61 @@ final class LLMViewModel {
var canSend: Bool {
!currentInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- && !isGenerating
- && isModelLoaded
+ && !isBusy
+ && hasUsableModel
+ }
+
+ /// Inference or its cancellation owns the chat.
+ var isBusy: Bool { isGenerating || isStopping }
+
+ /// A local model is resident, or a hosted one is reachable.
+ var hasUsableModel: Bool { isModelLoaded || isUsingConnect }
+
+ /// Why a written message cannot be sent, or nil when nothing is in the way.
+ ///
+ /// Only ever about the model. Sending and stopping already have their own
+ /// affordances in the composer, and repeating them here would make it shout
+ /// through every normal turn.
+ var sendBlockedReason: String? {
+ hasUsableModel ? nil : "No model is loaded yet."
+ }
+
+ /// Whether the loaded model can emit a reasoning trace at all.
+ ///
+ /// Distinct from `thinkingEnabled`, which is the user's preference. NPU
+ /// batch backends decode a whole reply at once and cannot stream a trace, so
+ /// the capability is withheld there regardless of what the model declares.
+ var thinkingSupported: Bool {
+ guard isModelLoaded, selectedFramework != .qhexrt else { return false }
+ return loadedModelSupportsThinking
+ }
+
+ var thinkingEnabled: Bool {
+ thinkingSupported && SettingsViewModel.shared.thinkingModeEnabled
+ }
+
+ func toggleThinking() {
+ guard thinkingSupported else { return }
+ SettingsViewModel.shared.thinkingModeEnabled.toggle()
+ }
+
+ /// Tools are on for the next turn: the user asked for them and the loaded
+ /// model can actually carry them.
+ var toolsEnabled: Bool {
+ !isUsingConnect
+ && useToolCalling
+ && ToolCallingModelPolicy.evaluate(model: ModelListViewModel.shared.currentModel).isAvailable
+ }
+
+ /// Why tools cannot run, or nil. Only spoken when the user has asked for
+ /// them — an unrequested capability has nothing to explain.
+ var toolsUnavailableMessage: String? {
+ guard useToolCalling else { return nil }
+ if isUsingConnect {
+ return "Web & tools are unavailable while using a hosted model."
+ }
+ let availability = ToolCallingModelPolicy.evaluate(model: ModelListViewModel.shared.currentModel)
+ return availability.isAvailable ? nil : availability.message
}
/// `Error` is not `Equatable`, so a view cannot `.onChange(of: error)`. This
@@ -387,6 +448,7 @@ final class LLMViewModel {
conversationStore.cancelPendingTitleGeneration()
isGenerating = true
+ isStopping = false
error = nil
generationStartedAt = Date()
@@ -538,6 +600,7 @@ final class LLMViewModel {
// `canSend` false until the stream has actually stopped — otherwise a
// second `sendMessage()` could start and overlap the still-running
// generation on the single-callback LLM component.
+ isStopping = true
generationTask?.cancel()
#if os(iOS)
@@ -785,8 +848,12 @@ final class LLMViewModel {
// MARK: - Private Methods - Message Generation
+ /// `hasUsableModel`, not `isModelLoaded`: a hosted Connect session answers
+ /// without a local model resident, and `handleModelUnloaded` can clear
+ /// `isModelLoaded` while the session is still live. Reading the narrower
+ /// flag here refused a turn the host was ready to serve.
private func ensureModelIsLoaded() async throws {
- if !isModelLoaded {
+ if !hasUsableModel {
throw LLMError.noModelLoaded
}
}
diff --git a/RunAnywhereAI/Features/Chat/Views/ChatComposerBar.swift b/RunAnywhereAI/Features/Chat/Views/ChatComposerBar.swift
new file mode 100644
index 00000000..521374b4
--- /dev/null
+++ b/RunAnywhereAI/Features/Chat/Views/ChatComposerBar.swift
@@ -0,0 +1,663 @@
+//
+// ChatComposerBar.swift
+// RunAnywhereAI
+//
+// The chat's bottom bar, ported from the Android example's `ChatInputBar` +
+// `PromptSuggestions` so both apps compose a turn the same way.
+//
+// The shape is Android's, the idiom is not: circular 44pt targets instead of
+// Material icon buttons, `symbolEffect` swaps instead of Compose crossfades,
+// and every animation routes through `motionAware` so Reduce Motion is honored.
+//
+// Order is load-bearing and matches Android top to bottom — divider, blocked
+// reason, attachment rejection, attachment, tool status, the switch row, the
+// editor, the stopping pill. Two of those strips (rejection, attachment) stay
+// visible in `compact` because they are the only two that change what Send
+// does; hiding them left a staged file with no way to see or remove it.
+//
+
+import SwiftUI
+
+// MARK: - Palette
+
+/// The two surfaces the composer paints, mapped from Android's Material roles.
+///
+/// Android sets `background` and `surface` to the same tone (Neutral98 light /
+/// Neutral6 dark), so the transcript and the bar are one field and every control
+/// stands on `surfaceContainerHigh`, a clear step up from it. That relationship
+/// is the whole reason the Android bar reads as a bar.
+///
+/// The first port used `systemGray6` for controls, which on iOS is the *same
+/// value* as `systemGroupedBackground` — so every button, the editor, and every
+/// chip painted themselves in the exact color they sat on and disappeared.
+/// `systemGray5` is the step that actually exists.
+enum ComposerPalette {
+ /// The field the bar and the transcript share. Android `surface`.
+ static let barFill = AppColors.backgroundGrouped
+ /// Buttons, the editor well, chips, strips. Android `surfaceContainerHigh`.
+ static let controlFill = AppColors.backgroundGray5
+ /// A control whose glyph is live. Android's `primary @ 0.15` container.
+ static let activeFill = AppColors.primaryAccent.opacity(0.15)
+
+ /// Glyph inside a composer control. Android `iconMd` is 22dp; an SF Symbol
+ /// set at 18pt medium matches that cap height.
+ static let glyph = Font.system(size: 18, weight: .medium)
+}
+
+// MARK: - Prompt Suggestions
+
+/// The horizontally scrolling starter chips that sit directly above the editor.
+///
+/// Shown only on an empty transcript, and only while the keyboard is down —
+/// a row of prompts above a raised keyboard is a second thing competing for a
+/// viewport that has just halved.
+struct ChatPromptSuggestionsRow: View {
+ let prompts: [StarterPrompt]
+ let onSelect: (StarterPrompt) -> Void
+
+ @State private var scrollOffset: CGFloat = 0
+ @State private var contentWidth: CGFloat = 0
+ @State private var viewportWidth: CGFloat = 0
+
+ private var canScrollBack: Bool { scrollOffset > 1 }
+ private var canScrollForward: Bool { contentWidth - scrollOffset - viewportWidth > 1 }
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Space.sm) {
+ ForEach(prompts) { prompt in
+ SuggestionPill(prompt: prompt) { onSelect(prompt) }
+ }
+ }
+ .padding(.horizontal, Space.md)
+ .background(
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: SuggestionMetricsKey.self,
+ value: SuggestionMetrics(
+ offset: -geometry.frame(in: .named(Self.scrollSpace)).minX,
+ contentWidth: geometry.size.width
+ )
+ )
+ }
+ )
+ }
+ .coordinateSpace(name: Self.scrollSpace)
+ .onPreferenceChange(SuggestionMetricsKey.self) { metrics in
+ scrollOffset = metrics.offset
+ contentWidth = metrics.contentWidth
+ }
+ .background(
+ GeometryReader { geometry in
+ ComposerPalette.barFill
+ .preference(key: SuggestionViewportKey.self, value: geometry.size.width)
+ }
+ )
+ .onPreferenceChange(SuggestionViewportKey.self) { viewportWidth = $0 }
+ .mask(edgeFade)
+ .motionAware(Motion.microFade, value: canScrollBack)
+ .motionAware(Motion.microFade, value: canScrollForward)
+ }
+
+ private static let scrollSpace = "chat.suggestions"
+
+ /// A scrim over whichever edge still has content past it, so an overflowing
+ /// row looks scrollable instead of cropped. Only that edge: a chip sliced
+ /// flush at the bezel reads as a rendering bug rather than "scroll for
+ /// more", and a row that happens to fit should stay crisp.
+ ///
+ /// A mask rather than an overlaid gradient — the row sits on the same field
+ /// as the bar, and a scrim tinted to one appearance is wrong in the other.
+ private var edgeFade: some View {
+ LinearGradient(
+ stops: [
+ .init(color: .black.opacity(canScrollBack ? 0 : 1), location: 0),
+ .init(color: .black, location: canScrollBack ? Self.fadeFraction : 0),
+ .init(color: .black, location: canScrollForward ? 1 - Self.fadeFraction : 1),
+ .init(color: .black.opacity(canScrollForward ? 0 : 1), location: 1)
+ ],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ }
+
+ /// Wide enough to read as a fade at phone width, narrow enough that it never
+ /// eats a whole chip.
+ private static let fadeFraction: CGFloat = 0.12
+}
+
+private struct SuggestionMetrics: Equatable {
+ var offset: CGFloat = 0
+ var contentWidth: CGFloat = 0
+}
+
+private struct SuggestionMetricsKey: PreferenceKey {
+ static let defaultValue = SuggestionMetrics()
+ static func reduce(value: inout SuggestionMetrics, nextValue: () -> SuggestionMetrics) {
+ value = nextValue()
+ }
+}
+
+private struct SuggestionViewportKey: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = nextValue()
+ }
+}
+
+private struct SuggestionPill: View {
+ let prompt: StarterPrompt
+ let action: () -> Void
+
+ var body: some View {
+ Button {
+ Haptics.light()
+ action()
+ } label: {
+ HStack(spacing: Space.xs) {
+ if let icon = prompt.icon {
+ Image(systemName: icon)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(AppColors.primaryAccent)
+ }
+ Text(prompt.title)
+ .appType(.secondary)
+ .foregroundStyle(AppColors.textPrimary)
+ }
+ .padding(.horizontal, Space.md)
+ .padding(.vertical, Space.sm)
+ .background(Capsule().fill(ComposerPalette.controlFill))
+ .contentShape(Capsule())
+ }
+ .buttonStyle(ComposerPressStyle())
+ }
+}
+
+// MARK: - Composer Bar
+
+struct ChatComposerBar: View {
+ @Bindable var viewModel: LLMViewModel
+ @FocusState.Binding var isTextFieldFocused: Bool
+
+ let imageAttachment: ChatImageAttachment?
+ let documentAttachment: ChatDocumentAttachment?
+ let attachmentRejection: String?
+ let isVisionModelReady: Bool
+ let areDocumentModelsReady: Bool
+ let canSendCurrentTurn: Bool
+ let compact: Bool
+
+ let onRemoveImageAttachment: () -> Void
+ let onRemoveDocumentAttachment: () -> Void
+ let onDismissAttachmentRejection: () -> Void
+ let onChooseVisionModel: () -> Void
+ let onChooseDocumentModels: () -> Void
+ let onResolveBlocked: () -> Void
+ let onComposerAction: (ComposerAction) -> Void
+ let onSend: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Rectangle()
+ .fill(AppColors.border)
+ .frame(height: Hairline.width)
+
+ VStack(spacing: Space.sm) {
+ strips
+ switchRow
+ editorRow
+ if viewModel.isStopping && !compact {
+ StoppingPill().transition(.composerStrip)
+ }
+ }
+ .padding(.horizontal, Space.md)
+ .padding(.top, Space.sm)
+ .padding(.bottom, compact ? Space.sm : Space.md)
+ .measured(Measure.text)
+ }
+ .background(ComposerPalette.barFill)
+ .motionAware(Motion.snappy, value: layoutSignature)
+ }
+
+ /// Everything that changes the bar's height in one value, so growth animates
+ /// once instead of six modifiers each animating a different subview at its
+ /// own speed.
+ private var layoutSignature: String {
+ [
+ viewModel.sendBlockedReason ?? "",
+ attachmentRejection ?? "",
+ imageAttachment == nil ? "" : "img",
+ documentAttachment == nil ? "" : "doc",
+ toolStatusMessage ?? "",
+ viewModel.isStopping ? "stopping" : "",
+ compact ? "compact" : ""
+ ].joined(separator: "|")
+ }
+
+ // MARK: - Strips
+
+ @ViewBuilder private var strips: some View {
+ if let reason = viewModel.sendBlockedReason, !compact {
+ BlockedReasonStrip(reason: reason, onResolve: onResolveBlocked)
+ .transition(.composerStrip)
+ }
+
+ // Deliberately outside the `compact` gate, unlike the strips around it.
+ // These two are the only ones that change what Send does.
+ if let rejection = attachmentRejection {
+ AttachmentRejectionStrip(reason: rejection, onDismiss: onDismissAttachmentRejection)
+ .transition(.composerStrip)
+ }
+
+ if let imageAttachment {
+ ImageAttachmentPill(
+ attachment: imageAttachment,
+ isVisionModelReady: isVisionModelReady,
+ onRemove: onRemoveImageAttachment,
+ onChooseVisionModel: onChooseVisionModel
+ )
+ .transition(.composerStrip)
+ }
+
+ if let documentAttachment {
+ DocumentAttachmentPill(
+ attachment: documentAttachment,
+ areModelsReady: areDocumentModelsReady,
+ indexState: viewModel.documentIndexState,
+ onRemove: onRemoveDocumentAttachment,
+ onChooseModels: onChooseDocumentModels
+ )
+ .transition(.composerStrip)
+ }
+
+ if let message = toolStatusMessage, !compact {
+ ToolStatusPill(unavailableMessage: viewModel.toolsUnavailableMessage, detail: message)
+ .transition(.composerStrip)
+ }
+ }
+
+ /// The tool strip's second line, or nil when there is nothing to report.
+ private var toolStatusMessage: String? {
+ if let unavailable = viewModel.toolsUnavailableMessage { return unavailable }
+ guard viewModel.toolsEnabled else { return nil }
+ return "Trace appears in replies"
+ }
+
+ // MARK: - Switch Row
+
+ private var switchRow: some View {
+ HStack(spacing: Space.sm) {
+ attachmentMenu
+
+ Spacer(minLength: 0)
+
+ // `isOn` follows the request, not the verdict. Painting the fill
+ // from `toolsEnabled` meant a tap on a model that cannot carry tools
+ // changed the setting and moved nothing on screen. Disabling the
+ // control instead would trap the opposite way: tools left on, then a
+ // hosted session connects, and there is no way to turn them off
+ // again. The request is what the tap owns; whether it can run is the
+ // status pill's job to say.
+ ComposerToggle(
+ icon: "globe",
+ isOn: viewModel.useToolCalling,
+ isEnabled: true,
+ label: toolsToggleLabel
+ ) {
+ viewModel.useToolCalling.toggle()
+ }
+
+ ComposerToggle(
+ icon: "mic",
+ isOn: false,
+ isEnabled: true,
+ label: "Talk mode"
+ ) {
+ onComposerAction(.talk)
+ }
+
+ // `thinkingSupported` gates the control, not just its value: a model
+ // that emits no reasoning has nothing to switch on, and a live toggle
+ // over it would promise a trace that can never arrive.
+ ComposerToggle(
+ icon: "brain",
+ isOn: viewModel.thinkingEnabled,
+ isEnabled: viewModel.thinkingSupported,
+ label: thinkingToggleLabel
+ ) {
+ viewModel.toggleThinking()
+ }
+ }
+ }
+
+ private var toolsToggleLabel: String {
+ if viewModel.toolsEnabled { return "Disable web and tools" }
+ if viewModel.toolsUnavailableMessage != nil {
+ return "Web and tools unavailable for current model"
+ }
+ return "Enable web and tools"
+ }
+
+ private var thinkingToggleLabel: String {
+ if !viewModel.thinkingSupported { return "Thinking not supported by current model" }
+ return viewModel.thinkingEnabled ? "Disable thinking" : "Enable thinking"
+ }
+
+ /// Attachments and modes, as one native menu. Two-line rows so each says
+ /// what it is *for*, matching the Android dropdown — "Image" and "Live
+ /// camera" are otherwise near-identical rows.
+ private var attachmentMenu: some View {
+ Menu {
+ Button { onComposerAction(.attachFile) } label: {
+ Text("Document")
+ Text("Ask with sources")
+ Image(systemName: "doc.text")
+ }
+ Button { onComposerAction(.attachPhoto) } label: {
+ Text("Image")
+ Text("Ask about a photo")
+ Image(systemName: "photo")
+ }
+ Button { onComposerAction(.takePhoto) } label: {
+ Text("Live camera")
+ Text("Look with vision")
+ Image(systemName: "eye")
+ }
+ Button { onComposerAction(.pasteAttachment) } label: {
+ Text("Paste")
+ Text("Attach the clipboard")
+ Image(systemName: "doc.on.clipboard")
+ }
+ .disabled(!ChatAttachmentLoader.pasteboardHasAttachment)
+ #if os(iOS)
+ // Android's fourth row. iOS only, because the Mac reaches Advanced
+ // from its sidebar and a second route to one screen is how a menu
+ // stops meaning anything.
+ Button { onComposerAction(.openAdvanced) } label: {
+ Text("Advanced tools")
+ Text("SDK demos and diagnostics")
+ Image(systemName: "slider.horizontal.3")
+ }
+ #endif
+ } label: {
+ Image(systemName: "line.3.horizontal")
+ .font(ComposerPalette.glyph)
+ .foregroundStyle(AppColors.textSecondary)
+ .frame(width: Measure.hitTarget, height: Measure.hitTarget)
+ .background(Circle().fill(ComposerPalette.controlFill))
+ .contentShape(Circle())
+ }
+ .menuStyle(.button)
+ .buttonStyle(.plain)
+ .menuIndicator(.hidden)
+ .fixedSize()
+ .accessibilityLabel("Attach or open a mode")
+ }
+
+ // MARK: - Editor
+
+ private var editorRow: some View {
+ HStack(alignment: .bottom, spacing: Space.sm) {
+ TextField(placeholder, text: $viewModel.currentInput, axis: .vertical)
+ .textFieldStyle(.plain)
+ .appType(.body)
+ .lineLimit(compact ? 1...2 : 1...5)
+ .padding(.horizontal, Space.lg)
+ .padding(.vertical, Space.md)
+ .frame(minHeight: Measure.hitTarget, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .fill(ComposerPalette.controlFill)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .strokeBorder(
+ isTextFieldFocused ? AppColors.primaryAccent.opacity(0.5) : .clear,
+ lineWidth: Stroke.regular
+ )
+ )
+ .focused($isTextFieldFocused)
+ .onSubmit(onSend)
+ .submitLabel(.send)
+ .accessibilityLabel("Message input")
+ .motionAware(Motion.microFade, value: isTextFieldFocused)
+
+ sendButton
+ }
+ }
+
+ /// One slot, two roles. A single Button whose symbol is computed rather than
+ /// two siblings, so `.symbolEffect(.replace)` fires and the row never
+ /// reflows when Send becomes Stop mid-sentence.
+ private var sendButton: some View {
+ Button {
+ Haptics.light()
+ if viewModel.isGenerating { viewModel.stopGeneration() } else { onSend() }
+ } label: {
+ Image(systemName: viewModel.isGenerating ? "stop.fill" : "arrow.up")
+ .font(ComposerPalette.glyph.weight(.semibold))
+ .foregroundStyle(sendIsEnabled ? AppColors.onBrandLarge : AppColors.textTertiary)
+ .frame(width: Measure.hitTarget, height: Measure.hitTarget)
+ .background(
+ Circle().fill(sendIsEnabled ? AppColors.primaryAccent : ComposerPalette.controlFill)
+ )
+ .contentShape(Circle())
+ }
+ .buttonStyle(ComposerPressStyle())
+ .disabled(!sendIsEnabled)
+ .accessibilityLabel(viewModel.isGenerating ? "Stop" : "Send message")
+ .contentTransition(.symbolEffect(.replace))
+ .motionAware(Motion.snappy, value: sendIsEnabled)
+ .motionAware(Motion.snappy, value: viewModel.isGenerating)
+ }
+
+ private var sendIsEnabled: Bool {
+ viewModel.isGenerating || canSendCurrentTurn
+ }
+
+ private var placeholder: String {
+ // The attachment wins: it is the more specific fact about what pressing
+ // Send will do right now.
+ if imageAttachment != nil { return "Add a question, or send to describe this image" }
+ if documentAttachment != nil { return "Add a question, or send to describe this file" }
+ return viewModel.toolsEnabled ? "Ask with web and tools…" : "Ask anything…"
+ }
+}
+
+// MARK: - Toggle
+
+/// A circular switch in the composer's action row.
+///
+/// The fill is what carries state, not the glyph — a tinted glyph on an untinted
+/// ground is easy to miss on a row of three. `symbolEffect(.bounce)` fires on
+/// the value change so a toggle reads as a thing that just happened rather than
+/// a color that quietly differs from a second ago.
+private struct ComposerToggle: View {
+ let icon: String
+ let isOn: Bool
+ let isEnabled: Bool
+ let label: String
+ let action: () -> Void
+
+ var body: some View {
+ Button {
+ Haptics.selection()
+ action()
+ } label: {
+ Image(systemName: icon)
+ .font(ComposerPalette.glyph)
+ .foregroundStyle(foreground)
+ .symbolEffect(.bounce, value: isOn)
+ .frame(width: Measure.hitTarget, height: Measure.hitTarget)
+ .background(Circle().fill(background))
+ .contentShape(Circle())
+ }
+ .buttonStyle(ComposerPressStyle())
+ .disabled(!isEnabled)
+ .accessibilityLabel(label)
+ .accessibilityAddTraits(isOn ? [.isSelected] : [])
+ .motionAware(Motion.snappy, value: isOn)
+ .motionAware(Motion.microFade, value: isEnabled)
+ }
+
+ private var foreground: Color {
+ if !isEnabled { return AppColors.textTertiary.opacity(0.5) }
+ return isOn ? AppColors.primaryAccent : AppColors.textSecondary
+ }
+
+ private var background: Color {
+ isOn ? ComposerPalette.activeFill : ComposerPalette.controlFill
+ }
+}
+
+/// Presses shrink a hair. iOS has no ripple, and a circular target that does not
+/// move under the thumb reads as unresponsive on a row of four.
+private struct ComposerPressStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.92 : 1)
+ .opacity(configuration.isPressed ? 0.85 : 1)
+ .motionAware(Motion.snappy, value: configuration.isPressed)
+ }
+}
+
+// MARK: - Strips
+
+/// The one blocker between a written message and an answer, plus the tap that
+/// clears it.
+///
+/// Deliberately not an error color: nothing has gone wrong on a first launch,
+/// the user simply has not chosen a model yet, and red here would read as a
+/// fault they caused.
+private struct BlockedReasonStrip: View {
+ let reason: String
+ let onResolve: () -> Void
+
+ var body: some View {
+ Button(action: onResolve) {
+ HStack(spacing: Space.sm) {
+ Image(systemName: "cube")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(AppColors.primaryAccent)
+
+ VStack(alignment: .leading, spacing: Space.hair) {
+ Text(reason)
+ .appType(.chip)
+ .foregroundStyle(AppColors.textPrimary)
+ Text("Tap to choose one — it downloads and runs on this device.")
+ .appType(.meta)
+ .foregroundStyle(AppColors.textSecondary)
+ }
+
+ Spacer(minLength: 0)
+
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(AppColors.textSecondary)
+ }
+ .padding(.horizontal, Space.md)
+ .padding(.vertical, Space.sm)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .fill(ComposerPalette.controlFill)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
+ }
+ .buttonStyle(ComposerPressStyle())
+ }
+}
+
+/// The file the composer would not take, and why.
+///
+/// Error-colored, unlike `BlockedReasonStrip`: something the user did was
+/// refused, and softening that into a neutral hint would leave them wondering
+/// whether the attachment went through.
+private struct AttachmentRejectionStrip: View {
+ let reason: String
+ let onDismiss: () -> Void
+
+ var body: some View {
+ HStack(spacing: Space.sm) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 14, weight: .semibold))
+ Text(reason)
+ .appType(.chip)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Button(action: onDismiss) {
+ Image(systemName: "xmark")
+ .font(.system(size: 13, weight: .semibold))
+ .frame(width: Measure.hitTarget, height: Measure.hitTarget)
+ .contentShape(Circle())
+ }
+ .buttonStyle(ComposerPressStyle())
+ .accessibilityLabel("Dismiss")
+ }
+ .foregroundStyle(AppColors.dangerText)
+ .padding(.leading, Space.md)
+ .padding(.vertical, Space.xs)
+ .background(
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .fill(AppColors.danger.opacity(0.12))
+ )
+ }
+}
+
+private struct ToolStatusPill: View {
+ let unavailableMessage: String?
+ let detail: String
+
+ private var isUnavailable: Bool { unavailableMessage != nil }
+
+ var body: some View {
+ HStack(spacing: Space.xs) {
+ Image(systemName: isUnavailable ? "exclamationmark.triangle.fill" : "globe")
+ .font(.system(size: 12, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: Space.hair) {
+ Text(isUnavailable ? "Web & tools unavailable" : "Web & tools on")
+ .appType(.chip)
+ Text(detail)
+ .appType(.meta)
+ .foregroundStyle(AppColors.textSecondary)
+ .lineLimit(isUnavailable ? 2 : 1)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .foregroundStyle(isUnavailable ? AppColors.dangerText : AppColors.primaryAccent)
+ .padding(.horizontal, Space.md)
+ .padding(.vertical, Space.xs)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .fill((isUnavailable ? AppColors.danger : AppColors.primaryAccent).opacity(0.12))
+ )
+ }
+}
+
+private struct StoppingPill: View {
+ var body: some View {
+ Text("Stopping the previous response… You can keep typing.")
+ .appType(.chip)
+ .foregroundStyle(AppColors.textSecondary)
+ .padding(.horizontal, Space.md)
+ .padding(.vertical, Space.xs)
+ .background(Capsule().fill(ComposerPalette.controlFill))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+// MARK: - Transition
+
+extension AnyTransition {
+ /// Fade plus the stack's own reflow, which is what makes a strip appear to
+ /// push the editor down rather than land on top of it. Asymmetric so a strip
+ /// the user dismissed leaves without re-animating its arrival.
+ static var composerStrip: AnyTransition {
+ .asymmetric(
+ insertion: .opacity.combined(with: .move(edge: .bottom)),
+ removal: .opacity
+ )
+ }
+}
diff --git a/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift b/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
index da3463ac..96c06a75 100644
--- a/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
+++ b/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
@@ -71,6 +71,16 @@ struct ChatInterfaceView: View {
@State private var selectedDocumentAnswerModel: RAModelInfo?
@State private var isVisionModelReady = false
@State private var errorMessage: String?
+ /// Why the last file the user chose was not attached, or nil.
+ ///
+ /// Shown next to the composer rather than raised as an alert: the user is
+ /// mid-compose and the remedy is to pick a different file, not to dismiss
+ /// something.
+ @State private var attachmentRejection: String?
+ #if os(iOS)
+ @Environment(\.verticalSizeClass)
+ private var verticalSizeClass
+ #endif
@State private var showModelLoadedToast = false
@State private var showingLoRAScaleSheet = false
@State private var showingLoRAManagement = false
@@ -527,34 +537,95 @@ extension ChatInterfaceView {
settingsViewModel: settingsViewModel,
toolSettingsViewModel: toolSettingsViewModel
)
- ChatInputAreaView(
- viewModel: viewModel,
- isTextFieldFocused: $isTextFieldFocused,
- showingLoRAManagement: $showingLoRAManagement,
- settingsViewModel: settingsViewModel,
- toolSettingsViewModel: toolSettingsViewModel,
- imageAttachment: pendingImageAttachment,
- documentAttachment: pendingDocumentAttachment,
- isVisionModelReady: isVisionModelReady,
- areDocumentModelsReady: areDocumentModelsReady,
- canSendCurrentTurn: canSendCurrentTurn,
- onRemoveImageAttachment: {
- pendingImageAttachment = nil
- },
- onRemoveDocumentAttachment: {
- pendingDocumentAttachment = nil
- viewModel.setDocumentIndexState(.notIndexed)
- },
- onChooseVisionModel: {
- showingVisionModelSelection = true
- },
- onChooseDocumentModels: {
- showNextDocumentModelPicker()
- },
- onComposerAction: handleComposerAction,
- onSend: sendMessage
- )
+
+ // Suggestions belong to the composer, not the transcript: they are
+ // what the reader reaches for next, so they sit against the editor
+ // rather than under a greeting a screen away. Withdrawn once a turn
+ // exists, and while the keyboard is up — a raised keyboard has
+ // already halved the viewport and the chips would take a third of
+ // what is left.
+ VStack(spacing: 0) {
+ if viewModel.messages.isEmpty && !keyboardIsCoveringContent {
+ ChatPromptSuggestionsRow(prompts: suggestionSet) { prompt in
+ viewModel.currentInput = prompt.text
+ isTextFieldFocused = true
+ }
+ .padding(.vertical, Space.sm)
+ .transition(.opacity)
+ }
+
+ composerBar
+ }
+ .background(ComposerPalette.barFill)
}
+ .motionAware(Motion.standardFade, value: viewModel.messages.isEmpty)
+ .motionAware(Motion.standardFade, value: isTextFieldFocused)
+ }
+
+ /// Whether a software keyboard is currently taking half the viewport.
+ ///
+ /// Only ever true on iOS. The Mac gives the composer focus the moment a
+ /// window opens (`.defaultFocus`, plus `focusComposer()` on conversation
+ /// change), so gating the chips on focus there hid them permanently.
+ private var keyboardIsCoveringContent: Bool {
+ #if os(iOS)
+ isTextFieldFocused
+ #else
+ false
+ #endif
+ }
+
+ private var suggestionSet: [StarterPrompt] {
+ StarterPrompt.set(
+ toolsEnabled: viewModel.toolsEnabled,
+ loraActive: !viewModel.loraAdapters.isEmpty
+ )
+ }
+
+ private var composerBar: some View {
+ ChatComposerBar(
+ viewModel: viewModel,
+ isTextFieldFocused: $isTextFieldFocused,
+ imageAttachment: pendingImageAttachment,
+ documentAttachment: pendingDocumentAttachment,
+ attachmentRejection: attachmentRejection,
+ isVisionModelReady: isVisionModelReady,
+ areDocumentModelsReady: areDocumentModelsReady,
+ canSendCurrentTurn: canSendCurrentTurn,
+ compact: isCompactComposer,
+ onRemoveImageAttachment: {
+ pendingImageAttachment = nil
+ },
+ onRemoveDocumentAttachment: {
+ pendingDocumentAttachment = nil
+ viewModel.setDocumentIndexState(.notIndexed)
+ },
+ onDismissAttachmentRejection: {
+ attachmentRejection = nil
+ },
+ onChooseVisionModel: {
+ showingVisionModelSelection = true
+ },
+ onChooseDocumentModels: {
+ showNextDocumentModelPicker()
+ },
+ onResolveBlocked: {
+ showingModelSelection = true
+ },
+ onComposerAction: handleComposerAction,
+ onSend: sendMessage
+ )
+ }
+
+ /// A landscape phone with the keyboard up cannot fit the status strips and
+ /// the editor at once. `verticalSizeClass` is the platform's own name for
+ /// that viewport, and it stands in for Android's measured IME-safe height.
+ private var isCompactComposer: Bool {
+ #if os(iOS)
+ verticalSizeClass == .compact
+ #else
+ false
+ #endif
}
/// What a drag over the transcript looks like.
@@ -722,17 +793,25 @@ extension ChatInterfaceView {
Task { @MainActor in
do {
guard let attachment = try await ChatAttachmentLoader.pasteboardAttachment() else {
- errorMessage = "There's nothing on the clipboard the chat can use. "
+ attachmentRejection = "There's nothing on the clipboard the chat can use. "
+ "Copy an image, or a PDF, .txt, .md, or .json file."
return
}
stage(attachment)
} catch {
- errorMessage = error.localizedDescription
+ attachmentRejection = error.localizedDescription
}
}
case .talk:
showingTalkMode = true
+ case .openAdvanced:
+ #if os(iOS)
+ showingAdvancedHub = true
+ #else
+ // The Mac reaches Advanced from its sidebar (⌘3), so the menu row is
+ // compiled out there and this case has nothing to do.
+ break
+ #endif
}
}
@@ -747,6 +826,11 @@ extension ChatInterfaceView {
/// document at once" representable, and the composer cannot send that.
@MainActor
private func stage(_ attachment: ChatPendingAttachment) {
+ // Reaching here means a file was accepted, so any previous refusal is
+ // now stale. Without this the rejection strip sat above the pill of the
+ // file that had just worked, contradicting it.
+ attachmentRejection = nil
+
switch attachment {
case .image(let image):
pendingImageAttachment = image
@@ -783,10 +867,11 @@ extension ChatInterfaceView {
filename: provider.suggestedName ?? "Dropped image"
)))
} else {
- errorMessage = "That can't be attached. Use an image, or a PDF, .txt, .md, or .json file."
+ attachmentRejection = "That can't be attached. "
+ + "Use an image, or a PDF, .txt, .md, or .json file."
}
} catch {
- errorMessage = error.localizedDescription
+ attachmentRejection = error.localizedDescription
}
}
return true
@@ -901,7 +986,7 @@ extension ChatInterfaceView {
do {
stage(.image(try await ChatAttachmentLoader.imageAttachment(from: item)))
} catch {
- errorMessage = error.localizedDescription
+ attachmentRejection = error.localizedDescription
}
}
@@ -923,11 +1008,11 @@ extension ChatInterfaceView {
// size are still checked here.
stage(try await ChatAttachmentLoader.attachment(forFileAt: url))
} catch {
- errorMessage = error.localizedDescription
+ attachmentRejection = error.localizedDescription
}
}
case .failure(let error):
- errorMessage = error.localizedDescription
+ attachmentRejection = error.localizedDescription
}
}
diff --git a/RunAnywhereAI/Features/Chat/Views/ChatMessageListView.swift b/RunAnywhereAI/Features/Chat/Views/ChatMessageListView.swift
index 03ada65c..0a51b66a 100644
--- a/RunAnywhereAI/Features/Chat/Views/ChatMessageListView.swift
+++ b/RunAnywhereAI/Features/Chat/Views/ChatMessageListView.swift
@@ -43,6 +43,8 @@ enum ComposerAction {
/// keyboard shortcut and no other way to hand a screenshot to the chat.
case pasteAttachment
case talk
+ /// The SDK demo hub. iOS only: the Mac reaches it from the sidebar.
+ case openAdvanced
}
// MARK: - Chat Messages View
@@ -94,8 +96,8 @@ struct ChatMessageListView: View {
VStack(spacing: Space.xl) {
// The shared figure, so the empty transcript is recognisably the same
// object as every other empty state in the app. 96pt rather than the
- // 132pt hero: this state also carries four starter prompts, and a
- // full-size mark pushed them below the fold on the shortest phone.
+ // 132pt hero: the starter prompts sit directly under this in the
+ // composer, and a full-size mark pushed them off the shortest phone.
EmptyStateMark(systemImage: "bubble.left.and.bubble.right", diameter: 96)
VStack(spacing: Space.sm) {
@@ -108,8 +110,6 @@ struct ChatMessageListView: View {
.foregroundStyle(AppColors.textSecondary)
.multilineTextAlignment(.center)
}
-
- starterPrompts
}
.padding(.horizontal, Space.screenMargin)
.padding(.vertical, Space.xxl)
@@ -126,23 +126,6 @@ struct ChatMessageListView: View {
}
}
- /// `.adaptive` rather than two fixed columns: two columns in a 1200pt Mac
- /// window stretched each chip to 500pt of mostly empty card, and two columns
- /// on a phone in landscape clipped the subtitles.
- private var starterPrompts: some View {
- LazyVGrid(
- columns: [GridItem(.adaptive(minimum: 200, maximum: 320), spacing: Space.md)],
- spacing: Space.md
- ) {
- ForEach(StarterPrompt.all) { prompt in
- StarterPromptChip(prompt: prompt) {
- viewModel.currentInput = prompt.text
- isTextFieldFocused = true
- }
- }
- }
- }
-
// MARK: - Message List
private var messageListView: some View {
@@ -203,110 +186,108 @@ struct ChatMessageListView: View {
// MARK: - Starter Prompts
-/// The four things a consumer opens an on-device assistant to do.
+/// The things a consumer opens an on-device assistant to do, in the set that
+/// suits whatever the next turn can actually reach.
///
-/// `title` is the shared label — the same string Android's `generalSuggestions` and the web's
-/// `STARTER_PROMPTS` show — so the same chip is recognisable on all three. It used to be a
-/// single word ("Plan"), which made the four chips look like a different feature from the
-/// two-word set on the other two apps. `subtitle` is this platform's extra line and qualifies
-/// the label rather than repeating it. A value type
-/// rather than four hand-built call sites, so the grid stays one `ForEach` and
-/// the copy lives in one place.
+/// `title` is the shared label — the same string Android's `PromptSuggestions`
+/// and the web's `STARTER_PROMPTS` show — so the same chip is recognisable on
+/// all three. The three sets and their copy mirror Android's `generalSuggestions`
+/// / `toolSuggestions` / `personalizedSuggestions` exactly; only the general set
+/// goes without icons there, and it does here too.
struct StarterPrompt: Identifiable {
let id: String
- let icon: String
+ let icon: String?
let title: String
- let subtitle: String
let text: String
- static let all: [StarterPrompt] = [
+ /// Which set to show. Mirrors Android's `PromptMode`: an adapter outranks
+ /// tools, because a personalized model is the more specific fact about what
+ /// the next turn will be.
+ static func set(toolsEnabled: Bool, loraActive: Bool) -> [StarterPrompt] {
+ if loraActive { return personalized }
+ return toolsEnabled ? tools : general
+ }
+
+ static let general: [StarterPrompt] = [
StarterPrompt(
id: "plan",
- icon: "list.bullet.clipboard",
+ icon: nil,
title: "Plan my day",
- subtitle: "from messy notes",
text: "Turn this messy list into a realistic plan with the top three priorities:"
),
StarterPrompt(
id: "rewrite",
- icon: "pencil.line",
+ icon: nil,
title: "Rewrite clearly",
- subtitle: "warm and concise",
text: "Rewrite this so it is clear, warm, and concise:"
),
StarterPrompt(
id: "compare",
- icon: "arrow.left.arrow.right",
+ icon: nil,
title: "Compare options",
- subtitle: "weigh the tradeoffs",
text: "Compare these options, explain the tradeoffs, and recommend one:"
),
StarterPrompt(
id: "summarize",
- icon: "checklist",
+ icon: nil,
title: "Summarize notes",
- subtitle: "into next steps",
text: "Summarize these notes into decisions, action items, and open questions:"
)
]
-}
-
-private struct StarterPromptChip: View {
- let prompt: StarterPrompt
- let action: () -> Void
- @State private var isHovering = false
- var body: some View {
- Button {
- Haptics.light()
- action()
- } label: {
- HStack(spacing: Space.md) {
- Image(systemName: prompt.icon)
- .font(.system(size: 15, weight: .medium))
- .foregroundStyle(AppColors.primaryAccent)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: Space.hair) {
- Text(prompt.title)
- .appType(.cardTitle)
- .foregroundStyle(AppColors.textPrimary)
- .lineLimit(1)
- Text(prompt.subtitle)
- .appType(.meta)
- .foregroundStyle(AppColors.textSecondary)
- .lineLimit(1)
- }
+ static let tools: [StarterPrompt] = [
+ StarterPrompt(
+ id: "trip",
+ icon: "checklist",
+ title: "Trip plan",
+ text: "Help me make a practical packing list for a weekend city trip."
+ ),
+ StarterPrompt(
+ id: "time",
+ icon: "clock",
+ title: "Time check",
+ text: "What time is it in London, Tokyo, and San Francisco?"
+ ),
+ StarterPrompt(
+ id: "battery",
+ icon: "battery.100",
+ title: "Device status",
+ text: "Check my battery level and tell me if I should charge before leaving."
+ ),
+ StarterPrompt(
+ id: "math",
+ icon: "function",
+ title: "Quick math",
+ text: "Calculate 15% of 240, then show the shortcut."
+ )
+ ]
- Spacer(minLength: 0)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(Space.cardPadding)
- .cardSurface(radius: Radius.lg)
- .overlay(
- RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
- .strokeBorder(
- isHovering ? AppColors.primaryAccent.opacity(0.45) : .clear,
- lineWidth: Stroke.regular
- )
- )
- // Hover lifts the card a hair off the page. On a Mac, a border that
- // changes color is easy to miss on a grid of four; a card that rises
- // is unmistakable, and it says "this is pressable" rather than just
- // "the pointer is here". No-op on a phone, which has no hover.
- .shadow(
- color: AppColors.primaryAccent.opacity(isHovering ? 0.18 : 0),
- radius: isHovering ? 12 : 0,
- y: isHovering ? 4 : 0
- )
- .scaleEffect(isHovering ? 1.012 : 1)
- }
- .buttonStyle(.plain)
- .onHover { isHovering = $0 }
- // `micro`, not `standard`: hover feedback slower than ~150ms lags the
- // pointer, and on a grid the reader notices the lag before the lift.
- .motionAware(Motion.microFade, value: isHovering)
- }
+ static let personalized: [StarterPrompt] = [
+ StarterPrompt(
+ id: "reply",
+ icon: "person",
+ title: "Draft reply",
+ text: "Draft a concise, kind reply to this message:"
+ ),
+ StarterPrompt(
+ id: "tone",
+ icon: "slider.horizontal.3",
+ title: "Tighten tone",
+ text: "Make this message more direct while keeping it friendly:"
+ ),
+ StarterPrompt(
+ id: "memo",
+ icon: "doc.text",
+ title: "Decision memo",
+ text: "Turn this into a one-page decision memo with risks and next steps:"
+ ),
+ StarterPrompt(
+ id: "coach",
+ icon: "bolt",
+ title: "Coach me",
+ text: "Help me think through this situation and suggest my next move:"
+ )
+ ]
}
// MARK: - Message Insert Transition
@@ -322,342 +303,9 @@ extension AnyTransition {
}
}
-// MARK: - Chat Input Area
-
-struct ChatInputAreaView: View {
- @Bindable var viewModel: LLMViewModel
- @FocusState.Binding var isTextFieldFocused: Bool
- @Binding var showingLoRAManagement: Bool
- @ObservedObject var settingsViewModel: SettingsViewModel
- @ObservedObject var toolSettingsViewModel: ToolSettingsViewModel
- let imageAttachment: ChatImageAttachment?
- let documentAttachment: ChatDocumentAttachment?
- let isVisionModelReady: Bool
- let areDocumentModelsReady: Bool
- let canSendCurrentTurn: Bool
- let onRemoveImageAttachment: () -> Void
- let onRemoveDocumentAttachment: () -> Void
- let onChooseVisionModel: () -> Void
- let onChooseDocumentModels: () -> Void
- let onComposerAction: (ComposerAction) -> Void
- let onSend: () -> Void
-
- private var hasText: Bool {
- !viewModel.currentInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
-
- var body: some View {
- VStack(spacing: Space.sm) {
- if !activeBadges.isEmpty {
- HStack(spacing: Space.sm) {
- ForEach(activeBadges) { badge in
- badgeView(badge)
- }
- Spacer(minLength: 0)
- }
- }
-
- if let imageAttachment {
- ImageAttachmentPill(
- attachment: imageAttachment,
- isVisionModelReady: isVisionModelReady,
- onRemove: onRemoveImageAttachment,
- onChooseVisionModel: onChooseVisionModel
- )
- }
-
- if let documentAttachment {
- DocumentAttachmentPill(
- attachment: documentAttachment,
- areModelsReady: areDocumentModelsReady,
- indexState: viewModel.documentIndexState,
- onRemove: onRemoveDocumentAttachment,
- onChooseModels: onChooseDocumentModels
- )
- }
-
- composerRow
- }
- .padding(.horizontal, Space.screenMargin)
- .padding(.top, Space.md)
- .padding(.bottom, Space.lg)
- .measured(Measure.text)
- .background(AppColors.backgroundGrouped)
- .motionAware(Motion.snappy, value: composerLayoutSignature)
- }
-
- /// Everything that changes the composer's height, in one value — so growth
- /// animates once instead of four modifiers each animating a different
- /// subview at a different speed.
- private var composerLayoutSignature: String {
- let badges = activeBadges.map(\.id).joined(separator: ",")
- return "\(badges)|\(imageAttachment == nil)|\(documentAttachment == nil)"
- }
-
- // MARK: - Composer Row
-
- private var composerRow: some View {
- HStack(alignment: .bottom, spacing: Space.sm) {
- composerMenu
-
- TextField(inputPlaceholder, text: $viewModel.currentInput, axis: .vertical)
- .textFieldStyle(.plain)
- .appType(.body)
- .lineLimit(1...6)
- .padding(.vertical, Space.sm)
- .focused($isTextFieldFocused)
- .onSubmit(onSend)
- .submitLabel(.send)
-
- trailingAction
- }
- .padding(.horizontal, Space.md)
- .padding(.vertical, Space.xs)
- .background(
- RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
- .fill(AppColors.surface)
- )
- .overlay(
- RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
- .strokeBorder(
- isTextFieldFocused ? AppColors.primaryAccent.opacity(0.5) : AppColors.borderSubtle,
- lineWidth: isTextFieldFocused ? Stroke.regular : Hairline.width
- )
- )
- .motionAware(Motion.microFade, value: isTextFieldFocused)
- }
-
- /// Attachments and per-turn switches in one native menu.
- ///
- /// The switches used to be two always-on circular buttons in the row, which
- /// on a phone left the text field about 150pt wide and gave two rarely
- /// changed settings the same visual weight as Send. Their *state* still
- /// shows, as a badge above the composer — visible always, changed from a
- /// menu, which is the right trade for something you set once.
- private var composerMenu: some View {
- Menu {
- Section {
- Button {
- onComposerAction(.attachFile)
- } label: {
- Label("Attach Document", systemImage: "doc.badge.plus")
- }
-
- Button {
- onComposerAction(.attachPhoto)
- } label: {
- Label("Attach Image", systemImage: "photo")
- }
-
- // Not iOS-only. VLMCameraView ships real macOS support — an
- // NSViewRepresentable preview and a Privacy & Security deep link —
- // and ChatInterfaceView already presents it under #if os(macOS),
- // but this was the only action that set showingVisionWorkbench.
- // Behind an iOS guard that made a finished feature unreachable on
- // a Mac that has working cameras.
- Button {
- onComposerAction(.takePhoto)
- } label: {
- // `eye` — looking through a live feed — and not `livephoto`, which
- // VLMCameraView already uses for the auto-streaming toggle. It is also
- // what `RAModelCategory.consumerCapabilityIcon` returns for vision, and
- // the glyph Android (`RACIcons.Outline.Eye`) and the web app draw here.
- Label("Live Camera", systemImage: "eye")
- }
-
- // Disabled rather than hidden when the clipboard is empty: a row
- // that appears and disappears is a control nobody learns, and it
- // is the only signal on a phone that pasting a screenshot is
- // even possible.
- Button {
- onComposerAction(.pasteAttachment)
- } label: {
- Label("Paste", systemImage: "doc.on.clipboard")
- }
- .disabled(!ChatAttachmentLoader.pasteboardHasAttachment)
- }
-
- Section {
- Toggle(isOn: $settingsViewModel.thinkingModeEnabled) {
- Label("Show Reasoning", systemImage: "brain")
- }
- .disabled(!viewModel.loadedModelSupportsThinking)
-
- Toggle(isOn: $toolSettingsViewModel.toolCallingEnabled) {
- // `globe` — the network — rather than `safari`, one browser's mark
- // standing in for the web. Matches the web app's `globe` and Android's
- // new `RACIcons.Outline.Globe`.
- Label("Web Tools", systemImage: "globe")
- }
- }
- } label: {
- Image(systemName: "plus")
- .font(.system(size: 16, weight: .semibold))
- .foregroundStyle(AppColors.textSecondary)
- .frame(width: 32, height: 32)
- .contentShape(Circle())
- }
- // `.button` + `.plain` rather than `.borderlessButton`: the latter is
- // deprecated on iOS in favor of exactly this pair, and the default menu
- // style paints AppKit's bordered chrome around the glyph on the Mac.
- .menuStyle(.button)
- .buttonStyle(.plain)
- .menuIndicator(.hidden)
- .fixedSize()
- .accessibilityLabel("Attach or change options")
- }
-
- /// One slot, three states — stop while generating, send when there is
- /// something to send, otherwise voice.
- ///
- /// A single `Button` whose symbol is computed, not three sibling buttons: the
- /// slot keeps its identity, so `.contentTransition(.symbolEffect(.replace))`
- /// actually fires and the row never reflows when send becomes stop
- /// mid-sentence. A permanently dimmed Send is also a dead end; offering
- /// voice in its place makes the empty composer actionable.
- private var trailingAction: some View {
- Button {
- Haptics.light()
- switch trailingRole {
- case .stop: viewModel.stopGeneration()
- case .send: onSend()
- case .talk: onComposerAction(.talk)
- }
- } label: {
- Image(systemName: trailingRole.icon)
- .font(.system(size: 28))
- .foregroundStyle(trailingTint)
- .frame(width: 32, height: 32)
- .contentShape(Circle())
- }
- .buttonStyle(.plain)
- .disabled(trailingRole == .send && !canSendCurrentTurn)
- .accessibilityLabel(trailingRole.label)
- .contentTransition(.symbolEffect(.replace))
- .motionAware(Motion.snappy, value: trailingRole)
- }
-
- private enum TrailingRole: Equatable {
- case stop
- case send
- case talk
-
- var icon: String {
- switch self {
- case .stop: return "stop.circle.fill"
- case .send: return "arrow.up.circle.fill"
- case .talk: return "mic.circle.fill"
- }
- }
-
- var label: String {
- switch self {
- case .stop: return "Stop generating"
- case .send: return "Send message"
- case .talk: return "Talk to the assistant"
- }
- }
- }
-
- private var trailingRole: TrailingRole {
- if viewModel.isGenerating { return .stop }
- return hasText ? .send : .talk
- }
-
- private var trailingTint: Color {
- trailingRole == .send && !canSendCurrentTurn
- ? AppColors.statusGray
- : AppColors.primaryAccent
- }
-
- private var inputPlaceholder: String {
- if imageAttachment != nil { return "Ask about this image…" }
- if documentAttachment != nil { return "Ask about this document…" }
- return "Message"
- }
-
- // MARK: - Badges
-
- /// A live capability that changes what the next turn will do. Not a setting —
- /// state, surfaced where the turn is composed.
- private struct ComposerBadge: Identifiable {
- let id: String
- let icon: String
- let title: String
- let tint: Color
- let action: (() -> Void)?
- }
-
- private var activeBadges: [ComposerBadge] {
- var badges: [ComposerBadge] = []
-
- if settingsViewModel.thinkingModeEnabled && viewModel.loadedModelSupportsThinking {
- badges.append(
- ComposerBadge(
- id: "thinking",
- icon: "brain",
- title: "Reasoning",
- tint: AppColors.primaryPurple,
- action: nil
- )
- )
- }
-
- if viewModel.useToolCalling && !viewModel.isUsingConnect {
- badges.append(
- ComposerBadge(
- id: "tools",
- icon: "globe",
- title: toolSettingsViewModel.registeredTools.isEmpty ? "Preparing tools…" : "Web tools",
- tint: AppColors.primaryAccent,
- action: nil
- )
- )
- }
-
- if !viewModel.isUsingConnect && !viewModel.loraAdapters.isEmpty {
- badges.append(
- ComposerBadge(
- id: "lora",
- icon: "sparkles",
- title: "LoRA ×\(viewModel.loraAdapters.count)",
- tint: AppColors.primaryPurple
- ) {
- Task { await viewModel.refreshAvailableAdapters() }
- showingLoRAManagement = true
- }
- )
- }
-
- return badges
- }
-
- @ViewBuilder
- private func badgeView(_ badge: ComposerBadge) -> some View {
- let content = HStack(spacing: Space.xs) {
- Image(systemName: badge.icon)
- .font(.system(size: 10, weight: .semibold))
- Text(badge.title)
- .appType(.chip)
- }
- .foregroundStyle(badge.tint)
- .padding(.horizontal, Space.sm)
- .padding(.vertical, Space.xs)
- .background(Capsule().fill(badge.tint.opacity(0.12)))
-
- if let action = badge.action {
- Button(action: action) { content }
- .buttonStyle(.plain)
- } else {
- content
- .accessibilityLabel("\(badge.title) is on")
- }
- }
-}
-
// MARK: - Attachment Pills
-private struct ImageAttachmentPill: View {
+struct ImageAttachmentPill: View {
let attachment: ChatImageAttachment
let isVisionModelReady: Bool
let onRemove: () -> Void
@@ -701,7 +349,7 @@ private struct ImageAttachmentPill: View {
}
}
-private struct DocumentAttachmentPill: View {
+struct DocumentAttachmentPill: View {
let attachment: ChatDocumentAttachment
let areModelsReady: Bool
let indexState: ChatDocumentIndexState
diff --git a/RunAnywhereAI/Features/Models/ModelOrg.swift b/RunAnywhereAI/Features/Models/ModelOrg.swift
index b309184b..eb79c70a 100644
--- a/RunAnywhereAI/Features/Models/ModelOrg.swift
+++ b/RunAnywhereAI/Features/Models/ModelOrg.swift
@@ -17,11 +17,14 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable {
case alibaba
case google
case microsoft
+ case ibm
case deepseek
case liquid
+ case deepgrove
case mistral
case prism
case openAI
+ case zhipu
case huggingFace
case apple
case openSource
@@ -35,11 +38,14 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable {
case .alibaba: return "Alibaba"
case .google: return "Google"
case .microsoft: return "Microsoft"
+ case .ibm: return "IBM"
case .deepseek: return "DeepSeek"
case .liquid: return "Liquid AI"
+ case .deepgrove: return "Deepgrove"
case .mistral: return "Mistral AI"
case .prism: return "Prism"
case .openAI: return "OpenAI"
+ case .zhipu: return "Zhipu AI"
case .huggingFace: return "Hugging Face"
case .apple: return "Apple"
case .openSource: return "Open source"
@@ -53,11 +59,14 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable {
case .alibaba: return "q.circle.fill"
case .google: return "g.circle.fill"
case .microsoft: return "m.square.fill"
+ case .ibm: return "cpu"
case .deepseek: return "brain.head.profile"
case .liquid: return "drop.fill"
+ case .deepgrove: return "leaf.fill"
case .mistral: return "wind"
case .prism: return "triangle.fill"
case .openAI: return "waveform"
+ case .zhipu: return "g.circle"
case .huggingFace: return "face.smiling.fill"
case .apple: return "apple.logo"
case .openSource: return "shippingbox.fill"
@@ -103,17 +112,24 @@ enum ModelOrgCatalog {
]),
OrgRule(org: .deepseek, patterns: ["deepseek"]),
OrgRule(org: .prism, patterns: ["bonsai"]),
- OrgRule(org: .microsoft, patterns: ["phi"]),
+ OrgRule(org: .deepgrove, patterns: ["maple"]),
+ OrgRule(org: .ibm, patterns: ["granite"]),
+ // `fara` above Microsoft's `phi` only so the two cannot fight if Fara is
+ // ever renamed. Fara1.5 ships mirrored under our own HF org, so the
+ // catalog row does not name a publisher; this files it by its own name
+ // rather than guessing one into a UI label.
+ OrgRule(org: .microsoft, patterns: ["phi", "fara"]),
OrgRule(org: .google, patterns: ["gemma", "embeddinggemma", "siglip"]),
- OrgRule(org: .meta, patterns: ["llama"]),
+ OrgRule(org: .meta, patterns: ["llama", "muse-glimmer", "muse_glimmer"]),
OrgRule(org: .alibaba, patterns: ["qwen"]),
OrgRule(org: .liquid, patterns: ["lfm2"]),
- OrgRule(org: .mistral, patterns: ["mistral"]),
+ OrgRule(org: .mistral, patterns: ["mistral", "ministral"]),
OrgRule(org: .huggingFace, patterns: ["smollm", "smolvlm"]),
OrgRule(org: .openAI, patterns: ["whisper"]),
+ OrgRule(org: .zhipu, patterns: ["glm"]),
OrgRule(org: .openSource, patterns: [
"internvl", "lama_dilated", "moonshine", "melo", "kokoro",
- "kitten", "piper", "silero", "minilm", "soprano", "pocket-tts", "glm-asr",
+ "kitten", "piper", "silero", "minilm", "soprano", "pocket-tts",
"segformer",
]),
]
diff --git a/RunAnywhereAI/Features/Models/ModelRecommendation.swift b/RunAnywhereAI/Features/Models/ModelRecommendation.swift
index 6f83a2a1..b8dcb81c 100644
--- a/RunAnywhereAI/Features/Models/ModelRecommendation.swift
+++ b/RunAnywhereAI/Features/Models/ModelRecommendation.swift
@@ -80,9 +80,10 @@ struct ModelRecommendationEngine {
let recommendedLLMs = pickModels(
ids: prefs.llmIDs,
+ category: .language,
from: byID,
canRunByModelID: canRunByModelID,
- limit: tier == .highEnd ? 5 : 4
+ limit: 5
)
let appleFoundation = appleFoundationAvailable
@@ -94,10 +95,31 @@ struct ModelRecommendationEngine {
return RecommendedSelection(
defaultChatModel: defaultChat,
recommendedLLMs: recommendedLLMs,
- recommendedASR: pickFirst(ids: prefs.asrIDs, from: byID, canRunByModelID: canRunByModelID),
- recommendedTTS: pickFirst(ids: prefs.ttsIDs, from: byID, canRunByModelID: canRunByModelID),
- recommendedVLM: pickFirst(ids: prefs.vlmIDs, from: byID, canRunByModelID: canRunByModelID),
- recommendedEmbedding: pickFirst(ids: prefs.embeddingIDs, from: byID, canRunByModelID: canRunByModelID)
+ recommendedASR: pickFirst(
+ ids: prefs.asrIDs,
+ category: .speechRecognition,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ ),
+ recommendedTTS: pickFirst(
+ ids: prefs.ttsIDs,
+ category: .speechSynthesis,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ ),
+ recommendedVLM: pickFirst(
+ ids: prefs.vlmIDs,
+ category: .multimodal,
+ secondaryCategory: .vision,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ ),
+ recommendedEmbedding: pickFirst(
+ ids: prefs.embeddingIDs,
+ category: .embedding,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ )
)
}
@@ -117,13 +139,33 @@ struct ModelRecommendationEngine {
? models.first { $0.isAppleFoundationModel && $0.category == .language }
: nil
let llm = appleFoundation
- ?? pickFirst(ids: prefs.llmIDs, from: byID, canRunByModelID: canRunByModelID)
+ ?? pickFirst(ids: prefs.llmIDs, category: .language, from: byID, canRunByModelID: canRunByModelID)
return VoicePipeline(
- stt: pickFirst(ids: prefs.asrIDs, from: byID, canRunByModelID: canRunByModelID),
+ stt: pickFirst(
+ ids: prefs.asrIDs,
+ category: .speechRecognition,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ ),
llm: llm,
- tts: pickFirst(ids: prefs.ttsIDs, from: byID, canRunByModelID: canRunByModelID),
- vad: byID[Self.vadModelID]
+ tts: pickFirst(
+ ids: prefs.ttsIDs,
+ category: .speechSynthesis,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ ),
+ // Through `pickFirst` like every other component, rather than a
+ // hand-rolled lookup. That one skipped the `can_run` gate the rest of
+ // the pipeline applies, so a VAD commons had already ruled out could
+ // still be handed back, and it read `Dictionary.values.first`, whose
+ // order is undefined once a catalog carries a second VAD row.
+ vad: pickFirst(
+ ids: [Self.vadModelID],
+ category: .voiceActivityDetection,
+ from: byID,
+ canRunByModelID: canRunByModelID
+ )
)
}
@@ -133,26 +175,62 @@ struct ModelRecommendationEngine {
// MARK: - Selection helpers
/// Keep the ordered ids that exist in the catalog and pass can_run (when
- /// known), up to `limit`. Preserves the curated order (light → smart).
+ /// known), up to `limit`, then back-fill from the category if the curated
+ /// list came up short.
+ ///
+ /// The back-fill is not a nicety. Curated ids are the app's opinion about
+ /// which models are good; the catalog is edited on its own schedule, in its
+ /// own PR, usually by someone not reading this file. Without a floor, a
+ /// catalog pass that renames or drops a family silently turns the Models
+ /// screen into a screen that recommends nothing — which is exactly what the
+ /// 0.20.24 catalog rebuild did here, taking all five reachable ids with it.
+ /// Mirrors Android `ModelRecommendation.pickLLMs`.
private func pickModels(
ids: [String],
+ category: RAModelCategory,
from byID: [String: RAModelInfo],
canRunByModelID: [String: Bool],
limit: Int
) -> [RAModelInfo] {
var picked: [RAModelInfo] = []
+ var pickedIDs = Set()
+
for id in ids {
guard picked.count < limit else { break }
if let model = byID[id], isRunnable(model, canRunByModelID: canRunByModelID) {
picked.append(model)
+ pickedIDs.insert(model.id)
}
}
+
+ guard picked.count < minimumRecommendations else { return picked }
+
+ // Smallest first, so a back-filled list still opens with something the
+ // device can plausibly run rather than with the biggest file present.
+ let backfill = byID.values
+ .filter { $0.category == category }
+ .filter { !pickedIDs.contains($0.id) }
+ .filter { isRunnable($0, canRunByModelID: canRunByModelID) }
+ .sorted { $0.consumerSizeBytes < $1.consumerSizeBytes }
+
+ for model in backfill where picked.count < limit {
+ picked.append(model)
+ pickedIDs.insert(model.id)
+ }
return picked
}
- /// First catalog model from the ordered ids that passes can_run when known.
+ /// Below this, the curated list is treated as having failed and the category
+ /// back-fill runs. Matches Android's threshold.
+ private let minimumRecommendations = 3
+
+ /// First catalog model from the ordered ids that passes can_run when known,
+ /// falling back to the smallest model in the category. Same reasoning as
+ /// `pickModels`: a stale id must degrade to a worse answer, never to none.
private func pickFirst(
ids: [String],
+ category: RAModelCategory,
+ secondaryCategory: RAModelCategory? = nil,
from byID: [String: RAModelInfo],
canRunByModelID: [String: Bool]
) -> RAModelInfo? {
@@ -161,7 +239,10 @@ struct ModelRecommendationEngine {
return model
}
}
- return nil
+ return byID.values
+ .filter { $0.category == category || $0.category == secondaryCategory }
+ .filter { isRunnable($0, canRunByModelID: canRunByModelID) }
+ .min { $0.consumerSizeBytes < $1.consumerSizeBytes }
}
/// Prefer commons `can_run`. When the SDK has not returned a verdict for
@@ -173,111 +254,118 @@ struct ModelRecommendationEngine {
// MARK: - Curated per-tier preferences (real registered ids)
+ /// `HardwareTierResolver` returns `.unknown` on every device today, so a
+ /// straight tier switch meant one list was the only list anything ever read.
+ /// Until commons publishes a typed tier, the platform is the one honest
+ /// signal available: a Mac is not a phone. That is not a RAM heuristic and
+ /// not a memory budget, so it does not cross the line the rest of this file
+ /// holds. A real tier, when it arrives, outranks it.
private func preferences(for tier: HardwareTier) -> TierPreferences {
switch tier {
- case .unknown, .midRange: return .midRange
case .lowEnd: return .lowEnd
+ case .midRange: return .midRange
case .highEnd: return .highEnd
+ case .unknown:
+ #if os(macOS)
+ return .highEnd
+ #else
+ return .midRange
+ #endif
}
}
}
-// MARK: - Curated id lists (real registered ids from ModelCatalogBootstrap)
+// MARK: - Curated id lists
+
+// Ids from the 0.20.24 catalog rebuild, ordered by how good the model is rather
+// than by how small it is. Size still decides the back-fill, because a fallback
+// should be cheap; these lists are where the app states a preference.
+//
+// Per-app curated lists are a deliberate, known-bad tradeoff: six apps hold six
+// copies of this judgement, which is the shape of the breakage the back-fill
+// above now absorbs. Promoting the ranking onto the catalog row is the escape
+// hatch if it bites twice.
+//
+// MLX ids appear alongside their GGUF twins. MLX registration fails on the arm64
+// simulator, so those rows are simply absent there and the next id wins.
private extension ModelRecommendationEngine.TierPreferences {
- /// Smallest quantized / ONNX variants only.
+ /// Smallest current-generation models. Nothing above ~2B.
static let lowEnd = Self(
llmIDs: [
- "mlx-lfm2-350m",
- "lfm2-350m-q4_k_m",
- "mlx-qwen3-0.6b-4bit",
- "qwen3-0.6b-q4_k_m"
+ "mlx-lfm2.5-230m-4bit",
+ "lfm2.5-230m-q4_k_m",
+ "mlx-qwen3.5-0.8b-mlx-4bit",
+ "qwen3.5-0.8b-q4_k_m"
],
asrIDs: [
- "sherpa-onnx-whisper-tiny.en",
- "mlx-qwen3-asr-0.6b-8bit"
+ "sherpa-onnx-whisper-tiny.en"
],
ttsIDs: [
- "mlx-soprano-1.1-80m-5bit",
"vits-piper-en_US-lessac-medium"
],
vlmIDs: [
"smolvlm2-256m-video-instruct-q8_0",
- "lfm2-vl-450m-q8_0"
+ "lfm2.5-vl-3b-q4_k_m"
],
embeddingIDs: [
- "all-minilm-l6-v2",
- "mlx-qwen3-embedding-0.6b-4bit-dwq"
+ "all-minilm-l6-v2"
]
)
- /// A spread: tiny/fast, balanced, tool-calling, thinking.
+ /// The phone and iPad default: a spread from instant to genuinely capable,
+ /// none of it large enough to be a bad idea on battery.
static let midRange = Self(
llmIDs: [
- "mlx-lfm2-350m",
- "mlx-llama-3.2-1b-instruct-4bit",
- "lfm2-1.2b-tool-q4_k_m",
- "mlx-qwen3-0.6b-4bit",
- "qwen3-1.7b-q4_k_m"
+ "mlx-lfm2.5-230m-4bit",
+ "mlx-lfm2.5-1.2b-instruct-4bit",
+ "lfm2.5-1.2b-instruct-q4_k_m",
+ "mlx-qwen3.5-2b-4bit",
+ "qwen3.5-2b-q4_k_m"
],
asrIDs: [
- "mlx-qwen3-asr-0.6b-8bit",
"sherpa-onnx-whisper-tiny.en"
],
ttsIDs: [
- "mlx-soprano-1.1-80m-5bit",
"vits-piper-en_US-lessac-medium"
],
- // No MLX Qwen2-VL. Measured on this Mac (M4 Max, MLX 4-bit): every vision
- // turn decoded its opening token and then repeated only that token —
- // 23 × "The" for "what colour is the circle?", the same for a photograph
- // and for a synthetic card, on a first turn and on later ones. The prompt
- // was sized correctly (418 tokens, image tokens included) and the MLX
- // *text* path answered normally with identical sampler settings, so this
- // is the model on this runtime, not our image or generation plumbing.
- // The web SDK already forces Qwen2-VL off WebGPU for an f16 M-RoPE
+ // No MLX Qwen2-VL, and no Qwen-family VLM as the default. Measured on an
+ // M4 Max (MLX 4-bit): every vision turn decoded its opening token and
+ // then repeated only that token, on photographs and synthetic cards
+ // alike, while the MLX text path answered normally with the same sampler
+ // settings. The web SDK forces Qwen2-VL off WebGPU for an f16 M-RoPE
// overflow; this is the same family failing the same way on Metal.
- // LFM2-VL through llama.cpp answers the same camera correctly (128 tokens
- // at 32 tok/s), so it leads instead. Qwen2-VL stays in the catalog —
- // pickable, just never the recommendation.
vlmIDs: [
- "lfm2-vl-450m-q8_0",
+ "lfm2.5-vl-3b-q4_k_m",
"smolvlm2-500m-video-instruct-q8_0",
"smolvlm2-256m-video-instruct-q8_0"
],
embeddingIDs: [
- "mlx-qwen3-embedding-0.6b-4bit-dwq",
"all-minilm-l6-v2"
]
)
- /// Full spread including a larger "genius" model.
+ /// Mac. Commons still gates each id on real device RAM through `can_run`,
+ /// so naming a 27B here is a preference, not a promise.
static let highEnd = Self(
llmIDs: [
- "mlx-llama-3.2-1b-instruct-4bit",
- "llama-3.2-3b-instruct-q4_k_m",
- "lfm2-1.2b-tool-q4_k_m",
- "qwen3-4b-q4_k_m",
- "mlx-qwen3-4b-4bit"
+ "mlx-lfm2.5-1.2b-instruct-4bit",
+ "granite-4.1-3b-q4_k_m",
+ "lfm2.5-1.2b-instruct-q4_k_m",
+ "qwen3.5-4b-q4_k_m",
+ "mlx-qwen3.5-4b-4bit"
],
asrIDs: [
- "mlx-qwen3-asr-0.6b-8bit",
"sherpa-onnx-whisper-tiny.en"
],
ttsIDs: [
- "mlx-soprano-1.1-80m-5bit",
"vits-piper-en_US-lessac-medium"
],
- // Same reason as `midRange`: MLX Qwen2-VL answers with one repeated
- // token. Qwen2.5-VL is a different generation on a different runtime
- // (llama.cpp) and leads here; the MLX Qwen3-VL stays as the second
- // choice rather than the default no one chose.
vlmIDs: [
- "qwen2.5-vl-3b-instruct-q4_k_m",
+ "lfm2.5-vl-3b-q4_k_m",
"mlx-qwen3-vl-4b-instruct-4bit"
],
embeddingIDs: [
- "mlx-qwen3-embedding-0.6b-4bit-dwq",
"all-minilm-l6-v2"
]
)
diff --git a/RunAnywhereAIUnitTests/HardwareTierTests.swift b/RunAnywhereAIUnitTests/HardwareTierTests.swift
index b6bff3bf..ba0452c6 100644
--- a/RunAnywhereAIUnitTests/HardwareTierTests.swift
+++ b/RunAnywhereAIUnitTests/HardwareTierTests.swift
@@ -38,7 +38,7 @@ final class HardwareTierTests: XCTestCase {
func testRecommendationDoesNotUseLocalByteBudget() {
var small = RAModelInfo()
- small.id = "mlx-lfm2-350m"
+ small.id = "mlx-lfm2.5-230m-4bit"
small.name = "LFM2 350M"
small.category = .language
small.downloadSizeBytes = 9_000_000_000
@@ -48,16 +48,16 @@ final class HardwareTierTests: XCTestCase {
tier: .unknown,
appleFoundationAvailable: false,
from: [small],
- canRunByModelID: ["mlx-lfm2-350m": true]
+ canRunByModelID: ["mlx-lfm2.5-230m-4bit": true]
)
let refused = engine.recommend(
tier: .unknown,
appleFoundationAvailable: false,
from: [small],
- canRunByModelID: ["mlx-lfm2-350m": false]
+ canRunByModelID: ["mlx-lfm2.5-230m-4bit": false]
)
- XCTAssertEqual(allowed.recommendedLLMs.map(\.id), ["mlx-lfm2-350m"])
+ XCTAssertEqual(allowed.recommendedLLMs.map(\.id), ["mlx-lfm2.5-230m-4bit"])
XCTAssertTrue(refused.recommendedLLMs.isEmpty)
}
}
diff --git a/RunAnywhereAIUnitTests/ModelOrgCatalogTests.swift b/RunAnywhereAIUnitTests/ModelOrgCatalogTests.swift
index 106f15b5..3613db92 100644
--- a/RunAnywhereAIUnitTests/ModelOrgCatalogTests.swift
+++ b/RunAnywhereAIUnitTests/ModelOrgCatalogTests.swift
@@ -52,7 +52,7 @@ final class ModelOrgCatalogTests: XCTestCase {
func testGroupsOrderFollowsOrgDeclaration() {
let models = [
makeModel(id: "all-minilm-l6-v2", name: "MiniLM"),
- makeModel(id: "qwen3-4b", name: "Qwen3 4B"),
+ makeModel(id: "qwen3.5-4b", name: "Qwen3.5 4B"),
makeModel(id: "nemotron-mini-4b", name: "Nemotron Mini 4B"),
]
diff --git a/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift b/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift
new file mode 100644
index 00000000..311be256
--- /dev/null
+++ b/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift
@@ -0,0 +1,161 @@
+//
+// ModelRecommendationEngineTests.swift
+// RunAnywhereAIUnitTests
+//
+// The engine picks by hardcoded id, and the catalog it picks from is edited in
+// a different PR by someone not reading that file. These lock the one property
+// that makes the coupling survivable: a curated list that has gone stale
+// degrades to a worse recommendation, never to no recommendation.
+//
+// Deliberately no assertion that any particular id is present. A test that
+// names ids is a second copy of the list, and it would fail on the catalog
+// change it is supposed to tolerate.
+//
+
+import XCTest
+@testable import RunAnywhereAI
+import RunAnywhere
+
+final class ModelRecommendationEngineTests: XCTestCase {
+
+ private let engine = ModelRecommendationEngine()
+
+ /// The 0.20.24 regression, as a test. Every curated id is absent, exactly as
+ /// it was after the catalog rebuild dropped the superseded families, and the
+ /// screen still has something to recommend.
+ func testRecommendsLanguageModelsWhenNoCuratedIDMatches() {
+ let catalog = [
+ makeModel(id: "totally-unrelated-3b", name: "Unrelated 3B", bytes: 2_000_000_000),
+ makeModel(id: "totally-unrelated-1b", name: "Unrelated 1B", bytes: 900_000_000),
+ makeModel(id: "totally-unrelated-8b", name: "Unrelated 8B", bytes: 5_000_000_000)
+ ]
+
+ let selection = engine.recommend(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog
+ )
+
+ XCTAssertEqual(selection.recommendedLLMs.count, 3)
+ XCTAssertNotNil(selection.defaultChatModel)
+ }
+
+ /// Back-fill opens with the cheapest option rather than the largest file in
+ /// the category, so a fallback stays runnable on the device that triggered it.
+ func testBackfillIsOrderedSmallestFirst() {
+ let catalog = [
+ makeModel(id: "big", name: "Big 8B", bytes: 5_000_000_000),
+ makeModel(id: "small", name: "Small 1B", bytes: 900_000_000),
+ makeModel(id: "medium", name: "Medium 3B", bytes: 2_000_000_000)
+ ]
+
+ let selection = engine.recommend(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog
+ )
+
+ XCTAssertEqual(selection.recommendedLLMs.map(\.id), ["small", "medium", "big"])
+ }
+
+ /// A model commons has ruled out is never recommended, even when nothing
+ /// else is left to offer. Absent verdicts stay permissive.
+ func testCanRunVerdictExcludesAndMissingVerdictAllows() {
+ let catalog = [
+ makeModel(id: "too-big", name: "Too Big 30B", bytes: 20_000_000_000),
+ makeModel(id: "fits", name: "Fits 1B", bytes: 900_000_000)
+ ]
+
+ let selection = engine.recommend(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog,
+ canRunByModelID: ["too-big": false]
+ )
+
+ XCTAssertEqual(selection.recommendedLLMs.map(\.id), ["fits"])
+ }
+
+ /// An empty catalog is the one case where recommending nothing is correct.
+ func testEmptyCatalogRecommendsNothing() {
+ let selection = engine.recommend(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: []
+ )
+
+ XCTAssertTrue(selection.recommendedLLMs.isEmpty)
+ XCTAssertNil(selection.defaultChatModel)
+ }
+
+ /// Companions fall back by category too, so a stale ASR id does not leave
+ /// the Voice screen with no speech model.
+ func testCompanionsFallBackByCategory() {
+ let catalog = [
+ makeModel(id: "some-llm", name: "Some 1B", bytes: 900_000_000),
+ makeModel(id: "unknown-asr", name: "Unknown ASR", category: .speechRecognition, bytes: 80_000_000),
+ makeModel(id: "unknown-tts", name: "Unknown TTS", category: .speechSynthesis, bytes: 60_000_000)
+ ]
+
+ let selection = engine.recommend(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog
+ )
+
+ XCTAssertEqual(selection.recommendedASR?.id, "unknown-asr")
+ XCTAssertEqual(selection.recommendedTTS?.id, "unknown-tts")
+ }
+
+ /// The voice pipeline shares the same picking, so it inherits the same floor.
+ func testVoicePipelineResolvesFromAnUnfamiliarCatalog() {
+ let catalog = [
+ makeModel(id: "some-llm", name: "Some 1B", bytes: 900_000_000),
+ makeModel(id: "unknown-asr", name: "Unknown ASR", category: .speechRecognition, bytes: 80_000_000),
+ makeModel(id: "unknown-tts", name: "Unknown TTS", category: .speechSynthesis, bytes: 60_000_000)
+ ]
+
+ let pipeline = engine.recommendVoicePipeline(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog
+ )
+
+ XCTAssertTrue(pipeline.isComplete)
+ }
+
+ /// The VAD path used to skip the `can_run` gate every other component
+ /// applied, so a model commons had ruled out could still reach the pipeline.
+ func testVoicePipelineRejectsAVadCommonsRuledOut() {
+ let catalog = [
+ makeModel(id: "some-llm", name: "Some 1B", bytes: 900_000_000),
+ makeModel(id: "unknown-asr", name: "Unknown ASR", category: .speechRecognition, bytes: 80_000_000),
+ makeModel(id: "unknown-tts", name: "Unknown TTS", category: .speechSynthesis, bytes: 60_000_000),
+ makeModel(id: "silero-vad", name: "Silero VAD", category: .voiceActivityDetection, bytes: 2_000_000)
+ ]
+
+ let pipeline = engine.recommendVoicePipeline(
+ tier: .unknown,
+ appleFoundationAvailable: false,
+ from: catalog,
+ canRunByModelID: ["silero-vad": false]
+ )
+
+ XCTAssertNil(pipeline.vad)
+ }
+
+ private func makeModel(
+ id: String,
+ name: String,
+ category: RAModelCategory = .language,
+ bytes: Int64
+ ) -> RAModelInfo {
+ var model = RAModelInfo()
+ model.id = id
+ model.name = name
+ model.category = category
+ model.framework = .llamaCpp
+ model.downloadSizeBytes = bytes
+ return model
+ }
+}
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 00000000..32f059e2
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,129 @@
+# Development reference
+
+Detail moved out of the root README so it stays a consumer-facing page. Everything here
+is about building, testing, and pinning the SDK, not about using the app.
+
+## Setup
+
+There is no monorepo checkout to build and no XCFramework to stage. SwiftPM downloads the
+checksum-verified native archives during resolve.
+
+```bash
+git clone https://github.com/RunanywhereAI/runanywhere-ios.git
+cd runanywhere-ios
+swift package resolve
+```
+
+`Package.swift` declares one dependency, and the Xcode project mirrors it:
+
+```swift
+.package(
+ url: "https://github.com/RunanywhereAI/runanywhere-swift.git",
+ from: "0.20.24"
+)
+```
+
+`runanywhere-swift` is a Swift-only SwiftPM distribution generated from the
+`runanywhere-sdks` monorepo. Consume it rather than the monorepo: it is a few MB instead
+of a few hundred, and it carries the generated proto sources that the monorepo no longer
+commits. Its tags are bare semver with no `v` prefix, which is what `from:` needs. The
+XCFramework binary targets still point at the checksum-verified release assets on
+`runanywhere-sdks`.
+
+The five products it publishes, all of which this app links:
+
+| Product | Role |
+|---|---|
+| `RunAnywhere` | Core SDK, always required |
+| `RunAnywhereLlamaCPP` | llama.cpp backend: LLM, VLM |
+| `RunAnywhereONNX` | Sherpa-ONNX backend: STT, TTS, VAD |
+| `RunAnywhereMLX` | Apple MLX backend, physical device or native macOS |
+| `RunAnywhereNeuRT` | Apple Neural Engine backend |
+
+Three files have to agree on the version: `Package.swift` (`from:`), the Xcode project's
+package reference (`upToNextMajorVersion` from the same minimum), and `Package.resolved`,
+which records the exact version and commit resolve selected. `Package.resolved` is
+committed and CI fails if a fresh resolve leaves it dirty.
+
+To take a newer SDK release within the same major, run `swift package update` and commit
+the refreshed `Package.resolved`. To require a newer minimum, bump the version in
+`Package.swift` and in the Xcode project's package reference, then resolve again. If
+resolution misbehaves, use File, Packages, Reset Package Caches first.
+
+## Build and run
+
+Open `RunAnywhereAI.xcodeproj` and press ⌘R, or:
+
+```bash
+./scripts/build_and_run_ios_sample.sh simulator "iPhone 16 Pro"
+./scripts/build_and_run_ios_sample.sh device
+./scripts/build_and_run_ios_sample.sh mac
+```
+
+`./scripts/verify.sh` resolves the package and runs a full simulator `xcodebuild`, which is
+the slow half of CI. `./scripts/smoke.sh` is the fast half: it greps the sources for SDK
+call patterns and checks the Parakeet CTC catalog entry, without compiling.
+
+Runtime logs:
+
+```bash
+log stream --predicate 'subsystem CONTAINS "com.runanywhere"' --info --debug
+```
+
+Most loggers use the `com.runanywhere.RunAnywhereAI` subsystem, a couple use plain
+`com.runanywhere`, and the SDK logs under its own, so match on the prefix.
+
+## Tests
+
+Unit tests live in `RunAnywhereAIUnitTests/` and build into the `RunAnywhereAITests`
+target; the XCUITest launch test lives in `RunAnywhereAIUITests/`. Both need a booted
+simulator:
+
+```bash
+xcodebuild test \
+ -project RunAnywhereAI.xcodeproj \
+ -scheme RunAnywhereAI \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
+ -only-testing:RunAnywhereAITests
+```
+
+Drop `-only-testing:` to run the UI test as well.
+
+## Continuous integration
+
+`.github/workflows/ci.yml` runs on pushes and pull requests against `main`. It checks out a
+clean clone on `macos-latest` (the macOS 26 arm64 image, the line carrying Xcode 26, which
+`swift-tools-version: 6.2` requires), then:
+
+1. resolves the SDK remotely, to prove no monorepo checkout is needed, and fails if the
+ resolve left `Package.resolved` dirty (i.e. the committed pin was stale);
+2. builds the `RunAnywhereAI` scheme for `generic/platform=iOS Simulator`, which pulls in
+ the keyboard and Live Activity extensions;
+3. runs `-only-testing:RunAnywhereAITests` on a booted simulator;
+4. runs `./scripts/smoke.sh`.
+
+Signing is off, since a simulator build needs no identity and hosted runners have no
+`DEVELOPMENT_TEAM`.
+
+
+## Layout
+
+`RunAnywhereAI/` holds the app: `App/` (entry point and platform shells), `Features/`,
+`Core/` (design system, services, models), and `Helpers/`. `RunAnywhereKeyboard/` and
+`RunAnywhereActivityExtension/` are the two extension targets. The app and the keyboard
+deploy to iOS 17.5; the Live Activity extension needs iOS 26.2, so on older systems it
+simply does not load.
+
+Architecture is MVVM with Swift Observation, one `RunAnywhere.*` entry point per modality,
+and centralized design tokens around brand orange `#FF6900`. `AGENTS.md` has the full
+reference.
+
+## Troubleshooting
+
+| Symptom | Fix |
+|---|---|
+| Missing XCFramework errors | Reset package caches and rerun `swift package resolve` so SwiftPM re-downloads the release archives |
+| Package resolution failures | Same: reset caches, resolve again |
+| Sandbox or derived-data issues | Clean the build folder (⇧⌘K), delete DerivedData if it persists |
+| MLX unavailable | Use a physical device or native macOS; MLX reports unavailable on the simulator |
+
diff --git a/docs/screenshots/02-model-picker.png b/docs/screenshots/02-model-picker.png
new file mode 100644
index 00000000..24296338
Binary files /dev/null and b/docs/screenshots/02-model-picker.png differ
diff --git a/docs/screenshots/03-ready.png b/docs/screenshots/03-ready.png
new file mode 100644
index 00000000..df3f72a3
Binary files /dev/null and b/docs/screenshots/03-ready.png differ
diff --git a/docs/screenshots/04-chat.png b/docs/screenshots/04-chat.png
new file mode 100644
index 00000000..72fd9bdb
Binary files /dev/null and b/docs/screenshots/04-chat.png differ
diff --git a/docs/screenshots/06-more.png b/docs/screenshots/06-more.png
new file mode 100644
index 00000000..579d3ae9
Binary files /dev/null and b/docs/screenshots/06-more.png differ
diff --git a/docs/screenshots/14-settings.png b/docs/screenshots/14-settings.png
new file mode 100644
index 00000000..6a8e72b0
Binary files /dev/null and b/docs/screenshots/14-settings.png differ
diff --git a/docs/screenshots/19-segmentation.png b/docs/screenshots/19-segmentation.png
new file mode 100644
index 00000000..e8ad0048
Binary files /dev/null and b/docs/screenshots/19-segmentation.png differ