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

RunAnywhere @@ -8,183 +8,156 @@ Download on the App Store + + Download for macOS +

-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. +

+ iOS 17.5+ + macOS 14.5+ + Swift 6.2 + RunAnywhere License +

-## 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 | +| ![Chat with a model loaded](docs/screenshots/03-ready.png) | ![A streamed answer](docs/screenshots/04-chat.png) | +| 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. | +| ![Choosing a model](docs/screenshots/02-model-picker.png) | ![The Advanced hub](docs/screenshots/06-more.png) | +| 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](docs/screenshots/19-segmentation.png) | ![Settings](docs/screenshots/14-settings.png) | +| 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