diff --git a/Packages/OsaurusCore/ComputerUse/Diagnostics/ComputerUseGateInspector.swift b/Packages/OsaurusCore/ComputerUse/Diagnostics/ComputerUseGateInspector.swift index 8f06e3c84..d673a100e 100644 --- a/Packages/OsaurusCore/ComputerUse/Diagnostics/ComputerUseGateInspector.swift +++ b/Packages/OsaurusCore/ComputerUse/Diagnostics/ComputerUseGateInspector.swift @@ -134,7 +134,8 @@ enum ComputerUseGateInspector { let decision: GateDecision let decisionText: String if gateIsReached { - finalDisposition = allowlist.isAllowed + finalDisposition = + allowlist.isAllowed ? (dangerousConfirm ? AutonomyDisposition.strictest(policyDisposition, .confirm) : policyDisposition) diff --git a/Packages/OsaurusCore/Managers/Model/ModelManager.swift b/Packages/OsaurusCore/Managers/Model/ModelManager.swift index 2ffdb9269..1f0512ed2 100644 --- a/Packages/OsaurusCore/Managers/Model/ModelManager.swift +++ b/Packages/OsaurusCore/Managers/Model/ModelManager.swift @@ -755,22 +755,6 @@ extension ModelManager { useCase: .vision ), - // DiffusionGemma — block-diffusion (not autoregressive) multimodal - // MoE. `model_type=diffusion_gemma` routes to the vmlx-swift - // block-diffusion engine. Shipping as a Top Pick is gated on a real - // Osaurus load + decode smoke test (visible coherent output, token/s, - // physical footprint within the MXFP8 gate); downgrade to a non-Top - // `preview` entry if that proof can't be produced. - curated( - id: "OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8", - description: - "DiffusionGemma 26B-A4B — block-diffusion multimodal MoE (~4B active), MXFP8. Images + tools + reasoning, high-precision. 128K context.", - isTopSuggestion: true, - modelType: "diffusion_gemma", - releasedAt: date("2026-06-13"), - useCase: .vision - ), - // Lower-precision Gemma 4 edge fallbacks (NOT defaults). Within the // E-series, 8-bit retains far better than 4-bit (E4B: 17% vs 33% // bounce; E2B: median 19 vs 2 messages). The 4-bit builds stay listed @@ -1235,6 +1219,7 @@ extension ModelManager { "osaurusai/gemma-4-26b-a4b-it-jang_2l", "osaurusai/gemma-4-26b-a4b-it-jang_4m", "osaurusai/gemma-4-26b-a4b-it-mxfp4", + "osaurusai/diffusiongemma-26b-a4b-it-mxfp8", ] } diff --git a/Packages/OsaurusCore/Models/Configuration/MLXModel.swift b/Packages/OsaurusCore/Models/Configuration/MLXModel.swift index 1ab051603..b2426d045 100644 --- a/Packages/OsaurusCore/Models/Configuration/MLXModel.swift +++ b/Packages/OsaurusCore/Models/Configuration/MLXModel.swift @@ -130,6 +130,34 @@ struct MLXModel: Identifiable, Codable { ) } + /// A jargon-light version of `name` for first-run surfaces (the onboarding + /// model chooser). The HF-derived `name` carries technical tokens — + /// instruction-tuned (`it`), quant/precision (`MXFP8`, `MXFP4`, `qat`, + /// `4bit`, `bf16`, `JANGTQ`, `JANG_4M`), MoE active-params (`A1B`/`A4B`), + /// and speculative-decode (`MTP`) — which make the title read like a + /// filename. Stripping them yields a product-style title ("Gemma 4 12B"). + /// The dropped precision is re-surfaced as a separate chip in the chooser so + /// same-size variants stay distinguishable. Falls back to `name` when + /// stripping would leave nothing. + var simplifiedName: String { + func isJargon(_ token: String) -> Bool { + let t = token.lowercased() + if t == "it" || t == "qat" || t == "mtp" { return true } + // Precision / quantization tokens. + if t.range(of: #"^mxfp\d+$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^\d+-?bit$"#, options: .regularExpression) != nil { return true } + if t == "fp16" || t == "bf16" || t == "fp32" { return true } + if t.range(of: #"^jangtq\d*$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^jang_?\d+[a-z]?$"#, options: .regularExpression) != nil { return true } + // MoE active-parameter token (e.g. "A1B", "A4B", "A3B"). + if t.range(of: #"^a\d+b$"#, options: .regularExpression) != nil { return true } + return false + } + let kept = name.split(separator: " ").map(String.init).filter { !isJargon($0) } + let result = kept.joined(separator: " ").trimmingCharacters(in: .whitespaces) + return result.isEmpty ? name : result + } + /// Formatted download size string (e.g., "3.9 GB"). /// /// Uses the value-type `ByteCountFormatStyle` rather than allocating a diff --git a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterEngine.swift b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterEngine.swift index 3ab489cfd..63504266b 100644 --- a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterEngine.swift +++ b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterEngine.swift @@ -246,9 +246,8 @@ public final class PrivacyFilterEngine { // code-block mask to a `nil` (entirely masked) and collect // the survivors. We do this BEFORE interning so we don't pay // for placeholders we're about to throw away. - var surviving: - [(category: EntityCategory, original: String, range: Range, label: String?)] = - [] + var surviving: [(category: EntityCategory, original: String, range: Range, label: String?)] = + [] surviving.reserveCapacity(resolved.count) for match in resolved { guard let restored = restore(match.range) else { continue } diff --git a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterPipeline.swift b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterPipeline.swift index f52fe6514..24bc49861 100644 --- a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterPipeline.swift +++ b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyFilterPipeline.swift @@ -335,9 +335,13 @@ enum PrivacyFilterPipeline { print("[PrivacyFilter] BLOCKING send: \(detail).") throw PrivacyFilterPipelineError.engineUnavailable(detail) } - print("[PrivacyFilter] Outbound: filter ENABLED (AI + regex) for provider \(providerId.uuidString); running detection.") + print( + "[PrivacyFilter] Outbound: filter ENABLED (AI + regex) for provider \(providerId.uuidString); running detection." + ) } else { - print("[PrivacyFilter] Outbound: filter ENABLED (regex-only, AI detection off) for provider \(providerId.uuidString); running detection.") + print( + "[PrivacyFilter] Outbound: filter ENABLED (regex-only, AI detection off) for provider \(providerId.uuidString); running detection." + ) } // Build the effective regex rule set ONCE per pipeline call. diff --git a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyRule.swift b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyRule.swift index c6fdc5a07..8ab68f697 100644 --- a/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyRule.swift +++ b/Packages/OsaurusCore/PrivacyFilter/Core/PrivacyRule.swift @@ -232,7 +232,8 @@ public struct RuleBuilder: Codable, Hashable, Sendable { case .exactWord, .anyOfTerms, .startsWith, .endsWith, .contains: let cleaned = cleanedTerms guard !cleaned.isEmpty else { return nil } - let alt = cleaned + let alt = + cleaned .map { NSRegularExpression.escapedPattern(for: $0) } .joined(separator: "|") let group = "(?:\(alt))" diff --git a/Packages/OsaurusCore/Resources/Localizable.xcstrings b/Packages/OsaurusCore/Resources/Localizable.xcstrings index 06655cb9a..242ba6e9c 100644 --- a/Packages/OsaurusCore/Resources/Localizable.xcstrings +++ b/Packages/OsaurusCore/Resources/Localizable.xcstrings @@ -1344,6 +1344,10 @@ }, "shouldTranslate" : false }, + "%lld actions" : { + "comment" : "A label displaying the number of actions supported by a given communication channel. The argument is the count of actions supported by the communication channel.", + "isCommentAutoGenerated" : true + }, "%lld blocking finding(s) must be fixed before provisioning." : { "localizations" : { "de" : { @@ -4979,6 +4983,9 @@ } } }, + "Agent availability" : { + "shouldTranslate" : false + }, "Agent Avatar" : { "localizations" : { "de" : { @@ -5012,6 +5019,10 @@ } } }, + "Agent Channels" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "Agent DB" : { "localizations" : { "de" : { @@ -5186,22 +5197,6 @@ } } }, - "Channels" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kanäle" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "频道" - } - } - } - }, "Agents execute commands safely" : { "localizations" : { "de" : { @@ -5906,6 +5901,12 @@ } } }, + "Allowlist" : { + "shouldTranslate" : false + }, + "allowlist blocks before disposition" : { + "shouldTranslate" : false + }, "Allows capturing screenshots and system audio when you approve those features." : { "localizations" : { "de" : { @@ -6188,6 +6189,22 @@ } } }, + "Already pay for AI? Use your own key" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zahlen Sie bereits für KI? Verwenden Sie Ihren eigenen Schlüssel" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已经为 AI 付费?使用您自己的密钥" + } + } + } + }, "Also selected: %@ — referenced by %@." : { "localizations" : { "de" : { @@ -8240,6 +8257,9 @@ } } }, + "Autonomy Gate Inspector" : { + "shouldTranslate" : false + }, "Available" : { "comment" : "A label displayed above the list of available plugins.", "isCommentAutoGenerated" : true, @@ -8389,6 +8409,9 @@ } } }, + "AXButton" : { + "shouldTranslate" : false + }, "Azure deployment IDs are configured, so connect can proceed even when /models is unavailable." : { "localizations" : { "de" : { @@ -9053,6 +9076,22 @@ "comment" : "A label for the section where a start and end marker can be specified.", "isCommentAutoGenerated" : true }, + "Bigger models are smarter but use more memory." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Größere Modelle sind intelligenter, benötigen aber mehr Arbeitsspeicher." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更大的模型更智能,但占用更多内存。" + } + } + } + }, "Binding host socket" : { "localizations" : { "de" : { @@ -10979,6 +11018,28 @@ } } }, + "ceiling %@ -> %@" : { + "shouldTranslate" : false + }, + "Ceiling contribution" : { + "shouldTranslate" : false + }, + "Change" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ändern" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改" + } + } + } + }, "Change Folder" : { "localizations" : { "de" : { @@ -11011,6 +11072,22 @@ } } }, + "Change model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Change model" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Change model" + } + } + } + }, "Change working folder" : { "localizations" : { "de" : { @@ -11083,6 +11160,22 @@ } } }, + "Channels" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanäle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "频道" + } + } + } + }, "Chart preview not yet rendered — showing rows instead." : { "localizations" : { "de" : { @@ -11731,6 +11824,22 @@ } } }, + "Choose a model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modell auswählen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择模型" + } + } + } + }, "Choose a passphrase (≥ 8 characters) to encrypt this agent's bundle. You'll need the same passphrase to import it on another Mac." : { "localizations" : { "de" : { @@ -11795,6 +11904,22 @@ } } }, + "Choose another model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anderes Modell auswählen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择其他模型" + } + } + } + }, "Choose backup destination" : { "localizations" : { "de" : { @@ -11954,6 +12079,22 @@ } } }, + "Choose your model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wählen Sie Ihr Modell" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择您的模型" + } + } + } + }, "Choose your OpenAI access" : { "extractionState" : "stale", "localizations" : { @@ -12377,6 +12518,9 @@ } } }, + "Click" : { + "shouldTranslate" : false + }, "Click 'Send Request' to test" : { "localizations" : { "de" : { @@ -12687,6 +12831,9 @@ } } }, + "cmd, shift" : { + "shouldTranslate" : false + }, "code" : { "localizations" : { "de" : { @@ -13414,6 +13561,10 @@ } } }, + "Config" : { + "comment" : "A label describing the configuration file.", + "isCommentAutoGenerated" : true + }, "Config needed" : { "localizations" : { "de" : { @@ -13587,6 +13738,10 @@ } } }, + "Configure communication channels agents can inspect or write to through one standard action surface." : { + "comment" : "A description of the purpose of the view.", + "isCommentAutoGenerated" : true + }, "Configure how chat mode generates responses." : { "localizations" : { "de" : { @@ -16770,6 +16925,10 @@ } } }, + "Custom HTTP" : { + "comment" : "Options for selecting the kind of an agent communication channel.", + "isCommentAutoGenerated" : true + }, "Custom instructions for this plugin..." : { "localizations" : { "de" : { @@ -17090,6 +17249,12 @@ } } }, + "Dangerous-app confirm" : { + "shouldTranslate" : false + }, + "dangerous-app floor -> Ask first" : { + "shouldTranslate" : false + }, "Dark" : { "localizations" : { "de" : { @@ -18549,6 +18714,10 @@ } } }, + "Diagnosing..." : { + "comment" : "A label that appears while a diagnostics check is in progress.", + "isCommentAutoGenerated" : true + }, "Diagnostic / Custom" : { "localizations" : { "de" : { @@ -19246,6 +19415,9 @@ } } }, + "Disposition" : { + "shouldTranslate" : false + }, "Distill pending" : { "localizations" : { "de" : { @@ -19491,6 +19663,9 @@ } } }, + "Double-click" : { + "shouldTranslate" : false + }, "Download" : { "localizations" : { "de" : { @@ -19880,6 +20055,9 @@ } } }, + "Drag" : { + "shouldTranslate" : false + }, "Drag to set the order shown across the app." : { "localizations" : { "de" : { @@ -20478,6 +20656,10 @@ } } }, + "Edit Connection" : { + "comment" : "A button label that, when tapped, will open a form to edit an existing agent channel connection.", + "isCommentAutoGenerated" : true + }, "Edit file content with search/replace" : { "extractionState" : "manual", "localizations" : { @@ -20907,6 +21089,41 @@ } } }, + "Effect class" : { + "shouldTranslate" : false + }, + "Efficient" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effizient" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高效" + } + } + } + }, + "Efficient (QAT)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effizient (QAT)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高效(QAT)" + } + } + } + }, "Either way, full-text search and all features work the same; encryption only changes how bytes sit on disk." : { "extractionState" : "manual", "localizations" : { @@ -22384,6 +22601,22 @@ } } }, + "Every model here runs privately on your Mac. Not sure? Start with the recommended one — you can switch anytime." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jedes Modell hier läuft privat auf Ihrem Mac. Unsicher? Beginnen Sie mit dem empfohlenen – Sie können jederzeit wechseln." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这里的每个模型都在您的 Mac 上私密运行。不确定?先从推荐的开始——您可以随时切换。" + } + } + } + }, "Everything in the table, including soft-deleted rows." : { "localizations" : { "de" : { @@ -24268,6 +24501,9 @@ } } }, + "Find" : { + "shouldTranslate" : false + }, "Find places nearby" : { "localizations" : { "de" : { @@ -24505,6 +24741,12 @@ } } }, + "For press_key" : { + "shouldTranslate" : false + }, + "For type/set" : { + "shouldTranslate" : false + }, "Forget" : { "comment" : "Title for an action that forgets a memory.", "isCommentAutoGenerated" : true, @@ -24640,6 +24882,22 @@ } } }, + "Free, private, and works offline. Uses some memory while it runs." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kostenlos, privat und offline nutzbar. Benötigt während der Ausführung etwas Arbeitsspeicher." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "免费、私密且可离线运行。运行时会占用一些内存。" + } + } + } + }, "Frequency" : { "localizations" : { "de" : { @@ -24754,6 +25012,22 @@ } } }, + "Frontier models like Claude, ChatGPT, and more. No setup or API key. Pay as you go." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spitzenmodelle wie Claude, ChatGPT und mehr. Keine Einrichtung, kein API-Schlüssel. Zahlung nach Verbrauch." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claude、ChatGPT 等前沿模型。无需设置或 API 密钥。按用量付费。" + } + } + } + }, "Full" : { "localizations" : { "de" : { @@ -24818,6 +25092,22 @@ } } }, + "Full precision" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volle Präzision" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完整精度" + } + } + } + }, "Fuses the per-token decode graph with MLX compile (+25% measured on Gemma 4 QAT). Takes effect after restarting Osaurus — MLX fixes its compile state at the first model load of the process, so toggling this mid-session cannot turn it on/off live (the setting is saved and applies on next launch). Experimental: kept off by default until the historical model-switch corruption (PR #1173) is root-caused. If model switching misbehaves with this on, turn it off and restart." : { "extractionState" : "manual", "localizations" : { @@ -24851,6 +25141,9 @@ } } }, + "Gate decision" : { + "shouldTranslate" : false + }, "Gave up: %@" : { "localizations" : { "de" : { @@ -25521,6 +25814,9 @@ } } }, + "Give up" : { + "shouldTranslate" : false + }, "Give your dino a brain" : { "localizations" : { "de" : { @@ -25569,6 +25865,9 @@ } } }, + "Global %@ -> %@" : { + "shouldTranslate" : false + }, "Global Hotkey" : { "localizations" : { "de" : { @@ -26442,6 +26741,7 @@ }, "Hide other options" : { "comment" : "Collapses the extra local models in the \"Configure AI\" onboarding step.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26545,6 +26845,22 @@ } } }, + "High precision" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hohe Präzision" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高精度" + } + } + } + }, "High-water active" : { "localizations" : { "de" : { @@ -29926,6 +30242,10 @@ } } }, + "Kind" : { + "comment" : "A label describing the type of communication channel.", + "isCommentAutoGenerated" : true + }, "KV" : { "shouldTranslate" : false }, @@ -31510,6 +31830,7 @@ } }, "Local brains live on your Mac. They use a chunk of memory while running, and they keep working offline." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34347,6 +34668,9 @@ } } }, + "Modifiers" : { + "shouldTranslate" : false + }, "Modify your API connection" : { "localizations" : { "de" : { @@ -35214,6 +35538,14 @@ } } }, + "New Connection" : { + "comment" : "A label for a button that creates a new agent channel connection.", + "isCommentAutoGenerated" : true + }, + "New Custom" : { + "comment" : "A button label that, when tapped, will present a form to create a new custom agent communication channel.", + "isCommentAutoGenerated" : true + }, "New data will be encrypted with SQLCipher." : { "localizations" : { "de" : { @@ -35588,6 +35920,9 @@ } } }, + "No app allowlist is active." : { + "shouldTranslate" : false + }, "No artifacts installed for this plugin." : { "localizations" : { "de" : { @@ -36139,6 +36474,9 @@ } } }, + "No forced-confirm app match for this preview." : { + "shouldTranslate" : false + }, "No identity set up" : { "localizations" : { "de" : { @@ -36267,6 +36605,10 @@ } } }, + "No JSON-backed channels yet. Add a custom HTTP, Slack, or Telegram connection definition to prepare agent communication without storing secrets in the file." : { + "comment" : "A message displayed when a user has not yet added any custom agent communication channel definitions.", + "isCommentAutoGenerated" : true + }, "No keys are stored. Localhost still works without a real token while network exposure is off." : { "localizations" : { "de" : { @@ -36449,6 +36791,9 @@ } } }, + "No matching per-app override for the preview app." : { + "shouldTranslate" : false + }, "No MCP providers imported; %lld declared." : { "localizations" : { "de" : { @@ -36842,6 +37187,9 @@ } } }, + "No preview ceiling is applied." : { + "shouldTranslate" : false + }, "No processing log entries yet. If you've been chatting, the distill pipeline never reached the model." : { "localizations" : { "de" : { @@ -37898,6 +38246,9 @@ } } }, + "Not reached" : { + "shouldTranslate" : false + }, "Not registered" : { "localizations" : { "de" : { @@ -38028,6 +38379,9 @@ } } }, + "Note" : { + "shouldTranslate" : false + }, "Note (optional)" : { "localizations" : { "de" : { @@ -38316,6 +38670,9 @@ } } }, + "Observe" : { + "shouldTranslate" : false + }, "Off" : { "localizations" : { "de" : { @@ -38969,6 +39326,9 @@ } } }, + "Open app" : { + "shouldTranslate" : false + }, "Open Bundle" : { "localizations" : { "de" : { @@ -39687,6 +40047,9 @@ } } }, + "Optional rationale or surrounding context" : { + "shouldTranslate" : false + }, "Optional, e.g. /Users/me/projects/my-mcp" : { "extractionState" : "manual", "localizations" : { @@ -39804,6 +40167,7 @@ } }, "Or run your own brain" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -41738,6 +42102,12 @@ } } }, + "per-app %@ -> %@" : { + "shouldTranslate" : false + }, + "Per-app contribution" : { + "shouldTranslate" : false + }, "Per-app rules" : { "comment" : "Title of a section in the Computer Use settings view that lists per-app safety rules.", "isCommentAutoGenerated" : true, @@ -41914,6 +42284,12 @@ } } }, + "Permission Doctor" : { + "shouldTranslate" : false + }, + "Permission Doctor and autonomy gate inspector." : { + "shouldTranslate" : false + }, "permission policy is deny" : { "localizations" : { "de" : { @@ -42375,6 +42751,7 @@ } }, "Pick the brain that powers your dino — switch any time." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -43291,6 +43668,7 @@ } }, "Powered by Venice" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -43306,6 +43684,22 @@ } } }, + "Powered by Venice — zero data retention. Privacy first." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereitgestellt von Venice – keine Datenspeicherung. Datenschutz zuerst." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 Venice 提供支持——零数据保留。隐私优先。" + } + } + } + }, "Pre-flight picks the most relevant per turn." : { "extractionState" : "stale", "localizations" : { @@ -43442,6 +43836,9 @@ } } }, + "Preparing inspection..." : { + "shouldTranslate" : false + }, "Preparing…" : { "localizations" : { "de" : { @@ -43482,6 +43879,9 @@ } } }, + "Press key" : { + "shouldTranslate" : false + }, "Press this shortcut to start/stop transcription" : { "localizations" : { "de" : { @@ -43514,6 +43914,9 @@ } } }, + "Preview a proposed app action against the current policy without running it." : { + "shouldTranslate" : false + }, "Preview Agent" : { "comment" : "A label for selecting the agent used for previewing bounded memory contexts.", "isCommentAutoGenerated" : true, @@ -43532,6 +43935,9 @@ } } }, + "Preview app: %@. Allowed apps: %@." : { + "shouldTranslate" : false + }, "Preview bounded memory context" : { "comment" : "A help text describing the functionality of the preview bounded memory context button.", "isCommentAutoGenerated" : true, @@ -43602,6 +44008,9 @@ } } }, + "Preview ceiling" : { + "shouldTranslate" : false + }, "Preview context" : { "comment" : "A label describing the context that is currently being previewed.", "isCommentAutoGenerated" : true, @@ -47211,6 +47620,9 @@ } } }, + "Read-only snapshot of the cached permission and consent state Computer Use already uses." : { + "shouldTranslate" : false + }, "Reading a file" : { "localizations" : { "de" : { @@ -48431,6 +48843,10 @@ } } }, + "Reload" : { + "comment" : "A button label that reloads the list of available connections.", + "isCommentAutoGenerated" : true + }, "Reload tools" : { "extractionState" : "manual", "localizations" : { @@ -50820,6 +51236,10 @@ } } }, + "Reveal" : { + "comment" : "A button label that reveals the path to the agent-channels.json configuration file.", + "isCommentAutoGenerated" : true + }, "Reveal in Finder" : { "localizations" : { "de" : { @@ -51050,6 +51470,9 @@ } } }, + "Right-click" : { + "shouldTranslate" : false + }, "Risk-Aware Actions" : { "localizations" : { "de" : { @@ -51083,6 +51506,9 @@ } } }, + "Role description" : { + "shouldTranslate" : false + }, "Rollback" : { "comment" : "Button label for rolling back to the default theme.", "isCommentAutoGenerated" : true, @@ -51608,6 +52034,22 @@ } } }, + "Run on your Mac" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf Ihrem Mac ausführen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在您的 Mac 上运行" + } + } + } + }, "Run Osaurus sandbox provisioning on macOS 26 or later." : { "localizations" : { "de" : { @@ -52687,6 +53129,10 @@ } } }, + "Save Connection" : { + "comment" : "A button label that saves a configuration.", + "isCommentAutoGenerated" : true + }, "Save Discord Settings" : { "comment" : "A button label that saves the user's Discord settings.", "isCommentAutoGenerated" : true, @@ -53494,6 +53940,9 @@ } } }, + "Scroll" : { + "shouldTranslate" : false + }, "Scrub PII before sending to cloud providers" : { "comment" : "Title of the master Privacy Filter toggle. Emphasises that the filter only acts on cloud-bound (remote provider) requests; local MLX / Foundation model paths bypass it.", "localizations" : { @@ -54310,6 +54759,7 @@ }, "See other options" : { "comment" : "Localized help text for the button that allows users to see more options for configuring a local model.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -54328,6 +54778,7 @@ }, "See other options (%lld)" : { "comment" : "Expands the extra local models in the \"Configure AI\" onboarding step.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -55356,6 +55807,9 @@ } } }, + "Set value" : { + "shouldTranslate" : false + }, "Setting up sandbox" : { "comment" : "The title of the view that appears when the user is setting up their Mac sandbox.", "isCommentAutoGenerated" : true, @@ -57106,6 +57560,14 @@ } } }, + "Slack" : { + "comment" : "The following text is used as a picker option.", + "isCommentAutoGenerated" : true + }, + "Slack and Telegram definitions can be prepared now; native execution lands in their adapter PRs." : { + "comment" : "A note explaining that the Slack and Telegram channel kinds have placeholder definitions that will be replaced with actual ones once those features are implemented.", + "isCommentAutoGenerated" : true + }, "Slash commands" : { "localizations" : { "de" : { @@ -57956,6 +58418,10 @@ } } }, + "Standard Actions" : { + "comment" : "A heading for a section of the agent channel connection center view that lists standard actions.", + "isCommentAutoGenerated" : true + }, "Standard local@domain.tld addresses." : { "comment" : "Description of the \"Email addresses\" built-in detection pattern.", "isCommentAutoGenerated" : true, @@ -59777,6 +60243,15 @@ } } }, + "Target label" : { + "shouldTranslate" : false + }, + "Target role" : { + "shouldTranslate" : false + }, + "Target value" : { + "shouldTranslate" : false + }, "Task" : { "localizations" : { "de" : { @@ -59841,6 +60316,10 @@ } } }, + "Telegram" : { + "comment" : "An option in the picker for Telegram.", + "isCommentAutoGenerated" : true + }, "Temperature" : { "localizations" : { "de" : { @@ -60908,6 +61387,9 @@ } } }, + "The preview app matches the forced-confirm guardrail." : { + "shouldTranslate" : false + }, "The Privacy Filter scrubs this before it reaches a cloud model." : { "comment" : "Privacy note shown under the screen-context preview when the Privacy Filter is on.", "localizations" : { @@ -62121,6 +62603,12 @@ } } }, + "This preview verb is handled before app allowlist gating." : { + "shouldTranslate" : false + }, + "This preview verb is handled before autonomy gating." : { + "shouldTranslate" : false + }, "This remote agent is no longer available." : { "localizations" : { "de" : { @@ -63829,6 +64317,22 @@ } } }, + "Try again" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" + } + } + } + }, "Try Again" : { "localizations" : { "de" : { @@ -63845,6 +64349,22 @@ } } }, + "TurboQuant" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "TurboQuant" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "TurboQuant" + } + } + } + }, "TurboQuant compressions" : { "localizations" : { "de" : { @@ -64081,6 +64601,9 @@ } } }, + "Typed text" : { + "shouldTranslate" : false + }, "Typing and changing values" : { "extractionState" : "manual", "localizations" : { @@ -64240,6 +64763,9 @@ } } }, + "unknown app" : { + "shouldTranslate" : false + }, "Unknown error" : { "localizations" : { "de" : { @@ -65423,6 +65949,22 @@ } } }, + "Use this model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieses Modell verwenden" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用此模型" + } + } + } + }, "Used when a request doesn't specify these. Leave blank to honor the model's own defaults." : { "extractionState" : "manual", "localizations" : { @@ -65856,6 +66398,12 @@ } } }, + "Verb" : { + "shouldTranslate" : false + }, + "Verb baseline: %@" : { + "shouldTranslate" : false + }, "Verbosity of the server-side request log." : { "extractionState" : "manual", "localizations" : { @@ -66573,6 +67121,9 @@ } } }, + "Wait" : { + "shouldTranslate" : false + }, "Wait until the first request before loading any model." : { "localizations" : { "de" : { @@ -66735,6 +67286,22 @@ } } }, + "Want more power?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr Leistung gewünscht?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要更强性能?" + } + } + } + }, "Warm restart rootfs" : { "extractionState" : "manual", "localizations" : { @@ -67889,6 +68456,10 @@ } } }, + "writes on" : { + "comment" : "A text indicating that a communication channel supports writing.", + "isCommentAutoGenerated" : true + }, "Writing a file" : { "localizations" : { "de" : { @@ -68642,6 +69213,22 @@ } } }, + "Your dino runs on your Mac. Add more power whenever you want." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihr Dino läuft auf Ihrem Mac. Fügen Sie jederzeit mehr Leistung hinzu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的恐龙在您的 Mac 上运行。随时可以增添更多性能。" + } + } + } + }, "Your identity is active" : { "localizations" : { "de" : { @@ -68724,6 +69311,7 @@ } }, "Your own key" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -68840,175 +69428,7 @@ } } } - }, - "AXButton" : { - "shouldTranslate" : false - }, - "Agent availability" : { - "shouldTranslate" : false - }, - "Allowlist" : { - "shouldTranslate" : false - }, - "Autonomy Gate Inspector" : { - "shouldTranslate" : false - }, - "Ceiling contribution" : { - "shouldTranslate" : false - }, - "Click" : { - "shouldTranslate" : false - }, - "Dangerous-app confirm" : { - "shouldTranslate" : false - }, - "Disposition" : { - "shouldTranslate" : false - }, - "Double-click" : { - "shouldTranslate" : false - }, - "Drag" : { - "shouldTranslate" : false - }, - "Effect class" : { - "shouldTranslate" : false - }, - "Find" : { - "shouldTranslate" : false - }, - "For press_key" : { - "shouldTranslate" : false - }, - "For type/set" : { - "shouldTranslate" : false - }, - "Gate decision" : { - "shouldTranslate" : false - }, - "Give up" : { - "shouldTranslate" : false - }, - "Global %@ -> %@" : { - "shouldTranslate" : false - }, - "Modifiers" : { - "shouldTranslate" : false - }, - "No app allowlist is active." : { - "shouldTranslate" : false - }, - "No forced-confirm app match for this preview." : { - "shouldTranslate" : false - }, - "No matching per-app override for the preview app." : { - "shouldTranslate" : false - }, - "No preview ceiling is applied." : { - "shouldTranslate" : false - }, - "Not reached" : { - "shouldTranslate" : false - }, - "Note" : { - "shouldTranslate" : false - }, - "Observe" : { - "shouldTranslate" : false - }, - "Open app" : { - "shouldTranslate" : false - }, - "Optional rationale or surrounding context" : { - "shouldTranslate" : false - }, - "Per-app contribution" : { - "shouldTranslate" : false - }, - "Permission Doctor" : { - "shouldTranslate" : false - }, - "Permission Doctor and autonomy gate inspector." : { - "shouldTranslate" : false - }, - "Preparing inspection..." : { - "shouldTranslate" : false - }, - "Press key" : { - "shouldTranslate" : false - }, - "Preview a proposed app action against the current policy without running it." : { - "shouldTranslate" : false - }, - "Preview app: %@. Allowed apps: %@." : { - "shouldTranslate" : false - }, - "Preview ceiling" : { - "shouldTranslate" : false - }, - "Read-only snapshot of the cached permission and consent state Computer Use already uses." : { - "shouldTranslate" : false - }, - "Right-click" : { - "shouldTranslate" : false - }, - "Role description" : { - "shouldTranslate" : false - }, - "Scroll" : { - "shouldTranslate" : false - }, - "Set value" : { - "shouldTranslate" : false - }, - "Target label" : { - "shouldTranslate" : false - }, - "Target role" : { - "shouldTranslate" : false - }, - "Target value" : { - "shouldTranslate" : false - }, - "The preview app matches the forced-confirm guardrail." : { - "shouldTranslate" : false - }, - "This preview verb is handled before app allowlist gating." : { - "shouldTranslate" : false - }, - "This preview verb is handled before autonomy gating." : { - "shouldTranslate" : false - }, - "Typed text" : { - "shouldTranslate" : false - }, - "Verb" : { - "shouldTranslate" : false - }, - "Verb baseline: %@" : { - "shouldTranslate" : false - }, - "Wait" : { - "shouldTranslate" : false - }, - "allowlist blocks before disposition" : { - "shouldTranslate" : false - }, - "ceiling %@ -> %@" : { - "shouldTranslate" : false - }, - "cmd, shift" : { - "shouldTranslate" : false - }, - "dangerous-app floor -> Ask first" : { - "shouldTranslate" : false - }, - "per-app %@ -> %@" : { - "shouldTranslate" : false - }, - "unknown app" : { - "shouldTranslate" : false } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Packages/OsaurusCore/Services/AgentChannel/AgentChannelConnectionManager.swift b/Packages/OsaurusCore/Services/AgentChannel/AgentChannelConnectionManager.swift index 518ae105e..8fa745fcd 100644 --- a/Packages/OsaurusCore/Services/AgentChannel/AgentChannelConnectionManager.swift +++ b/Packages/OsaurusCore/Services/AgentChannel/AgentChannelConnectionManager.swift @@ -86,21 +86,25 @@ final class AgentChannelConnectionManager: @unchecked Sendable { replacingOriginalId originalId: String? = nil ) throws { let validated = try validatedConnection(connection) - let normalizedOriginalId = originalId + let normalizedOriginalId = + originalId .map(AgentChannelConnection.normalizedId) .flatMap { $0.isEmpty ? nil : $0 } if let normalizedOriginalId, - Self.reservedConnectionIds.contains(normalizedOriginalId.lowercased()) { + Self.reservedConnectionIds.contains(normalizedOriginalId.lowercased()) + { throw AgentChannelConnectionManagerError.reservedConnectionId(normalizedOriginalId) } var configuration = loadConfiguration() if let normalizedOriginalId, - normalizedOriginalId != validated.id, - configuration.connections.contains(where: { $0.id == validated.id }) { + normalizedOriginalId != validated.id, + configuration.connections.contains(where: { $0.id == validated.id }) + { throw AgentChannelConnectionManagerError.duplicateConnectionId(validated.id) } if normalizedOriginalId == nil, - configuration.connections.contains(where: { $0.id == validated.id }) { + configuration.connections.contains(where: { $0.id == validated.id }) + { throw AgentChannelConnectionManagerError.duplicateConnectionId(validated.id) } configuration.connections.removeAll { existing in @@ -189,10 +193,10 @@ final class AgentChannelConnectionManager: @unchecked Sendable { let name = secret.name.trimmingCharacters(in: .whitespacesAndNewlines) let keychainId = secret.keychainId.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, - !keychainId.isEmpty, - !name.containsLineBreak, - !keychainId.containsLineBreak, - seen.insert(name).inserted + !keychainId.isEmpty, + !name.containsLineBreak, + !keychainId.containsLineBreak, + seen.insert(name).inserted else { throw AgentChannelConnectionManagerError.invalidSecretReference(name) } @@ -206,9 +210,9 @@ final class AgentChannelConnectionManager: @unchecked Sendable { throw AgentChannelConnectionManagerError.missingCustomHTTPConfiguration(connection.id) } guard let components = URLComponents(string: customHTTP.baseURL), - let scheme = components.scheme?.lowercased(), - ["http", "https"].contains(scheme), - components.host?.isEmpty == false + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + components.host?.isEmpty == false else { throw AgentChannelConnectionManagerError.invalidCustomHTTPBaseURL(customHTTP.baseURL) } @@ -216,7 +220,7 @@ final class AgentChannelConnectionManager: @unchecked Sendable { let supportedActionNames = Set(connection.supportedActions.map(\.rawValue)) for (actionName, action) in customHTTP.actions { guard AgentChannelAction(rawValue: actionName) != nil, - supportedActionNames.contains(actionName) + supportedActionNames.contains(actionName) else { throw AgentChannelConnectionManagerError.unsupportedCustomHTTPAction(actionName) } @@ -227,7 +231,7 @@ final class AgentChannelConnectionManager: @unchecked Sendable { ) } guard action.path.hasPrefix("/"), - !action.path.containsLineBreak + !action.path.containsLineBreak else { throw AgentChannelConnectionManagerError.invalidCustomHTTPPath( action: actionName, diff --git a/Packages/OsaurusCore/Tests/ComputerUse/ComputerUseEvidencePackTests.swift b/Packages/OsaurusCore/Tests/ComputerUse/ComputerUseEvidencePackTests.swift index 556141e1c..0a59e2ccf 100644 --- a/Packages/OsaurusCore/Tests/ComputerUse/ComputerUseEvidencePackTests.swift +++ b/Packages/OsaurusCore/Tests/ComputerUse/ComputerUseEvidencePackTests.swift @@ -244,11 +244,11 @@ final class ComputerUseEvidencePackTests: XCTestCase { func testScreenContextPrivacyPathInjectsFrozenBlockIntoLatestUserTurnOnly() { let frozenBlock = """ - [Screen Context] - Doing: In Safari - Focused field: text field "Search" - [/Screen Context] - """ + [Screen Context] + Doing: In Safari + Focused field: text field "Search" + [/Screen Context] + """ var messages: [ChatMessage] = [ ChatMessage(role: "system", content: "stable system prefix"), ChatMessage(role: "user", content: "first turn"), diff --git a/Packages/OsaurusCore/Tests/Discord/DiscordConnectionTests.swift b/Packages/OsaurusCore/Tests/Discord/DiscordConnectionTests.swift index c68ec45c4..6352f9fa8 100644 --- a/Packages/OsaurusCore/Tests/Discord/DiscordConnectionTests.swift +++ b/Packages/OsaurusCore/Tests/Discord/DiscordConnectionTests.swift @@ -595,7 +595,7 @@ struct DiscordConnectionTests { writeEnabled: true, defaultReadLimit: 250, secrets: [ - AgentChannelSecretReference(name: " bearer ", keychainId: " ops_webhook_token "), + AgentChannelSecretReference(name: " bearer ", keychainId: " ops_webhook_token ") ], customHTTP: AgentChannelCustomHTTPConfiguration( baseURL: "https://hooks.example.test", @@ -604,10 +604,10 @@ struct DiscordConnectionTests { method: "post", path: "/rooms/{room_id}/messages", headers: [ - "Authorization": "Bearer ${secret:bearer}", + "Authorization": "Bearer ${secret:bearer}" ], bodyTemplate: #"{"text":"${content}"}"# - ), + ) ] ) ) @@ -722,7 +722,7 @@ struct DiscordConnectionTests { kind: .customHTTP, supportedActions: [.diagnostics], secrets: [ - AgentChannelSecretReference(name: "bearer", keychainId: "ops_webhook_token"), + AgentChannelSecretReference(name: "bearer", keychainId: "ops_webhook_token") ], customHTTP: AgentChannelCustomHTTPConfiguration( baseURL: "https://hooks.example.test", @@ -764,10 +764,12 @@ struct DiscordConnectionTests { ) } - #expect(throws: AgentChannelConnectionManagerError.invalidCustomHTTPHeader( - action: "send_message", - header: "Authorization" - )) { + #expect( + throws: AgentChannelConnectionManagerError.invalidCustomHTTPHeader( + action: "send_message", + header: "Authorization" + ) + ) { try manager.upsertConnection( AgentChannelConnection( id: "unsafe-webhook", @@ -781,9 +783,9 @@ struct DiscordConnectionTests { method: "POST", path: "/messages", headers: [ - "Authorization": "Bearer ok\nInjected: value", + "Authorization": "Bearer ok\nInjected: value" ] - ), + ) ] ) ) @@ -796,40 +798,40 @@ struct DiscordConnectionTests { try await withIsolatedDiscordStores { _ in let manager = AgentChannelConnectionManager() let json = """ - { - "schemaVersion": 1, - "connections": [ - { - "id": "ops-webhook", - "name": "Ops Webhook", - "kind": "custom_http", - "enabled": true, - "supportedActions": ["diagnostics"], - "spaceAllowlist": [], - "readRoomAllowlist": [], - "writeRoomAllowlist": [], - "writeEnabled": false, - "defaultReadLimit": 50, - "secrets": [], - "customHTTP": { "baseURL": "https://hooks.example.test", "actions": {} } - }, { - "id": "ops-webhook", - "name": "Duplicate", - "kind": "custom_http", - "enabled": true, - "supportedActions": ["diagnostics"], - "spaceAllowlist": [], - "readRoomAllowlist": [], - "writeRoomAllowlist": [], - "writeEnabled": false, - "defaultReadLimit": 50, - "secrets": [], - "customHTTP": { "baseURL": "https://hooks.example.test", "actions": {} } + "schemaVersion": 1, + "connections": [ + { + "id": "ops-webhook", + "name": "Ops Webhook", + "kind": "custom_http", + "enabled": true, + "supportedActions": ["diagnostics"], + "spaceAllowlist": [], + "readRoomAllowlist": [], + "writeRoomAllowlist": [], + "writeEnabled": false, + "defaultReadLimit": 50, + "secrets": [], + "customHTTP": { "baseURL": "https://hooks.example.test", "actions": {} } + }, + { + "id": "ops-webhook", + "name": "Duplicate", + "kind": "custom_http", + "enabled": true, + "supportedActions": ["diagnostics"], + "spaceAllowlist": [], + "readRoomAllowlist": [], + "writeRoomAllowlist": [], + "writeEnabled": false, + "defaultReadLimit": 50, + "secrets": [], + "customHTTP": { "baseURL": "https://hooks.example.test", "actions": {} } + } + ] } - ] - } - """ + """ #expect(throws: AgentChannelConnectionManagerError.duplicateConnectionId("ops-webhook")) { try manager.importConfigurationData(Data(json.utf8)) diff --git a/Packages/OsaurusCore/Tests/Model/MLXModelTests.swift b/Packages/OsaurusCore/Tests/Model/MLXModelTests.swift index f2a303108..0280a26ed 100644 --- a/Packages/OsaurusCore/Tests/Model/MLXModelTests.swift +++ b/Packages/OsaurusCore/Tests/Model/MLXModelTests.swift @@ -146,6 +146,44 @@ struct MLXModelTests { #expect(model.isDownloaded == false) } + // MARK: - simplifiedName (onboarding chooser friendly title) + + private func model(named name: String) -> MLXModel { + MLXModel(id: "org/\(name)", name: name, description: "", downloadURL: "https://example.com") + } + + /// The chooser title strips instruction-tuned (`it`), quant/precision + /// (`MXFP8`/`MXFP4`/`qat`/`4bit`/`MTP`), and MoE active-param (`A1B`/`A4B`) + /// tokens so the name reads like a product, while keeping family + version + + /// size tier (including the Gemma `E2B`/`E4B` tiers). + @Test func simplifiedName_stripsPrecisionAndJargonTokens() { + #expect(model(named: "Gemma 4 12B it MXFP8").simplifiedName == "Gemma 4 12B") + #expect(model(named: "Gemma 4 12B it qat MXFP4").simplifiedName == "Gemma 4 12B") + #expect(model(named: "Gemma 4 E2B it qat MXFP4").simplifiedName == "Gemma 4 E2B") + #expect(model(named: "Gemma 4 26B A4B it qat MXFP4").simplifiedName == "Gemma 4 26B") + #expect(model(named: "LFM2.5 8B A1B MXFP8").simplifiedName == "LFM2.5 8B") + #expect(model(named: "Qwen3.6 27B MXFP8 MTP").simplifiedName == "Qwen3.6 27B") + #expect( + model(named: "Nemotron 3 Nano Omni 30B A3B MXFP4").simplifiedName + == "Nemotron 3 Nano Omni 30B" + ) + } + + /// Two same-size builds collapse to the same friendly title — that's why the + /// chooser pairs the title with a precision chip to keep them apart. + @Test func simplifiedName_sameSizeVariantsCollapseToSameTitle() { + let highPrecision = model(named: "Gemma 4 12B it MXFP8").simplifiedName + let efficient = model(named: "Gemma 4 12B it qat MXFP4").simplifiedName + #expect(highPrecision == efficient) + } + + /// If stripping would leave nothing, fall back to the original name rather + /// than rendering an empty row title. + @Test func simplifiedName_fallsBackWhenAllTokensAreJargon() { + #expect(model(named: "MXFP4").simplifiedName == "MXFP4") + #expect(model(named: "it qat MXFP8").simplifiedName == "it qat MXFP8") + } + @Test func releasedAt_defaultsToNil() { let model = MLXModel( id: "org/repo", diff --git a/Packages/OsaurusCore/Tests/Model/ModelManagerSuggestedTests.swift b/Packages/OsaurusCore/Tests/Model/ModelManagerSuggestedTests.swift index 4bffa1cdb..12775c689 100644 --- a/Packages/OsaurusCore/Tests/Model/ModelManagerSuggestedTests.swift +++ b/Packages/OsaurusCore/Tests/Model/ModelManagerSuggestedTests.swift @@ -239,7 +239,6 @@ struct ModelManagerSuggestedTests { "OsaurusAI/gemma-4-12B-it-qat-MXFP4", "OsaurusAI/gemma-4-31B-it-qat-MXFP4", "OsaurusAI/gemma-4-26B-A4B-it-qat-MXFP4", - "OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8", "OsaurusAI/gemma-4-E4B-it-8bit", "OsaurusAI/gemma-4-E2B-it-8bit", "OsaurusAI/Qwen3.6-27B-MXFP4", @@ -270,6 +269,7 @@ struct ModelManagerSuggestedTests { "liquidai/lfm2-24b-a2b-mlx-8bit", "lmstudio-community/gpt-oss-20b-mlx-8bit", "lmstudio-community/gpt-oss-120b-mlx-8bit", + "osaurusai/diffusiongemma-26b-a4b-it-mxfp8", "osaurusai/gemma-4-26b-a4b-it-mxfp4", "osaurusai/gemma-4-26b-a4b-it-4bit", "osaurusai/gemma-4-31b-it-jang_4m", @@ -298,8 +298,17 @@ struct ModelManagerSuggestedTests { description: "from auto-fetch", downloadURL: "https://huggingface.co/OsaurusAI/gemma-4-26B-A4B-it-4bit" ) - manager.applyOsaurusOrgFetch(autoFetched: [retired]) + // DiffusionGemma was retired from suggestions; the org auto-fetch + // must not re-surface it as a plain non-curated row either. + let diffusionGemma = MLXModel( + id: "OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8", + name: "x", + description: "from auto-fetch", + downloadURL: "https://huggingface.co/OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8" + ) + manager.applyOsaurusOrgFetch(autoFetched: [retired, diffusionGemma]) #expect(!manager.suggestedModels.contains { $0.id == retired.id }) + #expect(!manager.suggestedModels.contains { $0.id == diffusionGemma.id }) } } @@ -334,7 +343,6 @@ struct ModelManagerSuggestedTests { let candidates = ModelManager().suggestedModels.filter(\.isTopSuggestion) let excluded: Set = [ "OsaurusAI/gemma-4-26B-A4B-it-qat-MXFP4", - "OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8", "OsaurusAI/Qwen3.6-35B-A3B-MXFP8-MTP", "OsaurusAI/Qwen3.6-27B-MXFP8-MTP", ] diff --git a/Packages/OsaurusCore/Tests/Onboarding/ConfigureAIStateDownloadTests.swift b/Packages/OsaurusCore/Tests/Onboarding/ConfigureAIStateDownloadTests.swift index 23178ecff..61aabb078 100644 --- a/Packages/OsaurusCore/Tests/Onboarding/ConfigureAIStateDownloadTests.swift +++ b/Packages/OsaurusCore/Tests/Onboarding/ConfigureAIStateDownloadTests.swift @@ -88,34 +88,73 @@ struct ConfigureAIStateDownloadTests { } /// `cancelLocalDownload()` must both reset the download state AND - /// pop the substate back to the picker. The previous UX left the - /// user on the dead downloading screen even after dismissing the - /// failure alert — issue #1071. - @Test func cancelLocalDownload_returnsToPickerAndResetsState() { + /// pop the screen back home. The previous UX left the user on the dead + /// downloading screen even after dismissing the failure alert — issue #1071. + @Test func cancelLocalDownload_returnsHomeAndResetsState() { let (state, model) = makeStateWithModel() defer { clear(model) } - state.localSubstate = .downloading + state.screen = .downloading ModelManager.shared.downloadService.downloadStates[model.id] = .downloading(progress: 0.3) state.cancelLocalDownload() - #expect(state.localSubstate == .picker) + #expect(state.screen == .home) let after = ModelManager.shared.downloadService.downloadStates[model.id] #expect(after == .notStarted) } - @Test func localPathStaysAvailableAt24GBAndBelow() { - #expect(ConfigureAIState.isLocalTabAvailable(totalMemoryGB: 24) == true) - #expect(ConfigureAIState.isLocalTabAvailable(totalMemoryGB: 8) == true) + /// The step is local-first: a fresh state lands on the home screen with the + /// local card selected (hosted off), so the recommended "Run on your Mac" + /// card is the default and the Osaurus Router stays gated behind an explicit + /// Cloud choice. + @Test func defaultsToLocalHomeScreen() { + let state = ConfigureAIState() + #expect(state.screen == .home) + #expect(state.isHostedSelected == false) + #expect(state.apiSubstate == .picker) + #expect(state.selectedBrainSource == nil) + + // `ensureLocalSelection` pre-picks a model without flipping to hosted. + state.ensureLocalSelection(totalMemoryGB: 24) + #expect(state.isHostedSelected == false) + #expect(state.screen == .home) + } + /// The router gate in `finishOnboarding` keys off `selectedBrainSource`: + /// hosted -> router enabled, everything else -> disabled. Confirm the home + /// card selectors feed that signal correctly. + @Test func selectingHostedVsLocalDrivesBrainSourceForRouterGate() { let state = ConfigureAIState() - #expect(state.availablePaths(totalMemoryGB: 24) == [.local, .apiProvider]) - #expect(state.availablePaths(totalMemoryGB: 8) == [.local, .apiProvider]) - state.selectedPath = .local - state.applyDefaultPathIfNeeded(totalMemoryGB: 24) - #expect(state.selectedPath == .local) + // Tapping the cloud card selects hosted (radio). + state.selectHostedOsaurus() + #expect(state.isHostedSelected == true) + + // Tapping the local card flips back to local. + state.selectLocalBrain() + #expect(state.isHostedSelected == false) + + // Committing hosted records the hosted brain source -> router enabled. + state.selectHostedAndContinue(onComplete: {}) + #expect(state.selectedBrainSource == .hostedOsaurus) + } + + /// Drilling into bring-your-own-key drops the hosted selection so the + /// footer Continue doesn't advance the hosted path, and backing out returns + /// to the home screen. + @Test func byokDrillInAndBackReturnsHome() { + let state = ConfigureAIState() + state.selectHostedOsaurus() + #expect(state.isHostedSelected == true) + + state.showBYOK() + #expect(state.screen == .byok) + #expect(state.apiSubstate == .picker) + #expect(state.isHostedSelected == false) + + state.popBYOKToHome() + #expect(state.screen == .home) } @Test func ensureLocalSelectionDoesNotDeadEndWhenAllCuratedModelsAreTooLarge() { @@ -189,4 +228,84 @@ struct ConfigureAIStateDownloadTests { state.selectedBrainSource = .providerKey(.openai) #expect(state.providerModelPinTarget == providerId) } + + // MARK: - Model chooser modal + + /// Build a throwaway in-memory model so a test can move the draft to a + /// different selection than the seeded one. + private func makeModel(_ tag: String) -> MLXModel { + MLXModel( + id: "cfg-ai/\(tag)-\(UUID().uuidString)", + name: "Test \(tag)", + description: "", + downloadURL: "https://example.com/\(tag)", + rootDirectory: FileManager.default.temporaryDirectory + ) + } + + /// Opening the chooser seeds the draft from the current selection (so the + /// active model is pre-highlighted) and flips the dialog open. + @Test func openModelChooser_seedsDraftFromSelectionAndOpens() { + let (state, model) = makeStateWithModel() + defer { clear(model) } + + #expect(state.isChoosingModel == false) + #expect(state.draftModel == nil) + + state.openModelChooser() + + #expect(state.isChoosingModel == true) + #expect(state.draftModel?.id == model.id) + } + + /// Tapping a row only moves the draft — it does not commit the selection or + /// close the dialog, so users can browse freely before deciding. + @Test func selectDraftModel_updatesDraftWithoutCommitting() { + let (state, model) = makeStateWithModel() + defer { clear(model) } + + state.openModelChooser() + let other = makeModel("other") + state.selectDraftModel(other) + + #expect(state.draftModel?.id == other.id) + #expect(state.selectedModel?.id == model.id) + #expect(state.isChoosingModel == true) + } + + /// "Use this model" applies the draft as the active local brain (which also + /// clears any hosted selection) and closes the dialog. + @Test func commitModelChooser_appliesDraftClearsHostedAndCloses() { + let (state, model) = makeStateWithModel() + defer { clear(model) } + + // User had the cloud card selected before opening the chooser. + state.selectHostedOsaurus() + #expect(state.isHostedSelected == true) + + state.openModelChooser() + let picked = makeModel("picked") + state.selectDraftModel(picked) + state.commitModelChooser() + + #expect(state.selectedModel?.id == picked.id) + #expect(state.isHostedSelected == false) + #expect(state.isChoosingModel == false) + #expect(state.draftModel?.id == picked.id) + } + + /// Cancel / X / Esc / scrim-tap all route here: the dialog closes and the + /// committed selection is untouched even though the draft had moved. + @Test func cancelModelChooser_closesWithoutChangingSelection() { + let (state, model) = makeStateWithModel() + defer { clear(model) } + + state.openModelChooser() + state.selectDraftModel(makeModel("other")) + + state.cancelModelChooser() + + #expect(state.isChoosingModel == false) + #expect(state.selectedModel?.id == model.id) + } } diff --git a/Packages/OsaurusCore/Tests/PrivacyFilter/DecouplingAndBuilderTests.swift b/Packages/OsaurusCore/Tests/PrivacyFilter/DecouplingAndBuilderTests.swift index cdb432392..0eb9b8c1e 100644 --- a/Packages/OsaurusCore/Tests/PrivacyFilter/DecouplingAndBuilderTests.swift +++ b/Packages/OsaurusCore/Tests/PrivacyFilter/DecouplingAndBuilderTests.swift @@ -72,11 +72,13 @@ struct DecouplingAndBuilderTests { @Test func builder_numberSequence_rangeAndOpenEnded() { #expect( RuleBuilder(matchType: .numberSequence, digitsMin: 4, digitsMax: 6).compile() - == #"\b\d{4,6}\b"#) + == #"\b\d{4,6}\b"# + ) // digitsMax < digitsMin means "no upper bound". #expect( RuleBuilder(matchType: .numberSequence, digitsMin: 9, digitsMax: 0).compile() - == #"\b\d{9,}\b"#) + == #"\b\d{9,}\b"# + ) } @Test func builder_betweenMarkers_nonGreedy() { @@ -90,7 +92,8 @@ struct DecouplingAndBuilderTests { #expect(RuleBuilder(matchType: .numberSequence, digitsMin: 0).compile() == nil) #expect( RuleBuilder(matchType: .betweenMarkers, startMarker: "", endMarker: ">>").compile() - == nil) + == nil + ) } @Test func effectivePattern_resolvesBuilderOrRaw() { @@ -98,13 +101,21 @@ struct DecouplingAndBuilderTests { #expect(raw.effectivePattern == "abc") let built = PrivacyRule( - name: "built", pattern: "", category: .secret, kind: .builder, - builder: RuleBuilder(matchType: .exactWord, terms: ["Zed"])) + name: "built", + pattern: "", + category: .secret, + kind: .builder, + builder: RuleBuilder(matchType: .exactWord, terms: ["Zed"]) + ) #expect(built.effectivePattern == #"\b(?:Zed)\b"#) let empty = PrivacyRule( - name: "empty", pattern: "", category: .secret, kind: .builder, - builder: RuleBuilder(matchType: .anyOfTerms, terms: [])) + name: "empty", + pattern: "", + category: .secret, + kind: .builder, + builder: RuleBuilder(matchType: .anyOfTerms, terms: []) + ) #expect(empty.effectivePattern == nil) } @@ -114,9 +125,13 @@ struct DecouplingAndBuilderTests { var config = PrivacyFilterConfiguration() config.customRules = [ PrivacyRule( - name: "Codename", pattern: "", category: .secret, kind: .builder, + name: "Codename", + pattern: "", + category: .secret, + kind: .builder, caseSensitive: false, - builder: RuleBuilder(matchType: .exactWord, terms: ["Bluebird"])) + builder: RuleBuilder(matchType: .exactWord, terms: ["Bluebird"]) + ) ] let set = RegexEntityDetector.EffectiveRuleSet.build(from: config) let matches = RegexEntityDetector.detect(in: "the BLUEBIRD project", ruleset: set) @@ -127,9 +142,13 @@ struct DecouplingAndBuilderTests { var config = PrivacyFilterConfiguration() config.customRules = [ PrivacyRule( - name: "Codename", pattern: "", category: .secret, kind: .builder, + name: "Codename", + pattern: "", + category: .secret, + kind: .builder, caseSensitive: true, - builder: RuleBuilder(matchType: .exactWord, terms: ["Bluebird"])) + builder: RuleBuilder(matchType: .exactWord, terms: ["Bluebird"]) + ) ] let set = RegexEntityDetector.EffectiveRuleSet.build(from: config) let matches = RegexEntityDetector.detect(in: "the BLUEBIRD project", ruleset: set) @@ -140,8 +159,11 @@ struct DecouplingAndBuilderTests { var config = PrivacyFilterConfiguration() config.customRules = [ PrivacyRule( - name: "Tag", pattern: "secret-[0-9]+", category: .secret, - caseSensitive: false) + name: "Tag", + pattern: "secret-[0-9]+", + category: .secret, + caseSensitive: false + ) ] let set = RegexEntityDetector.EffectiveRuleSet.build(from: config) let matches = RegexEntityDetector.detect(in: "ref SECRET-42 here", ruleset: set) diff --git a/Packages/OsaurusCore/Views/Management/ManagementView.swift b/Packages/OsaurusCore/Views/Management/ManagementView.swift index f1147a877..41f6840f6 100644 --- a/Packages/OsaurusCore/Views/Management/ManagementView.swift +++ b/Packages/OsaurusCore/Views/Management/ManagementView.swift @@ -143,7 +143,8 @@ private extension ManagementView { get: { stateManager.selectedTab.rawValue }, set: { newValue in if let tab = ManagementTab.resolved(from: newValue), - ManagementTab.visibleCases.contains(tab) { + ManagementTab.visibleCases.contains(tab) + { stateManager.selectedTab = tab } } diff --git a/Packages/OsaurusCore/Views/Onboarding/OnboardingCards.swift b/Packages/OsaurusCore/Views/Onboarding/OnboardingCards.swift index 2037e22c8..f31fbf2a0 100644 --- a/Packages/OsaurusCore/Views/Onboarding/OnboardingCards.swift +++ b/Packages/OsaurusCore/Views/Onboarding/OnboardingCards.swift @@ -144,6 +144,10 @@ struct OnboardingRowBadge { case warning /// Red chip — used for high-risk compatibility warnings. case error + /// Filled accent pill — the high-signal "Recommended" tag that points + /// brand-new users at the default model in the chooser dialog. Distinct + /// from `.success` (green) so it doesn't read as another "Downloaded". + case accent /// Category chip whose color, icon, and label all come from the /// `ModelUseCase`. The badge's `text` field is unused for this /// style — `OnboardingBadgeChip` reads from the enum directly. @@ -533,8 +537,10 @@ struct OnboardingCalloutBanner: View { // MARK: - Badge Chip -/// Small chip used for `OnboardingRowCard` badges. -private struct OnboardingBadgeChip: View { +/// Small chip used for `OnboardingRowCard` badges. Also reused directly by the +/// Configure AI home card's selected-model inset so its precision / Downloaded / +/// size chips stay pixel-identical to the ones in the model chooser. +struct OnboardingBadgeChip: View { let badge: OnboardingRowBadge @Environment(\.theme) private var theme @@ -572,6 +578,19 @@ private struct OnboardingBadgeChip: View { icon: "xmark.octagon.fill", color: theme.errorColor ) + case .accent: + HStack(spacing: 3) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .semibold)) + Text(badge.text) + .font(theme.font(size: 10, weight: .semibold)) + .lineLimit(1) + } + .fixedSize(horizontal: true, vertical: false) + .foregroundColor(theme.onboardingOnAccent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(shape.fill(theme.accentColor)) case .useCase(let useCase): HStack(spacing: 3) { Image(systemName: useCase.iconName) diff --git a/Packages/OsaurusCore/Views/Onboarding/OnboardingConfigureAIView.swift b/Packages/OsaurusCore/Views/Onboarding/OnboardingConfigureAIView.swift index bd601d3f6..4fcd65c28 100644 --- a/Packages/OsaurusCore/Views/Onboarding/OnboardingConfigureAIView.swift +++ b/Packages/OsaurusCore/Views/Onboarding/OnboardingConfigureAIView.swift @@ -2,58 +2,42 @@ // OnboardingConfigureAIView.swift // osaurus // -// Onboarding step 3 — pick where the model brain lives (a curated local -// MLX model, or any cloud / locally-hosted provider) and configure it -// inline. +// Onboarding step 3 — "Give your dino a brain". Local-first: a single home +// screen leads with "Run on your Mac" (the recommended default — a curated +// MLX model that runs locally), demotes the managed "Osaurus Cloud" option to +// a secondary "want more power?" card, and tucks bring-your-own-key behind a +// quiet drill-in row. // -// Apple Intelligence was removed from this step: it's too limited (no -// tools, no web, no agent work) to be a first-class first-run option. -// Users with `FoundationModelService` available can still configure it -// post-onboarding from Settings. +// Apple Intelligence was removed from this step: it's too limited (no tools, +// no web, no agent work) to be a first-class first-run option. Users with +// `FoundationModelService` available can still configure it post-onboarding +// from Settings. // // Split into: -// - `ConfigureAIState`: ObservableObject holding path/substate selection, -// connection-test progress, and the substate slide direction (lives at -// OnboardingView level). -// - `ConfigureAIBody`: the body slot — sticky segmented path picker plus a -// per-path substate body that slides direction-aware between picker -// and drilled-in forms. -// - `ConfigureAICTA`: the footer primary action, dispatched per substate. +// - `ConfigureAIState`: ObservableObject holding the home selection (local +// vs hosted), the drilled-in screen (download / bring-your-own-key), +// connection-test progress, and the slide direction (lives at the +// OnboardingView level so it survives step transitions). +// - `ConfigureAIBody`: the body slot — a two-column shell whose right column +// is the home screen, sliding direction-aware into the download and +// bring-your-own-key sub-screens. +// - `ConfigureAICTA`: the footer primary action, dispatched per screen. // import SwiftUI -// MARK: - Path +// MARK: - Screen / substates -enum ConfigurePath: String, CaseIterable { - case local - case apiProvider - - var title: LocalizedStringKey { - switch self { - case .local: return "Local" - // Renamed from "Cloud" once the managed cloud option (Osaurus Cloud) was - // promoted to its own always-visible lead card above the tabs: this tab - // is now strictly the bring-your-own-key path, so "Cloud" would collide. - case .apiProvider: return "Your own key" - } - } - - var icon: String { - switch self { - case .local: return "internaldrive" - case .apiProvider: return "key" - } - } -} - -// MARK: - Local / API substates - -enum LocalSubstate: Equatable { - case picker +/// The top-level screen within the Configure AI step. `home` shows the local + +/// cloud cards and the bring-your-own-key entry row; the other two are +/// drilled-in sub-screens reached from home. +enum ConfigureScreen: Equatable { + case home case downloading + case byok } +/// Bring-your-own-key drill-in depth (inside `ConfigureScreen.byok`). enum APISubstate: Equatable { case picker /// "Use an API key" drill-in: grouped list of API-key vendors, the local @@ -68,8 +52,6 @@ enum APITestResult: Equatable { case failure(String) } -// MARK: - Auth choice protocol - // MARK: - Resolved provider config struct ResolvedProviderConfig { @@ -125,23 +107,25 @@ struct CustomProviderForm { @MainActor final class ConfigureAIState: ObservableObject { - @Published var selectedPath: ConfigurePath = .local - @Published var localSubstate: LocalSubstate = .picker + /// The screen currently shown. Starts at `home` (local + cloud cards plus + /// the bring-your-own-key entry row). + @Published var screen: ConfigureScreen = .home + + /// Bring-your-own-key drill-in depth. Only meaningful while + /// `screen == .byok`. @Published var apiSubstate: APISubstate = .picker - /// Whether the always-visible Osaurus Cloud lead card is the active brain - /// selection. It sits above the Local / Your-own-key tabs and is selected by - /// default, so the lowest-friction path is the landing state. The flag is - /// "sticky": switching tabs never changes it; it only clears when the user - /// actively commits to an alternative (taps a local model, drills into a - /// bring-your-own-key provider), and is re-asserted by tapping the card. - /// Drives the Continue CTA at the top-level pickers. - @Published var isHostedSelected: Bool = true + /// Whether the managed "Osaurus Cloud" card is the active brain selection. + /// Defaults to `false`: the step is local-first, so "Run on your Mac" is the + /// pre-selected recommended default. Flipped on by tapping the cloud card, + /// and off by tapping the local card, picking a local model, or drilling + /// into a bring-your-own-key provider. + @Published var isHostedSelected: Bool = false /// The brain the user committed to on this step. Recorded at the proceed /// moment for each path (with no payment side effect) and read by - /// `finishOnboarding` to pin hosted routing and persist the analytics - /// dimension for the first `message_sent`. + /// `finishOnboarding` to pin routing and persist the analytics dimension for + /// the first `message_sent`. @Published var selectedBrainSource: BrainSource? = nil /// The local model id to record as the active agent's default when the user @@ -166,23 +150,13 @@ final class ConfigureAIState: ObservableObject { return addedProviderId } - /// Guards `applyDefaultPathIfNeeded(totalMemoryGB:)` so the RAM-based - /// default path is only ever applied once. Without it a user who manually - /// switched back to Local would be bounced to Cloud again on the next - /// `onAppear`. - private var didApplyDefaultPath = false - - /// Direction the next substate transition should travel. Mirrors the - /// global step `OnboardingDirection` so the substate slide reads as a - /// natural continuation of the outer navigation language. + /// Direction the next screen transition should travel. Mirrors the global + /// step `OnboardingDirection` so the sub-screen slide reads as a natural + /// continuation of the outer navigation language. @Published var substateDirection: OnboardingDirection = .forward // Local @Published var selectedModel: MLXModel? = nil - /// Whether the local picker has expanded past the single opinionated - /// default to reveal the remaining eligible models. Lives on the state - /// (not the body view) so the choice survives step slide transitions. - @Published var showAllLocalModels = false // API @Published var apiKey: String = "" @@ -211,45 +185,18 @@ final class ConfigureAIState: ObservableObject { /// both finalize. Reset whenever credentials are cleared (back / reselect). var hasFinalizedAPI = false - /// Whether the Local tab should be offered at all on this Mac. RAM is an - /// advisory fit signal only; users can still choose local models on small - /// machines because mmap-backed runtimes may succeed under macOS memory - /// compression/paging even when a static estimate looks tight. - static func isLocalTabAvailable(totalMemoryGB: Double) -> Bool { - true - } - - /// Paths offered on this Mac. - func availablePaths(totalMemoryGB: Double) -> [ConfigurePath] { - [.local, .apiProvider] - } - - // No footer caption on either tab. The reassurance copy crowded the footer, - // and — more importantly — a caption on one tab but not the other makes the - // footer (and thus the centered left-column dino) jump in height when the - // user switches tabs. Keeping both captionless holds the layout steady. + // No footer caption. The reassurance copy crowded the footer, and a caption + // present on one screen but not another makes the footer (and thus the + // centered left-column dino) jump in height. var footerCaption: LocalizedStringKey? { nil } - func selectPath(_ path: ConfigurePath) { - // Path changes are lateral, but we treat them as forward motion so - // the substate body slides in from the trailing edge consistently. - substateDirection = .forward - selectedPath = path - if path != .local { localSubstate = .picker } - if path != .apiProvider { resetAPIState(direction: .forward) } - // The hosted lead card lives above the tabs, so switching tabs is pure - // navigation — it must not change the hosted selection. The flag only - // clears when the user commits to a model / provider below. - testResult = nil - } - // MARK: Back handling /// The global header back button always exits the Configure AI step. - /// Sub-substates (key form, custom form, local downloading) have their - /// own in-section back rows, so the header back button doesn't double - /// as both global-step nav AND substate nav — that ambiguity used to - /// confuse users. + /// Sub-screens (download, bring-your-own-key forms) have their own + /// in-section back rows, so the header back button doesn't double as both + /// global-step nav AND sub-screen nav — that ambiguity used to confuse + /// users. func handleBack(parentBack: () -> Void) { parentBack() } @@ -297,19 +244,8 @@ final class ConfigureAIState: ObservableObject { } } - /// Picks the lowest-friction default tab for this Mac on first appearance. - /// Local remains available even on low-RAM Macs; compatibility only affects - /// the recommended model and warning badges. - func applyDefaultPathIfNeeded(totalMemoryGB: Double) { - guard !didApplyDefaultPath else { return } - // `totalMemoryGB == 0` means the monitor hasn't reported yet; wait for - // a real value before committing to a default. - guard totalMemoryGB > 0 else { return } - didApplyDefaultPath = true - } - /// Auto-selects the recommended local pick — the best model this Mac can - /// run — so the picker lands on a sensible default the user can just + /// run — so the home screen lands on a sensible default the user can just /// accept. The rule is hardware-deterministic: /// /// 1. If a curated top pick is already on disk, keep it. The user @@ -356,9 +292,9 @@ final class ConfigureAIState: ObservableObject { /// 3. The smallest comfortable top pick; or, if nothing is comfortable, /// the smallest candidate overall (never the largest). /// - /// The 26B-A4B QAT MoE, DiffusionGemma, and the larger Qwen/Nemotron - /// flagships are intentionally excluded from (1)/(2): they stay selectable - /// Top Picks in the carousel but are never auto-selected. + /// The 26B-A4B QAT MoE and the larger Qwen/Nemotron flagships are + /// intentionally excluded from (1)/(2): they stay selectable Top Picks but + /// are never auto-selected. static func recommendedLocalPick( from candidates: [MLXModel], totalMemoryGB: Double @@ -386,14 +322,58 @@ final class ConfigureAIState: ObservableObject { return smallest(comfortable) ?? smallest(candidates) } - /// Tapping a local model row makes it the active brain, superseding the - /// hosted lead card above. Kept side-effect-light (no `withAnimation`) so the - /// footer CTA doesn't morph through the shared transaction. + /// Tapping a local model row (in the "Change" popover) makes it the active + /// brain, superseding the hosted card. Kept side-effect-light (no + /// `withAnimation`) so the footer CTA doesn't morph through the shared + /// transaction. func selectLocalModel(_ model: MLXModel) { isHostedSelected = false selectedModel = model } + /// Tapping the "Run on your Mac" card selects the local brain without + /// changing which model is picked. + func selectLocalBrain() { + isHostedSelected = false + } + + // MARK: Model chooser (centered modal) + + /// Whether the centered "Choose your model" dialog is open. It's hosted at + /// the OnboardingView window root so it can dim the whole step and center + /// over it — a popover trapped in the small, clipped body region overflowed + /// the window and covered the footer CTA. + @Published var isChoosingModel: Bool = false + + /// The model highlighted inside the chooser before the user confirms. The + /// draft lets brand-new users browse without committing: `commitModelChooser` + /// applies it, Cancel discards it. + @Published var draftModel: MLXModel? = nil + + /// Open the chooser, seeding the highlight from the current selection. + func openModelChooser() { + draftModel = selectedModel + isChoosingModel = true + } + + /// Highlight a model inside the chooser (no commit yet). + func selectDraftModel(_ model: MLXModel) { + draftModel = model + } + + /// Apply the highlighted model as the active local brain and close. + func commitModelChooser() { + if let model = draftModel { + selectLocalModel(model) + } + isChoosingModel = false + } + + /// Close the chooser without changing the selection. + func cancelModelChooser() { + isChoosingModel = false + } + func startLocalDownloadOrContinue(onComplete: () -> Void) { // Committing to a local model — record the brain source for the funnel // (no payment, no network). @@ -404,7 +384,7 @@ final class ConfigureAIState: ObservableObject { return } substateDirection = .forward - localSubstate = .downloading + screen = .downloading startLocalDownload() } @@ -423,15 +403,41 @@ final class ConfigureAIState: ObservableObject { ModelManager.shared.resumeDownload(model.id) } - /// Cancels an in-flight or paused download and returns the user to the - /// model picker. Used by the inline Cancel control on the downloading - /// screen so the user has a clear escape route — the previous version - /// only had the small back chevron at the top of the section. + /// Cancels an in-flight or paused download and returns the user to the home + /// screen. Used by the inline Cancel control on the downloading screen so + /// the user has a clear escape route — the previous version only had the + /// small back chevron at the top of the section. func cancelLocalDownload() { if let model = selectedModel { ModelManager.shared.cancelDownload(model.id) } - popLocalToPicker() + popToHome() + } + + // MARK: Navigation + + /// Any drilled-in sub-screen → home (backward slide). + func popToHome() { + substateDirection = .backward + screen = .home + isChoosingModel = false + } + + /// Home → bring-your-own-key flow (forward slide). Drilling into BYOK drops + /// the hosted selection so Continue doesn't advance the hosted path. + func showBYOK() { + substateDirection = .forward + isHostedSelected = false + apiSubstate = .picker + screen = .byok + isChoosingModel = false + } + + /// BYOK top-level picker → home (backward slide). Clears any entered + /// credentials so a stale secret never leaks across selections. + func popBYOKToHome() { + resetAPIState(direction: .backward) + screen = .home } // MARK: API @@ -480,9 +486,8 @@ final class ConfigureAIState: ObservableObject { } /// Resets the API substate back to the picker. Direction defaults to - /// `.backward` so the substate slide reads as "popping out", but - /// callers can pass `.forward` when this is invoked as a side-effect - /// of a forward path switch. + /// `.backward` so the slide reads as "popping out", but callers can pass + /// `.forward` when this is invoked as a side-effect of a forward switch. func resetAPIState(direction: OnboardingDirection = .backward) { substateDirection = direction apiSubstate = .picker @@ -506,8 +511,8 @@ final class ConfigureAIState: ObservableObject { /// API-key sub-list). func showAPIKeyPicker() { substateDirection = .forward - // Drilling into a bring-your-own-key flow drops the hosted lead - // selection so Continue doesn't advance the hosted path by mistake. + // Drilling into a bring-your-own-key flow drops the hosted selection so + // Continue doesn't advance the hosted path by mistake. isHostedSelected = false apiSubstate = .apiKeyPicker } @@ -516,10 +521,6 @@ final class ConfigureAIState: ObservableObject { func popAPIKeyPickerToTop() { substateDirection = .backward apiSubstate = .picker - // Selection is sticky: backing out of a bring-your-own-key flow leaves - // the hosted card deselected (the lead card shows its unselected state), - // so the user can re-tap it to reassert hosted rather than having it - // silently re-selected under them. } /// Back out of a provider form. A form entered via the OAuth-first top level @@ -536,8 +537,6 @@ final class ConfigureAIState: ObservableObject { let returnToTop = selectedAuthMethod.isOAuth clearAPICredentials() apiSubstate = returnToTop ? .picker : .apiKeyPicker - // Selection stays sticky on back (see `popAPIKeyPickerToTop`): the hosted - // card keeps its deselected state until the user taps it again. } /// Picker → form drill-in. Tapping a provider card immediately advances @@ -550,7 +549,7 @@ final class ConfigureAIState: ObservableObject { /// is no in-form fork, so we pin the auth mode here at selection time. func selectAPIPreset(_ preset: ProviderPreset, preferAPIKey: Bool = false) { substateDirection = .forward - // Choosing a bring-your-own-key provider supersedes the hosted lead. + // Choosing a bring-your-own-key provider supersedes the hosted card. isHostedSelected = false if let entry = ProviderCatalog.entry(for: preset) { selectedAuthMethod = preferAPIKey ? .apiKey : (entry.authMethods.first ?? .apiKey) @@ -564,17 +563,11 @@ final class ConfigureAIState: ObservableObject { // MARK: Hosted (Osaurus, Venice-backed) - /// Tapping the always-visible hosted lead card re-asserts the recommended - /// selection, superseding any local model / provider the user had picked. - /// Because the card lives above the tabs and stays selectable even after a - /// drill-in, selecting it also collapses any in-progress provider form / - /// API-key sub-list / local-download view back to the top-level pickers. - /// Otherwise hosted could read as "selected" while a provider's own form and - /// footer action were still on screen. + /// Tapping the "Osaurus Cloud" card makes hosted the active brain selection, + /// superseding any local model the user had picked. func selectHostedOsaurus() { isHostedSelected = true - resetAPIState(direction: .backward) - localSubstate = .picker + screen = .home } /// Commit the hosted brain and advance. Selection is intentionally NOT a @@ -591,12 +584,6 @@ final class ConfigureAIState: ObservableObject { onComplete() } - /// Local downloading → picker (backward). - func popLocalToPicker() { - substateDirection = .backward - localSubstate = .picker - } - func resolvedAPIConfig() -> ResolvedProviderConfig? { guard let provider = currentAPIProvider else { return nil } if provider == .custom { @@ -726,18 +713,16 @@ struct ConfigureAIBody: View { @Environment(\.theme) private var theme @ObservedObject private var modelManager = ModelManager.shared - /// Drives the capability filter on the local picker. `totalMemoryGB` - /// is populated synchronously in `SystemMonitorService.init`, so the - /// first onboarding frame already has a real value to classify - /// curated top suggestions against. + /// Drives the capability filter on the local model popover. `totalMemoryGB` + /// is populated synchronously in `SystemMonitorService.init`, so the first + /// onboarding frame already has a real value to classify curated top + /// suggestions against. /// Non-observing on purpose. We only ever read `totalMemoryGB` — total - /// physical RAM, a runtime constant populated synchronously in - /// `SystemMonitorService.init` (see `body`'s comment below). Observing via - /// `@ObservedObject` subscribed this deep onboarding tree to the service's - /// 2s CPU/memory publishes, forcing a full re-render every tick. On a - /// memory-pressured machine those re-renders were slow enough to trip the - /// app-hang watchdog. A plain reference reads the same constant without - /// subscribing to publishes that can never change our output. + /// physical RAM, a runtime constant. Observing via `@ObservedObject` + /// subscribed this deep onboarding tree to the service's 2s CPU/memory + /// publishes, forcing a full re-render every tick. A plain reference reads + /// the same constant without subscribing to publishes that can never change + /// our output. private let systemMonitor = SystemMonitorService.shared var body: some View { @@ -746,146 +731,49 @@ struct ConfigureAIBody: View { leftHeadline: "Pick a brain", leftBody: "Run a brain on your Mac, or plug in one you already pay for. You can swap brains any time, and your chats come along.", - subtitle: pathSubtitle, - // We manage our own inner scroll: the segmented control stays - // pinned at the top while the substate body scrolls beneath it. + subtitle: "Your dino runs on your Mac. Add more power whenever you want.", + // We manage our own inner scroll: each screen owns its scrolling so + // the slide transition stays crisp. useScrollView: false ) { - VStack(alignment: .leading, spacing: 14) { - // Osaurus Cloud is a top-level choice that lives outside the tab - // system: always visible and selectable, even after the user - // drills into a provider form / API-key sub-list / local - // download. Tab navigation happens entirely below it and never - // hides it — it just deselects while an alternative is active. - hostedLeadCard - - if showsModeToggle { - // The Local / Your-own-key tabs (and their "or" divider) are - // the run-it-yourself alternatives. They show only at the - // top-level pickers; once the user drills in, the in-section - // Back row owns navigation within the tab. - orRunYourOwnDivider - pathSegmentedControl - } - - // Substate envelope. Clipped horizontally so the slide - // transition never bleeds into the left column, but - // vertically scaled (`y: 4`) so card hover shadows can - // escape the substate region without being trimmed at - // the scroll-area edges. - ZStack(alignment: .topLeading) { - substateContainer - .id(substateID) - .transition(substateTransition) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .clipShape(Rectangle().scale(x: 1, y: 4)) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: substateID) + // Screen envelope. Clipped horizontally so the slide transition + // never bleeds into the left column, but vertically scaled (`y: 4`) + // so card hover shadows can escape the screen region without being + // trimmed at the scroll-area edges. + ZStack(alignment: .topLeading) { + screenContainer + .id(substateID) + .transition(substateTransition) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipShape(Rectangle().scale(x: 1, y: 4)) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: substateID) } .onAppear { - state.applyDefaultPathIfNeeded(totalMemoryGB: systemMonitor.totalMemoryGB) state.ensureLocalSelection(totalMemoryGB: systemMonitor.totalMemoryGB) } } - // MARK: - Path subtitle - - /// Single neutral line for the whole step. The hosted lead card is now the - /// first element, so a per-tab subtitle above it (e.g. "runs on your Mac") - /// would contradict the card. The card copy and tab labels carry the - /// per-path nuance instead. - private var pathSubtitle: LocalizedStringKey { - "Pick the brain that powers your dino — switch any time." - } - - // MARK: - Path Segmented Control - - /// Binding that drives the shared `OnboardingSegmentedControl` while - /// preserving the side effects on `state.selectPath(_:)` (substate - /// reset, slide direction). A direct `$state.selectedPath` binding - /// would skip those. - private var pathBinding: Binding { - Binding( - get: { state.selectedPath }, - set: { state.selectPath($0) } - ) - } - - /// Paths offered on this Mac, gated by available memory (Cloud-only on - /// sub-24GB machines). Computed from the live monitor reading rather than - /// `state` so the first frame is already correct — no Local-tab flash. - private var availablePaths: [ConfigurePath] { - state.availablePaths(totalMemoryGB: systemMonitor.totalMemoryGB) - } - - /// The mode toggle is the single top-level nav: show it only on the two - /// top-level pickers, and only when there's actually a choice to make. - /// Once the user drills into the API-key hub / a form / a download, the - /// in-section "Back" row owns navigation instead. - private var showsModeToggle: Bool { - guard availablePaths.count > 1 else { return false } - switch state.selectedPath { - case .local: return state.localSubstate == .picker - case .apiProvider: return state.apiSubstate == .picker - } - } - - private var pathSegmentedControl: some View { - OnboardingSegmentedControl( - selection: pathBinding, - items: availablePaths.map { - OnboardingSegmentItem(tag: $0, title: $0.title, icon: $0.icon) - } - ) - } - - /// Thin "or" rule separating the recommended hosted lead card from the - /// run-it-yourself tabs beneath it, so the hierarchy reads as - /// "recommended default ··· alternatives". - private var orRunYourOwnDivider: some View { - HStack(spacing: 10) { - dividerRule - Text("Or run your own brain", bundle: .module) - .font(theme.font(size: 11, weight: .medium)) - .foregroundColor(theme.tertiaryText) - .fixedSize() - dividerRule - } - .padding(.vertical, 2) - } - - private var dividerRule: some View { - Rectangle() - .fill(theme.primaryBorder.opacity(0.6)) - .frame(height: 1) - .frame(maxWidth: .infinity) - } - - // MARK: - Substate dispatch + // MARK: - Screen dispatch private var substateID: String { - switch state.selectedPath { - case .local: - switch state.localSubstate { - case .picker: return "local-picker" - case .downloading: return "local-downloading" - } - case .apiProvider: + switch state.screen { + case .home: return "home" + case .downloading: return "downloading" + case .byok: switch state.apiSubstate { - case .picker: return "api-picker" - case .apiKeyPicker: return "api-key-picker" - case .keyForm(let p): return "api-key-\(p.rawValue)" - case .customForm: return "api-custom" + case .picker: return "byok-picker" + case .apiKeyPicker: return "byok-key-picker" + case .keyForm(let p): return "byok-key-\(p.rawValue)" + case .customForm: return "byok-custom" } } } /// Direction-aware horizontal slide that mirrors the global step - /// transition's vocabulary: pure offset, no opacity. Sized to the - /// substate region width so the body slides cleanly off one edge - /// while the next slides in from the opposite edge. + /// transition's vocabulary: pure offset, no opacity. Sized to the screen + /// region width so the body slides cleanly off one edge while the next + /// slides in from the opposite edge. private var substateTransition: AnyTransition { let dx = OnboardingMetrics.substateSlideOffset let inOffset = state.substateDirection == .forward ? dx : -dx @@ -896,34 +784,29 @@ struct ConfigureAIBody: View { ) } - /// Substate container — owns its own scrolling and in-section back row - /// when the user has drilled into a sub-substate (key form, custom form, - /// downloading). The segmented control above stays pinned in place. - @ViewBuilder - private var substateContainer: some View { - switch state.selectedPath { - case .local: localSubstateContainer - case .apiProvider: apiSubstateContainer - } - } - + /// Screen container — owns its own scrolling and in-section back row when + /// the user has drilled into a sub-screen (download, bring-your-own-key). @ViewBuilder - private var localSubstateContainer: some View { - switch state.localSubstate { - case .picker: - OnboardingScrollContainer { localPickerView } + private var screenContainer: some View { + switch state.screen { + case .home: + OnboardingScrollContainer { homeView } case .downloading: - substateWithBackBar(onBack: { state.popLocalToPicker() }) { + substateWithBackBar(onBack: { state.popToHome() }) { localDownloadingView } + case .byok: + byokContainer } } @ViewBuilder - private var apiSubstateContainer: some View { + private var byokContainer: some View { switch state.apiSubstate { case .picker: - OnboardingScrollContainer { apiPickerView } + substateWithBackBar(onBack: { state.popBYOKToHome() }) { + apiPickerView + } case .apiKeyPicker: substateWithBackBar(onBack: { state.popAPIKeyPickerToTop() }) { apiKeyPickerView @@ -939,10 +822,9 @@ struct ConfigureAIBody: View { } } - /// Sub-substate frame: an in-context back row (drills out to the - /// picker) followed by the substate body wrapped in the shared - /// scroll container for any overflow (key forms, custom-provider - /// form, etc.). + /// Sub-screen frame: an in-context back row (drills out to home / one level + /// up) followed by the body wrapped in the shared scroll container for any + /// overflow (key forms, custom-provider form, etc.). private func substateWithBackBar( onBack: @escaping () -> Void, @ViewBuilder content: () -> C @@ -954,7 +836,7 @@ struct ConfigureAIBody: View { } private func substateBackRow(onBack: @escaping () -> Void) -> some View { - // Always a plain "Back" — the section title was redundant breadcrumb + // Always a plain "Back" — a section title was redundant breadcrumb // noise (and truncated awkwardly, e.g. "Use an API k…"). Button(action: onBack) { HStack(spacing: 6) { @@ -973,185 +855,296 @@ struct ConfigureAIBody: View { .localizedHelp("Back") } - // MARK: - Local picker + // MARK: - Home screen - /// Top-suggestion curated models paired with their compatibility - /// verdict against the current `totalMemoryGB`. `.unknown` is treated - /// as "let through" — same fail-open behavior as - /// `ModelFilterState.PerformanceFilter.hideTooLarge`, so the list - /// isn't blank during startup before the system monitor reports. - private var topSuggestionsWithCompatibility: [(model: MLXModel, compatibility: ModelCompatibility)] { - let totalMemoryGB = systemMonitor.totalMemoryGB - return modelManager.suggestedModels - .filter(\.isTopSuggestion) - .map { ($0, $0.compatibility(totalMemoryGB: totalMemoryGB)) } + private var homeView: some View { + VStack(spacing: 12) { + runOnYourMacCard + wantMorePowerDivider + osaurusCloudCard + useYourOwnKeyRow + } } - /// What the local picker renders: the curated top suggestions only. - /// - /// Onboarding is intentionally opinionated — it surfaces only our curated - /// top picks (downloaded ones still appear, badged "Downloaded"), so the - /// first-run list never balloons with ad-hoc / auto-fetched models the - /// user happens to have on disk. The full catalog lives in the Models tab. - private var localPickerModels: [(model: MLXModel, compatibility: ModelCompatibility)] { - topSuggestionsWithCompatibility + // MARK: Run on your Mac (recommended, local) + + /// The recommended local card. Tapping the upper region selects the local + /// brain; the model inset's "Change" control opens the model popover. + private var runOnYourMacCard: some View { + OnboardingGlassCard(isSelected: !state.isHostedSelected) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 14) { + localBrainIcon + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Run on your Mac", bundle: .module) + .font(theme.font(size: 14, weight: .semibold)) + .foregroundColor(theme.primaryText) + .lineLimit(1) + .layoutPriority(2) + recommendedBadge + Spacer(minLength: 8) + } + Text( + "Free, private, and works offline. Uses some memory while it runs.", + bundle: .module + ) + .font(theme.font(size: 12)) + .foregroundColor(theme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + selectionRadio(!state.isHostedSelected) + } + .contentShape(Rectangle()) + .onTapGesture { state.selectLocalBrain() } + + localModelInset + } + .padding(.horizontal, OnboardingMetrics.cardPaddingH) + .padding(.vertical, OnboardingMetrics.cardPaddingV) + } + .animation(.spring(response: 0.35, dampingFraction: 0.9), value: state.isHostedSelected) } + /// Leading accent badge for the local card, mirroring `OnboardingRowCard`'s + /// selected-icon treatment (accent fill + glow when selected). + private var localBrainIcon: some View { + ZStack { + if !state.isHostedSelected { + Circle() + .fill(theme.accentColor) + .blur(radius: 8) + .frame( + width: OnboardingMetrics.cardIcon - 8, + height: OnboardingMetrics.cardIcon - 8 + ) + } + Circle() + .fill(!state.isHostedSelected ? theme.accentColor : theme.cardBackground) + .frame(width: OnboardingMetrics.cardIcon, height: OnboardingMetrics.cardIcon) + Image(systemName: "cpu") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(!state.isHostedSelected ? .white : theme.secondaryText) + } + } + + /// The selected-model chip under the local card body: model name + a + /// Downloaded / size badge + a "Change" control that opens the model dialog. + private var localModelInset: some View { + HStack(spacing: 8) { + Text(state.selectedModel?.simplifiedName ?? L("Choose a model")) + .font(theme.font(size: 13, weight: .semibold)) + .foregroundColor(theme.primaryText) + .lineLimit(1) + localInsetPrecisionBadge + localInsetBadge + Spacer(minLength: 8) + changeButton + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(theme.tertiaryBackground) + ) + } + + /// The friendly precision tag (e.g. "High precision") for the selected + /// model, mirroring the chip in the chooser so the card matches what the + /// user tapped — `simplifiedName` alone can't tell two same-size builds + /// apart. Reuses `OnboardingBadgeChip` so the styling can't drift from the + /// chooser's chips. @ViewBuilder - private var localPickerView: some View { - let pairs = localPickerModels - if !pairs.isEmpty { - // Be opinionated: surface a single recommended pick (the model - // `ensureLocalSelection` chose) and tuck everything else behind a - // disclosure, so first-run isn't a wall of model choices. - let featuredId = state.selectedModel?.id - let featured = pairs.first(where: { $0.model.id == featuredId }) ?? pairs.first - let rest = pairs.filter { $0.model.id != featured?.model.id } + private var localInsetPrecisionBadge: some View { + if let model = state.selectedModel, let precision = onboardingPrecisionTag(for: model) { + OnboardingBadgeChip(badge: OnboardingRowBadge(precision)) + } + } - VStack(spacing: OnboardingMetrics.cardSpacing) { - computeIntensiveCallout - if let featured { - localModelCard(for: featured) - } - if !rest.isEmpty { - localMoreOptionsDisclosure(count: rest.count) - if state.showAllLocalModels { - ForEach(rest, id: \.model.id) { pair in - localModelCard(for: pair) + @ViewBuilder + private var localInsetBadge: some View { + if let model = state.selectedModel { + if model.isDownloaded { + OnboardingBadgeChip(badge: OnboardingRowBadge(L("Downloaded"), style: .success)) + } else if let size = model.formattedDownloadSize { + OnboardingBadgeChip(badge: OnboardingRowBadge(size)) + } + } + } + + private var changeButton: some View { + Button { + state.openModelChooser() + } label: { + Text("Change", bundle: .module) + .font(theme.font(size: 12, weight: .semibold)) + .foregroundColor(theme.accentColor) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .localizedHelp("Change model") + } + + // MARK: Divider + + /// Thin "Want more power?" rule separating the recommended local card from + /// the upgrade options beneath it. + private var wantMorePowerDivider: some View { + HStack(spacing: 10) { + dividerRule + Text("Want more power?", bundle: .module) + .font(theme.font(size: 11, weight: .medium)) + .foregroundColor(theme.tertiaryText) + .fixedSize() + dividerRule + } + .padding(.vertical, 2) + } + + private var dividerRule: some View { + Rectangle() + .fill(theme.primaryBorder.opacity(0.6)) + .frame(height: 1) + .frame(maxWidth: .infinity) + } + + // MARK: Osaurus Cloud (secondary, hosted) + + private var osaurusCloudCard: some View { + Button { + state.selectHostedOsaurus() + } label: { + OnboardingGlassCard(isSelected: state.isHostedSelected) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 14) { + cloudIcon + VStack(alignment: .leading, spacing: 6) { + Text("Osaurus Cloud", bundle: .module) + .font(theme.font(size: 14, weight: .semibold)) + .foregroundColor(theme.primaryText) + .lineLimit(1) + Text( + "Frontier models like Claude, ChatGPT, and more. No setup or API key. Pay as you go.", + bundle: .module + ) + .font(theme.font(size: 12)) + .foregroundColor(theme.secondaryText) + .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 8) + selectionRadio(state.isHostedSelected) } + poweredByVeniceLine + .padding(.leading, OnboardingMetrics.cardIcon + 14) } + .padding(.horizontal, OnboardingMetrics.cardPaddingH) + .padding(.vertical, OnboardingMetrics.cardPaddingV) } } + .buttonStyle(.plain) + .animation(.spring(response: 0.35, dampingFraction: 0.9), value: state.isHostedSelected) } - /// One selectable local-model row. Shared by the featured default and the - /// disclosure-revealed remainder so both read identically. - private func localModelCard( - for pair: (model: MLXModel, compatibility: ModelCompatibility) - ) -> some View { - let model = pair.model - return OnboardingRowCard( - icon: .symbol(model.isVLM ? "eye" : "cpu"), - title: model.name, - subtitle: model.description, - secondaryLine: model.formattedReleaseMonth.map { L("Released \($0)") }, - badges: localBadges(for: model, compatibility: pair.compatibility), - // Local model rows ship up to four badges - // (use case · size · modality · compat verdict); - // inline next to the title they truncated the - // model name to "Gemm…". Bump them to their own - // row so the full name is always readable. - badgesBelowTitle: true, - accessory: .radio( - isSelected: !state.isHostedSelected && state.selectedModel?.id == model.id - ), - isSelected: !state.isHostedSelected && state.selectedModel?.id == model.id, - isDisabled: false - ) { - // No `withAnimation` — selecting a model otherwise - // morphs the CTA between "Continue" and - // "Download & Install" as a side-effect of the - // shared transaction. Tapping a model also supersedes - // the Osaurus Cloud lead card pinned above the tabs. - state.selectLocalModel(model) + private var cloudIcon: some View { + ZStack { + Circle() + .fill(state.isHostedSelected ? theme.accentColor : theme.cardBackground) + .frame(width: OnboardingMetrics.cardIcon, height: OnboardingMetrics.cardIcon) + Image(systemName: "cloud") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(state.isHostedSelected ? .white : theme.secondaryText) + } + } + + /// "Powered by Venice" attribution with the privacy posture, prefixed with a + /// lock glyph so the zero-data-retention claim reads as the trust anchor it + /// is. + private var poweredByVeniceLine: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "lock") + .font(.system(size: 11)) + .foregroundColor(theme.tertiaryText) + Text( + "Powered by Venice — zero data retention. Privacy first.", + bundle: .module + ) + .font(theme.font(size: 11)) + .foregroundColor(theme.tertiaryText) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) } } - /// Expand / collapse control for the non-featured local models. - private func localMoreOptionsDisclosure(count: Int) -> some View { + // MARK: Use your own key (BYOK entry) + + /// Quiet single-line drill-in to the bring-your-own-key flow. + private var useYourOwnKeyRow: some View { Button { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - state.showAllLocalModels.toggle() - } + state.showBYOK() } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.down") - .font(.system(size: 11, weight: .semibold)) - .rotationEffect(.degrees(state.showAllLocalModels ? 180 : 0)) - Text( - state.showAllLocalModels - ? L("Hide other options") - : L("See other options (\(count))") - ) - .font(theme.font(size: 13, weight: .semibold)) - Spacer(minLength: 0) + OnboardingGlassCard { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(theme.cardBackground) + .frame(width: 32, height: 32) + Image(systemName: "key.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(theme.secondaryText) + } + Text("Already pay for AI? Use your own key", bundle: .module) + .font(theme.font(size: 13, weight: .medium)) + .foregroundColor(theme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(theme.tertiaryText) + } + .padding(.horizontal, OnboardingMetrics.cardPaddingH) + .padding(.vertical, 12) } - .foregroundColor(theme.accentColor) - .padding(.vertical, 6) - .padding(.horizontal, 2) - .contentShape(Rectangle()) } .buttonStyle(.plain) - .localizedHelp("See other options") } - /// Inline explainer rendered above the curated list — first-time - /// users don't realize local models actually run on their Mac, so - /// we set the RAM / latency / offline expectation up front rather - /// than burying it in the model detail view. - private var computeIntensiveCallout: some View { - OnboardingGlassCard { - HStack(spacing: 10) { - ZStack { - Circle() - .fill(theme.accentColor.opacity(0.14)) - .frame(width: 28, height: 28) - Image(systemName: "cpu") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(theme.accentColor) - } - Text( - "Local brains live on your Mac. They use a chunk of memory while running, and they keep working offline.", - bundle: .module + // MARK: Shared selection radio + + private func selectionRadio(_ isSelected: Bool) -> some View { + ZStack { + Circle() + .strokeBorder( + isSelected ? theme.accentColor : theme.primaryBorder, + lineWidth: isSelected ? 6 : 1.5 ) - .font(theme.font(size: 11)) - .foregroundColor(theme.secondaryText) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) + .frame(width: 20, height: 20) + if isSelected { + Circle().fill(Color.white).frame(width: 7, height: 7) } - .padding(.horizontal, 12) - .padding(.vertical, 10) } } - /// Order: use-case category (leading scannable signal) → status / - /// size → modality → capability verdict (trailing, near the - /// accessory where the eye lands to evaluate the row). - private func localBadges( - for model: MLXModel, - compatibility: ModelCompatibility - ) -> [OnboardingRowBadge] { - var result: [OnboardingRowBadge] = [] - if let useCase = model.useCase { - result.append(.useCase(useCase)) - } - if model.isDownloaded { - result.append(OnboardingRowBadge(L("Downloaded"), style: .success)) - } else if let size = model.formattedDownloadSize { - result.append(OnboardingRowBadge(size)) - } - result.append(OnboardingRowBadge(model.isVLM ? "VLM" : "LLM")) - switch compatibility { - case .tight: - result.append(OnboardingRowBadge(L("Tight fit"), style: .warning)) - case .tooLarge: - result.append(OnboardingRowBadge(L("Too large for this Mac"), style: .error)) - case .compatible, .unknown: - break - } - return result + // MARK: "Recommended" badge + + /// "Recommended" pill shown beside the local card title. + private var recommendedBadge: some View { + Text("Recommended", bundle: .module) + .font(theme.font(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(theme.accentColor)) } // MARK: - Local downloading - /// State-driven downloading view. Renders one of two layouts - /// depending on the live `localDownloadState`: - /// - `.downloading` / `.paused` (or initial): progress card with - /// inline Pause / Resume / Cancel controls. - /// - `.failed`: inline error card with Retry and - /// Choose-another-model actions, so the user always has a path - /// forward without a disabled Continue button. + /// State-driven downloading view. Renders one of two layouts depending on + /// the live `localDownloadState`: + /// - `.downloading` / `.paused` (or initial): progress card with inline + /// Pause / Resume / Cancel controls. + /// - `.failed`: inline error card with Retry and Choose-another-model + /// actions, so the user always has a path forward without a disabled + /// Continue button. @ViewBuilder private var localDownloadingView: some View { if case .failed(let message) = state.localDownloadState { @@ -1223,8 +1216,8 @@ struct ConfigureAIBody: View { } /// Pause / Resume + Cancel inline controls — keep the Continue CTA below - /// for "Continue when done", but give the user immediate, visible - /// control over the in-flight download so they're never stuck (issue + /// for "Continue when done", but give the user immediate, visible control + /// over the in-flight download so they're never stuck (issue /// [#1071](https://github.com/osaurus-ai/osaurus/issues/1071)). @ViewBuilder private var inlineDownloadControls: some View { @@ -1276,9 +1269,9 @@ struct ConfigureAIBody: View { .help(Text(help)) } - /// Inline failure card with Try again / Choose another model - /// actions, so the user always has a clear path forward without - /// the chrome dead-ending into a disabled Continue button. + /// Inline failure card with Try again / Choose another model actions, so + /// the user always has a clear path forward without the chrome dead-ending + /// into a disabled Continue button. private func localDownloadFailedCard(message: String) -> some View { OnboardingGlassCard { VStack(alignment: .leading, spacing: 12) { @@ -1308,7 +1301,7 @@ struct ConfigureAIBody: View { OnboardingCompactButton( title: "Choose another model", style: .ghost, - action: { state.popLocalToPicker() } + action: { state.popToHome() } ) OnboardingCompactButton( title: "Try again", @@ -1369,10 +1362,9 @@ struct ConfigureAIBody: View { // MARK: - API picker - /// The bring-your-own-key tab body: OAuth-first sign-in rows plus the "Use - /// an API key" drill-in. The managed Osaurus Cloud option is no longer here — - /// it leads the step as a pinned card above the tabs — and the tab itself is - /// now labeled "Your own key", so an in-list group header would be redundant. + /// The bring-your-own-key body: OAuth-first sign-in rows plus the "Use an + /// API key" drill-in. The managed Osaurus Cloud option is not here — it + /// leads the home screen as a card. private var apiPickerView: some View { VStack(alignment: .leading, spacing: OnboardingMetrics.cardSpacing) { ForEach(ProviderPreset.oauthProviders, id: \.id) { preset in @@ -1382,113 +1374,6 @@ struct ConfigureAIBody: View { } } - // MARK: - Hosted lead card - - /// The recommended managed-cloud option, pinned above the tabs and selected - /// by default. Always rendered at full size — selection only changes the - /// visual treatment (radio, accent icon, card border), never the layout — so - /// the card never resizes as the user selects/deselects it while browsing the - /// run-it-yourself alternatives. Routes through Venice via the managed - /// Osaurus Router; selecting it is payment-free (the Continue CTA advances). - private var hostedLeadCard: some View { - Button { - state.selectHostedOsaurus() - } label: { - OnboardingGlassCard(isSelected: state.isHostedSelected) { - hostedLeadCardBody - .padding(.horizontal, OnboardingMetrics.cardPaddingH) - .padding(.vertical, OnboardingMetrics.cardPaddingV) - } - } - .buttonStyle(.plain) - .animation(.spring(response: 0.35, dampingFraction: 0.9), value: state.isHostedSelected) - } - - /// The hosted card's full body. Rendered in every selection state so the card - /// keeps a constant size; only `hostedIcon` / `hostedRadio` / the glass border - /// reflect whether it's currently the active selection. - private var hostedLeadCardBody: some View { - let option = HostedOption.default - return VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 14) { - hostedIcon - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text("Osaurus Cloud", bundle: .module) - .font(theme.font(size: 14, weight: .semibold)) - .foregroundColor(theme.primaryText) - .lineLimit(1) - .layoutPriority(2) - recommendedBadge - Spacer(minLength: 8) - } - Text(option.valueLine, bundle: .module) - .font(theme.font(size: 12)) - .foregroundColor(theme.secondaryText) - .fixedSize(horizontal: false, vertical: true) - Text(option.privacyTier.privacyLine, bundle: .module) - .font(theme.font(size: 11)) - .foregroundColor(theme.tertiaryText) - .lineSpacing(2) - .fixedSize(horizontal: false, vertical: true) - } - hostedRadio - } - poweredByVenice - .padding(.leading, OnboardingMetrics.cardIcon + 14) - } - } - - /// Leading accent badge for the hosted card, mirroring `OnboardingRowCard`'s - /// selected-icon treatment. - private var hostedIcon: some View { - ZStack { - Circle() - .fill(state.isHostedSelected ? theme.accentColor : theme.cardBackground) - .frame(width: OnboardingMetrics.cardIcon, height: OnboardingMetrics.cardIcon) - Image(systemName: "sparkles") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(state.isHostedSelected ? .white : theme.secondaryText) - } - } - - /// "Recommended" pill shown beside the hosted title. - private var recommendedBadge: some View { - Text("Recommended", bundle: .module) - .font(theme.font(size: 10, weight: .bold)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(theme.accentColor)) - } - - /// Pick-one radio, matching the `OnboardingRowCard` radio accessory. - private var hostedRadio: some View { - ZStack { - Circle() - .strokeBorder( - state.isHostedSelected ? theme.accentColor : theme.primaryBorder, - lineWidth: state.isHostedSelected ? 6 : 1.5 - ) - .frame(width: 20, height: 20) - if state.isHostedSelected { - Circle().fill(Color.white).frame(width: 7, height: 7) - } - } - } - - /// "Powered by Venice" attribution. Uses the shared `venice-keys` asset via - /// `ProviderIcon`, sized smaller than the Osaurus title so the hosted brand - /// leads and Venice reads as the upstream. - private var poweredByVenice: some View { - HStack(spacing: 6) { - ProviderIcon(preset: .venice, size: 13, color: theme.tertiaryText) - Text("Powered by Venice", bundle: .module) - .font(theme.font(size: 11, weight: .medium)) - .foregroundColor(theme.tertiaryText) - } - } - /// Drill-in entry to the grouped API-key sub-list. Titled "Use an API key" /// even though it also houses Ollama (local) and Custom, because API-key /// vendors are the dominant case; the sub-list section headers disambiguate. @@ -1545,7 +1430,7 @@ struct ConfigureAIBody: View { return L("Custom / OpenAI-compatible") case .venice: // Disambiguate the bring-your-own-key Venice row from the hosted - // lead card (which also routes through Venice). Onboarding-local — + // cloud card (which also routes through Venice). Onboarding-local — // Settings keeps the plain `Venice AI` name. return L("Venice (your key)") default: @@ -1814,15 +1699,291 @@ struct ConfigureAIBody: View { } } +/// Friendly precision/variant tag for the onboarding model surfaces. The +/// chooser strips precision tokens from the title for a clean, product-style +/// name (`MLXModel.simplifiedName`); this chip re-surfaces the meaningful +/// difference so same-size variants (e.g. the two "Gemma 4 12B" builds) stay +/// distinguishable. Returns `nil` when no precision marker is recognized. Order +/// matters: `qat` is checked before the bare 4-bit branch (QAT builds are +/// MXFP4), and the high-precision 8-bit branch before the 4-bit one. +private func onboardingPrecisionTag(for model: MLXModel) -> String? { + let lower = model.name.lowercased() + if lower.contains("qat") { return L("Efficient (QAT)") } + if lower.contains("mxfp8") || lower.contains("8bit") { return L("High precision") } + if lower.contains("mxfp4") || lower.contains("4bit") { return L("Efficient") } + if lower.contains("fp16") || lower.contains("bf16") || lower.contains("fp32") { + return L("Full precision") + } + if lower.contains("jangtq") || lower.contains("jang") { return L("TurboQuant") } + return nil +} + +// MARK: - Model chooser modal + +/// Centered "Choose your model" dialog, hosted at the OnboardingView window +/// root over a dimmed scrim. It replaces the old floating "Change" popover, +/// which crammed a tall scrolling list into the small, clipped body region — +/// overflowing the window and covering the footer CTA. +/// +/// Forgiving draft-then-confirm so brand-new users can browse without +/// committing: tapping a row only highlights it (`state.draftModel`); "Use this +/// model" commits, while Cancel / X / Esc / scrim-tap dismiss without touching +/// the active selection. Copy and badges are written for first-timers — no +/// `LLM`/`VLM` jargon, a clear "Recommended" tag, and a plain-language memory +/// hint. +struct ConfigureModelChooserModal: View { + @ObservedObject var state: ConfigureAIState + + @Environment(\.theme) private var theme + @ObservedObject private var modelManager = ModelManager.shared + + /// See `ConfigureAIBody.systemMonitor`: a plain reference (not an + /// `@ObservedObject`) so the dialog doesn't re-render on every 2s + /// CPU/memory publish — `totalMemoryGB` is constant for the session. + private let systemMonitor = SystemMonitorService.shared + + private let dialogWidth: CGFloat = 520 + private let dialogCornerRadius: CGFloat = 20 + + var body: some View { + ZStack { + scrim + dialog + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // Esc closes the dialog without changing the selection. + .onExitCommand { state.cancelModelChooser() } + } + + // MARK: Scrim + + /// Dims the whole step behind the dialog and acts as a tap-to-cancel + /// target, so a background click reads as "never mind". + private var scrim: some View { + Color.black.opacity(0.3) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { state.cancelModelChooser() } + } + + // MARK: Dialog + + private var dialog: some View { + VStack(spacing: 0) { + header + hairline + modelList + hairline + footer + } + .frame(width: dialogWidth) + .background(dialogSurface) + .clipShape(RoundedRectangle(cornerRadius: dialogCornerRadius, style: .continuous)) + .overlay(dialogBorder) + .shadow(color: theme.shadowColor.opacity(0.28), radius: 30, y: 14) + .transition(.scale(scale: 0.96).combined(with: .opacity)) + } + + private var dialogSurface: some View { + ZStack { + if theme.glassEnabled { + Rectangle().fill(.ultraThinMaterial) + } + theme.cardBackground.opacity( + theme.glassEnabled + ? (theme.isDark + ? OnboardingStyle.glassOpacityDark + : OnboardingStyle.glassOpacityLight) + : 1.0 + ) + } + } + + private var dialogBorder: some View { + RoundedRectangle(cornerRadius: dialogCornerRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [ + theme.glassEdgeLight.opacity(theme.isDark ? 0.35 : 0.6), + theme.primaryBorder.opacity(theme.isDark ? 0.4 : 0.5), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + } + + private var hairline: some View { + Rectangle() + .fill(theme.primaryBorder.opacity(0.18)) + .frame(height: 1) + } + + // MARK: Header + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Choose your model", bundle: .module) + .font(theme.font(size: 18, weight: .semibold)) + .foregroundColor(theme.primaryText) + Text( + "Every model here runs privately on your Mac. Not sure? Start with the recommended one — you can switch anytime.", + bundle: .module + ) + .font(theme.font(size: 12)) + .foregroundColor(theme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(2) + } + Spacer(minLength: 8) + OnboardingCloseButton { state.cancelModelChooser() } + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 14) + } + + // MARK: List + + private var modelList: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: OnboardingMetrics.cardSpacing) { + ForEach(pickerModels, id: \.model.id) { pair in + OnboardingRowCard( + icon: .symbol(pair.model.isVLM ? "eye" : "cpu"), + title: pair.model.simplifiedName, + subtitle: pair.model.description, + badges: badges(for: pair.model, compatibility: pair.compatibility), + badgesBelowTitle: true, + accessory: .radio(isSelected: isDraftSelected(pair.model)), + isSelected: isDraftSelected(pair.model) + ) { + state.selectDraftModel(pair.model) + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + .frame(maxHeight: 380) + } + + // MARK: Footer + + private var footer: some View { + VStack(spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .medium)) + Text("Bigger models are smarter but use more memory.", bundle: .module) + .font(theme.font(size: 11)) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(theme.tertiaryText) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + Spacer(minLength: 0) + OnboardingCompactButton(title: "Cancel", style: .ghost) { + state.cancelModelChooser() + } + OnboardingBrandButton( + title: "Use this model", + action: { state.commitModelChooser() }, + isEnabled: state.draftModel != nil + ) + .frame(width: 190) + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 18) + } + + // MARK: Catalog (modal-local) + + /// Top-suggestion curated models paired with their compatibility verdict + /// against the current `totalMemoryGB`. `.unknown` is treated as "let + /// through" (fail-open) so the list isn't blank during startup before the + /// system monitor reports. + private var topSuggestionsWithCompatibility: [(model: MLXModel, compatibility: ModelCompatibility)] { + let totalMemoryGB = systemMonitor.totalMemoryGB + return modelManager.suggestedModels + .filter(\.isTopSuggestion) + .map { ($0, $0.compatibility(totalMemoryGB: totalMemoryGB)) } + } + + /// Onboarding is intentionally opinionated — it surfaces only our curated + /// top picks (downloaded ones still appear, badged "Downloaded"), so the + /// first-run list never balloons with ad-hoc / auto-fetched models on disk. + /// The full catalog lives in the Models tab. + private var pickerModels: [(model: MLXModel, compatibility: ModelCompatibility)] { + topSuggestionsWithCompatibility + } + + /// The single model the funnel recommends for this Mac — gets the + /// "Recommended" pill so a first-timer has an obvious safe default. + private var recommendedModelId: String? { + ConfigureAIState.recommendedLocalPick( + from: modelManager.suggestedModels.filter(\.isTopSuggestion), + totalMemoryGB: systemMonitor.totalMemoryGB + )?.id + } + + private func isDraftSelected(_ model: MLXModel) -> Bool { + state.draftModel?.id == model.id + } + + /// Friendlier than the inline card badges: leads with a clear `Recommended` + /// pill for first-timers, keeps the use-case category and a Downloaded/size + /// chip, and surfaces the capability warnings — but drops the `LLM`/`VLM` + /// jargon (the eye/cpu icon already signals modality). + private func badges( + for model: MLXModel, + compatibility: ModelCompatibility + ) -> [OnboardingRowBadge] { + var result: [OnboardingRowBadge] = [] + if model.id == recommendedModelId { + result.append(OnboardingRowBadge(L("Recommended"), style: .accent)) + } + if let useCase = model.useCase { + result.append(.useCase(useCase)) + } + // Re-surface the precision the title dropped (e.g. "High precision" vs + // "Efficient (QAT)") so same-size variants stay distinguishable. + if let precision = onboardingPrecisionTag(for: model) { + result.append(OnboardingRowBadge(precision)) + } + if model.isDownloaded { + result.append(OnboardingRowBadge(L("Downloaded"), style: .success)) + } else if let size = model.formattedDownloadSize { + result.append(OnboardingRowBadge(size)) + } + switch compatibility { + case .tight: + result.append(OnboardingRowBadge(L("Tight fit"), style: .warning)) + case .tooLarge: + result.append(OnboardingRowBadge(L("Too large for this Mac"), style: .error)) + case .compatible, .unknown: + break + } + return result + } +} + // MARK: - CTA -/// Primary CTA for the Configure AI step, dispatched per substate: -/// - Local picker: Download/Continue, enabled once a model is selected. -/// - Local downloading: a single adaptive "Continue in Background" → -/// "Continue" button (plus "Try Again" on failure). -/// - Cloud picker / API-key hub: cards drill in on tap, so a quiet hint +/// Primary CTA for the Configure AI step, dispatched per screen: +/// - Home (local selected): Download & Install / Continue, enabled once a +/// model is selected. +/// - Home (hosted selected): Continue (commits the hosted brain). +/// - Downloading: a single adaptive "Continue in Background" → "Continue" +/// button (plus "Try Again" on failure). +/// - BYOK picker / API-key hub: cards drill in on tap, so a quiet hint /// stands in for the (absent) Continue button. -/// - Cloud forms: the stateful Connect/Test/Continue button. +/// - BYOK forms: the stateful Connect/Test/Continue button. struct ConfigureAICTA: View { @ObservedObject var state: ConfigureAIState let onComplete: () -> Void @@ -1830,17 +1991,17 @@ struct ConfigureAICTA: View { @Environment(\.theme) private var theme /// Observed-but-not-read: the CTA's `isLocalCompleted` / `isLocalFailed` - /// reads bounce through `ConfigureAIState`, but those computed - /// properties pull live values out of `ModelManager.shared` rather - /// than out of any `@Published` on `state`. Without this observer the - /// CTA wouldn't refresh from "Continue (disabled)" → "Continue - /// (enabled)" when the download finishes. + /// reads bounce through `ConfigureAIState`, but those computed properties + /// pull live values out of `ModelManager.shared` rather than out of any + /// `@Published` on `state`. Without this observer the CTA wouldn't refresh + /// from "Continue (disabled)" → "Continue (enabled)" when the download + /// finishes. @ObservedObject private var modelManager = ModelManager.shared var body: some View { primaryButton .onChange(of: state.isLocalCompleted) { _, completed in - if completed && state.localSubstate == .downloading { + if completed && state.screen == .downloading { onComplete() } } @@ -1866,58 +2027,42 @@ struct ConfigureAICTA: View { @ViewBuilder private var primaryButton: some View { - // Osaurus Cloud is selectable from above either tab, so its Continue CTA - // takes precedence at the top-level pickers. The flag only clears when - // the user commits to a local model / provider (a drilled-in substate), - // so this never shadows a form's own action. - if state.isHostedSelected && isTopLevelPicker { - OnboardingBrandButton( - title: "Continue", - action: { state.selectHostedAndContinue(onComplete: onComplete) } - ) - .fixedSize(horizontal: true, vertical: false) - } else { - switch state.selectedPath { - case .local: - switch state.localSubstate { - case .picker: - OnboardingBrandButton( - title: state.selectedModel?.isDownloaded == true ? "Continue" : "Download & Install", - action: { state.startLocalDownloadOrContinue(onComplete: onComplete) }, - isEnabled: state.selectedModel != nil - ) - .fixedSize(horizontal: true, vertical: false) - case .downloading: - localDownloadingCTA - } - - case .apiProvider: - switch state.apiSubstate { - case .picker, .apiKeyPicker: - // Provider cards drill in on tap — no Continue press - // required. A subtle hint replaces the dead disabled button - // so the footer reads as guidance, not a broken control. - providerPickerHint - case .keyForm, .customForm: - apiActionButton - } + switch state.screen { + case .home: + if state.isHostedSelected { + OnboardingBrandButton( + title: "Continue", + action: { state.selectHostedAndContinue(onComplete: onComplete) } + ) + .fixedSize(horizontal: true, vertical: false) + } else { + OnboardingBrandButton( + title: state.selectedModel?.isDownloaded == true ? "Continue" : "Download & Install", + action: { state.startLocalDownloadOrContinue(onComplete: onComplete) }, + isEnabled: state.selectedModel != nil + ) + .fixedSize(horizontal: true, vertical: false) } - } - } - /// True at either top-level picker (Local model list / Your-own-key list), - /// where the pinned Osaurus Cloud card is visible and owns the footer CTA - /// while selected. False once drilled into a form / sub-list / download. - private var isTopLevelPicker: Bool { - switch state.selectedPath { - case .local: return state.localSubstate == .picker - case .apiProvider: return state.apiSubstate == .picker + case .downloading: + localDownloadingCTA + + case .byok: + switch state.apiSubstate { + case .picker, .apiKeyPicker: + // Provider cards drill in on tap — no Continue press required. + // A subtle hint replaces the dead disabled button so the footer + // reads as guidance, not a broken control. + providerPickerHint + case .keyForm, .customForm: + apiActionButton + } } } - /// Footer text shown on the Cloud provider list / API-key hub, where the - /// cards themselves are the action. A quiet hint reads better than a dead - /// disabled "Continue". + /// Footer text shown on the bring-your-own-key provider list / API-key hub, + /// where the cards themselves are the action. A quiet hint reads better than + /// a dead disabled "Continue". private var providerPickerHint: some View { Text("Pick a provider to continue", bundle: .module) .font(theme.font(size: OnboardingMetrics.captionSize)) @@ -1926,10 +2071,10 @@ struct ConfigureAICTA: View { } /// CTA for the local downloading screen. Mirrors the inline state-driven - /// downloading view: while the download is in flight or paused, the - /// CTA is disabled and the inline Pause/Resume/Cancel controls own the - /// action surface. On failure the CTA flips to a "Try Again" button so - /// the user always has a path forward — issue [#1071](https://github.com/osaurus-ai/osaurus/issues/1071). + /// downloading view: while the download is in flight or paused, the CTA is + /// disabled and the inline Pause/Resume/Cancel controls own the action + /// surface. On failure the CTA flips to a "Try Again" button so the user + /// always has a path forward — issue [#1071](https://github.com/osaurus-ai/osaurus/issues/1071). @ViewBuilder private var localDownloadingCTA: some View { if state.isLocalFailed { diff --git a/Packages/OsaurusCore/Views/Onboarding/OnboardingView.swift b/Packages/OsaurusCore/Views/Onboarding/OnboardingView.swift index 777c05413..536286828 100644 --- a/Packages/OsaurusCore/Views/Onboarding/OnboardingView.swift +++ b/Packages/OsaurusCore/Views/Onboarding/OnboardingView.swift @@ -87,8 +87,19 @@ public struct OnboardingView: View { body: { bodySlot }, cta: { ctaSlot } ) + + // Hosted at the window root (above the chrome, not inside the + // clipped body) so the "Choose your model" dialog can dim the whole + // step and center over it — the previous popover overflowed the body + // region and covered the footer CTA. + if currentStep == .configureAI && configureAIState.isChoosingModel { + ConfigureModelChooserModal(state: configureAIState) + .transition(.opacity) + .zIndex(2) + } } .frame(width: OnboardingMetrics.windowWidth, height: OnboardingMetrics.windowHeight) + .animation(theme.springAnimation(), value: configureAIState.isChoosingModel) .onAppear { onPreferredSizeChange?( CGSize( @@ -517,6 +528,17 @@ public struct OnboardingView: View { configureAIState.selectedBrainSource?.telemetryValue ) + // Gate the managed Osaurus Router on an explicit Cloud choice. The + // router defaults on (`OsaurusRouter.isEnabled`), so without this it + // would be injected for everyone — including local / bring-your-own-key + // users who never opted into hosted routing. Enable it only when the + // user picked Osaurus Cloud; otherwise turn it off (reversible later via + // the Credits/Dashboard toggle). Must run before `pinSelectedBrainModel` + // so the hosted path's `connectOsaurusRouterIfPossible` sees it enabled. + RemoteProviderManager.shared.setOsaurusRouterEnabled( + configureAIState.selectedBrainSource == .hostedOsaurus + ) + // Pin the new/active agent's default model to the brain the user chose // on the Configure AI step, so the first chat respects their selection. pinSelectedBrainModel() diff --git a/Packages/OsaurusCore/Views/Settings/AgentChannelConnectionCenterView.swift b/Packages/OsaurusCore/Views/Settings/AgentChannelConnectionCenterView.swift index b9d09bff2..7af335cdd 100644 --- a/Packages/OsaurusCore/Views/Settings/AgentChannelConnectionCenterView.swift +++ b/Packages/OsaurusCore/Views/Settings/AgentChannelConnectionCenterView.swift @@ -228,7 +228,8 @@ struct AgentChannelConnectionCenterView: View { SettingsToggle( title: "Enable Writes", - description: "Permit send and reply actions only for write-allowlisted rooms. Tool calls still require confirmation.", + description: + "Permit send and reply actions only for write-allowlisted rooms. Tool calls still require confirmation.", isOn: $draft.writeEnabled ) @@ -302,7 +303,7 @@ struct AgentChannelConnectionCenterView: View { LazyVGrid( columns: [ - GridItem(.adaptive(minimum: 150), spacing: 8), + GridItem(.adaptive(minimum: 150), spacing: 8) ], alignment: .leading, spacing: 8 @@ -338,12 +339,14 @@ struct AgentChannelConnectionCenterView: View { label: "Base URL", text: $draft.customBaseURL, placeholder: "https://hooks.example.test", - help: "HTTP or HTTPS origin for this configured channel. Execution remains disabled until the security-reviewed runner lands." + help: + "HTTP or HTTPS origin for this configured channel. Execution remains disabled until the security-reviewed runner lands." ) multilineField( title: "Action Map JSON", text: $draft.customActionsJSON, - help: "JSON object keyed by standard action names. Values define method, path, optional query, headers, and bodyTemplate." + help: + "JSON object keyed by standard action names. Values define method, path, optional query, headers, and bodyTemplate." ) } } @@ -426,7 +429,8 @@ struct AgentChannelConnectionCenterView: View { private func reloadConnections() { connections = manager.editableConnections() if let selectedConnectionId, - let selected = connections.first(where: { $0.id == selectedConnectionId }) { + let selected = connections.first(where: { $0.id == selectedConnectionId }) + { draft = AgentChannelConnectionDraft(connection: selected) } else if let first = connections.first { select(first) @@ -474,7 +478,7 @@ struct AgentChannelConnectionCenterView: View { let connectionId = draft.id.trimmingCharacters(in: .whitespacesAndNewlines) guard !connectionId.isEmpty else { return } guard let originalId = draft.originalId, - AgentChannelConnection.normalizedId(connectionId) == originalId + AgentChannelConnection.normalizedId(connectionId) == originalId else { showStatus("Save the channel connection before running diagnostics", isError: true) return @@ -508,11 +512,11 @@ struct AgentChannelConnectionCenterView: View { private static func prettyJSON(_ payload: [String: Any]) -> String { guard JSONSerialization.isValidJSONObject(payload), - let data = try? JSONSerialization.data( - withJSONObject: payload, - options: [.prettyPrinted, .sortedKeys] - ), - let string = String(data: data, encoding: .utf8) + let data = try? JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys] + ), + let string = String(data: data, encoding: .utf8) else { return String(describing: payload) } @@ -638,7 +642,11 @@ private struct StatusMessageView: View { .padding(10) .background( RoundedRectangle(cornerRadius: 8) - .fill((isError ? themeManager.currentTheme.warningColor : themeManager.currentTheme.successColor).opacity(0.08)) + .fill( + (isError ? themeManager.currentTheme.warningColor : themeManager.currentTheme.successColor).opacity( + 0.08 + ) + ) ) } } @@ -710,21 +718,21 @@ private struct AgentChannelConnectionDraft { } private static let defaultActionsJSON = """ - { - "send_message" : { - "bodyTemplate" : "{\\"text\\":\\"${content}\\"}", - "headers" : { - "Authorization" : "Bearer ${secret:bearer}", - "Content-Type" : "application/json" - }, - "method" : "POST", - "path" : "/rooms/{room_id}/messages", - "query" : { + { + "send_message" : { + "bodyTemplate" : "{\\"text\\":\\"${content}\\"}", + "headers" : { + "Authorization" : "Bearer ${secret:bearer}", + "Content-Type" : "application/json" + }, + "method" : "POST", + "path" : "/rooms/{room_id}/messages", + "query" : { + } + } } - } - } - """ + """ private static func parseList(_ text: String) -> [String] { text.components(separatedBy: CharacterSet(charactersIn: ", \n\t")) @@ -762,8 +770,8 @@ private struct AgentChannelConnectionDraft { _ actions: [String: AgentChannelCustomHTTPAction] ) -> String { guard !actions.isEmpty, - let data = try? JSONEncoder.prettyAgentChannelEncoder.encode(actions), - let string = String(data: data, encoding: .utf8) + let data = try? JSONEncoder.prettyAgentChannelEncoder.encode(actions), + let string = String(data: data, encoding: .utf8) else { return defaultActionsJSON } diff --git a/Packages/OsaurusCore/Views/Settings/ComputerUseDiagnosticsPanel.swift b/Packages/OsaurusCore/Views/Settings/ComputerUseDiagnosticsPanel.swift index 94e69b329..b89fd5e66 100644 --- a/Packages/OsaurusCore/Views/Settings/ComputerUseDiagnosticsPanel.swift +++ b/Packages/OsaurusCore/Views/Settings/ComputerUseDiagnosticsPanel.swift @@ -546,7 +546,7 @@ struct ComputerUseDiagnosticsPanel: View { format: L("Global %@ -> %@"), inspection.globalContribution.label, inspection.globalContribution.disposition.displayLabel - ), + ) ] if let perApp = inspection.perAppContribution { parts.append( diff --git a/Packages/OsaurusEvals/Sources/OsaurusEvalsCLI/OsaurusEvalsCaptureScreenCLI.swift b/Packages/OsaurusEvals/Sources/OsaurusEvalsCLI/OsaurusEvalsCaptureScreenCLI.swift index ced7abd2d..aac7ff3dc 100644 --- a/Packages/OsaurusEvals/Sources/OsaurusEvalsCLI/OsaurusEvalsCaptureScreenCLI.swift +++ b/Packages/OsaurusEvals/Sources/OsaurusEvalsCLI/OsaurusEvalsCaptureScreenCLI.swift @@ -267,7 +267,8 @@ extension OsaurusEvalsCLI { do { let fixture = try loadFixture(at: inputURL) let candidate = fixture.sanitizedForPromotion() - let outURL = outPath.map { URL(fileURLWithPath: $0) } + let outURL = + outPath.map { URL(fileURLWithPath: $0) } ?? defaultPromotionURL(inputURL: inputURL, fixture: fixture) try writeFixture(candidate.fixture, to: outURL) diff --git a/Packages/OsaurusEvals/Sources/OsaurusEvalsKit/ScreenContextFixture.swift b/Packages/OsaurusEvals/Sources/OsaurusEvalsKit/ScreenContextFixture.swift index cd5388a29..6ffbe11b7 100644 --- a/Packages/OsaurusEvals/Sources/OsaurusEvalsKit/ScreenContextFixture.swift +++ b/Packages/OsaurusEvals/Sources/OsaurusEvalsKit/ScreenContextFixture.swift @@ -77,30 +77,30 @@ public struct ScreenContextFixture: Sendable, Codable, Equatable { } } - public struct PromotionSanitizationReport: Sendable, Equatable { - public var stringFieldsRedacted: Int - public var secureValuesDropped: Int - public var elementIDsRewritten: Int - public var pathFieldsDropped: Int - public var windowTitlesRedacted: Int - public var appMetadataRedacted: Int - - public init( - stringFieldsRedacted: Int = 0, - secureValuesDropped: Int = 0, - elementIDsRewritten: Int = 0, - pathFieldsDropped: Int = 0, - windowTitlesRedacted: Int = 0, - appMetadataRedacted: Int = 0 - ) { - self.stringFieldsRedacted = stringFieldsRedacted - self.secureValuesDropped = secureValuesDropped - self.elementIDsRewritten = elementIDsRewritten - self.pathFieldsDropped = pathFieldsDropped - self.windowTitlesRedacted = windowTitlesRedacted - self.appMetadataRedacted = appMetadataRedacted - } + public struct PromotionSanitizationReport: Sendable, Equatable { + public var stringFieldsRedacted: Int + public var secureValuesDropped: Int + public var elementIDsRewritten: Int + public var pathFieldsDropped: Int + public var windowTitlesRedacted: Int + public var appMetadataRedacted: Int + + public init( + stringFieldsRedacted: Int = 0, + secureValuesDropped: Int = 0, + elementIDsRewritten: Int = 0, + pathFieldsDropped: Int = 0, + windowTitlesRedacted: Int = 0, + appMetadataRedacted: Int = 0 + ) { + self.stringFieldsRedacted = stringFieldsRedacted + self.secureValuesDropped = secureValuesDropped + self.elementIDsRewritten = elementIDsRewritten + self.pathFieldsDropped = pathFieldsDropped + self.windowTitlesRedacted = windowTitlesRedacted + self.appMetadataRedacted = appMetadataRedacted } + } public struct PromotionCandidate: Sendable, Equatable { public let fixture: ScreenContextFixture @@ -275,7 +275,8 @@ public struct ScreenContextFixture: Sendable, Codable, Equatable { if pathFields > 0 { reasons.append("contains accessibility paths that can include private labels") } - let hasUserWindowTitle = Self.hasUserWindowTitle(activeWindow?.title) + let hasUserWindowTitle = + Self.hasUserWindowTitle(activeWindow?.title) || Self.hasUserWindowTitle(snapshot.focusedWindow) || snapshot.windows.contains(where: { Self.hasUserWindowTitle($0.title) }) || windowsByPid.values.flatMap({ $0 }).contains(where: { Self.hasUserWindowTitle($0.title) }) diff --git a/Packages/OsaurusEvals/Tests/OsaurusEvalsKitTests/ScreenContextCaptureLabTests.swift b/Packages/OsaurusEvals/Tests/OsaurusEvalsKitTests/ScreenContextCaptureLabTests.swift index ecffeacf7..620ba1561 100644 --- a/Packages/OsaurusEvals/Tests/OsaurusEvalsKitTests/ScreenContextCaptureLabTests.swift +++ b/Packages/OsaurusEvals/Tests/OsaurusEvalsKitTests/ScreenContextCaptureLabTests.swift @@ -44,7 +44,7 @@ struct ScreenContextCaptureLabTests { name: "Terminal", active: true, hidden: false - ), + ) ], activeWindow: CUActiveWindow( pid: 22, @@ -136,7 +136,7 @@ struct ScreenContextCaptureLabTests { name: "Safari", active: true, hidden: false - ), + ) ], activeWindow: CUActiveWindow( pid: 701, @@ -158,8 +158,8 @@ struct ScreenContextCaptureLabTests { y: 0, w: 1200, h: 800 - ), - ], + ) + ] ], snapshot: ScreenContextFixture.Snapshot( app: "Safari", @@ -174,7 +174,7 @@ struct ScreenContextCaptureLabTests { y: 0, w: 1200, h: 800 - ), + ) ], elements: [ CUElement(