From aaa2e2518e72920e927021887f7a44faacd0692c Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 15:35:53 +0200 Subject: [PATCH 1/2] Add Apple Intelligence language-fallback settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine & Model → Apple Intelligence gains two switches: - Fall Back to Open Source Model (on by default, today's behavior): when Apple Intelligence rejects the text's language, the router retries with the selected Open Source model. Off returns no suggestion ("appleLanguageUnsupported") and never loads the local model. - Keep Fallback Model Loaded (off by default): keeps the local runtime started while Apple Intelligence is selected, so the first fallback suggestion does not wait for a multi-GB model to load. The row names the model the fallback uses and warns when no Open Source model is selected. The fallback always uses the selected Open Source model because the local runtime holds one model at a time. AppDelegate now re-evaluates the runtime when either switch changes, as it does for an engine change. Co-Authored-By: Claude Opus 5.5 --- Cotabby/App/Core/AppDelegate.swift | 14 ++++++- .../Settings/SuggestionSettingsData.swift | 15 +++++++ .../Settings/SuggestionSettingsModel.swift | 25 +++++++++++- .../Runtime/SuggestionEngineRouter.swift | 12 ++++++ .../Settings/SuggestionSettingsStore.swift | 23 ++++++++++- ...neAndModelPaneView+AppleIntelligence.swift | 40 +++++++++++++++++++ Cotabby/UI/Settings/SettingsIndex.swift | 9 ++++- .../Runtime/SuggestionEngineRouterTests.swift | 30 ++++++++++++++ 8 files changed, 163 insertions(+), 5 deletions(-) diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index e29c9e63..5c610f06 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -83,9 +83,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) - suggestionSettings.$selectedEngine + // The fallback switches decide whether the local model stays loaded under Apple Intelligence, + // so they re-evaluate the runtime just like an engine change does. + Publishers.CombineLatest3( + suggestionSettings.$selectedEngine.removeDuplicates(), + suggestionSettings.$isAppleLanguageFallbackEnabled.removeDuplicates(), + suggestionSettings.$keepsFallbackModelLoaded.removeDuplicates() + ) .dropFirst() - .removeDuplicates() .sink { [weak self] _ in self?.startRuntimeIfPreferredEngineRequiresIt() } @@ -284,6 +289,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { switch suggestionSettings.selectedEngine { case .llamaOpenSource: runtimeModel.startIfNeeded() + case .appleIntelligence + where suggestionSettings.isAppleLanguageFallbackEnabled && suggestionSettings.keepsFallbackModelLoaded: + // The user chose to keep the fallback model ready, so an unsupported language gets a + // suggestion without first waiting several seconds for the model to load. + runtimeModel.startIfNeeded() case .appleIntelligence, .openAICompatible: // Switching away must release Metal buffers and the mapped GGUF. Otherwise an Ollama // user still pays the duplicate memory cost the external endpoint is meant to avoid. diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index d8241b99..19aa4416 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -28,6 +28,11 @@ struct SuggestionEngineSettings: Equatable { var pluggedInEngine: SuggestionEngineKind var pluggedInModelFilename: String var pluggedInEndpointModelName: String + /// When Apple Intelligence rejects a language, retry with the selected Open Source model. + var isAppleLanguageFallbackEnabled: Bool + /// Keep that fallback model loaded while Apple Intelligence is selected, so the first + /// fallback suggestion does not wait for the model to load. Costs the model's memory. + var keepsFallbackModelLoaded: Bool } /// Completion length, timing, streaming, and acceptance behavior. @@ -185,6 +190,16 @@ extension SuggestionSettingsData { set { engine.isPowerBasedModelSwitchingEnabled = newValue } } + var isAppleLanguageFallbackEnabled: Bool { + get { engine.isAppleLanguageFallbackEnabled } + set { engine.isAppleLanguageFallbackEnabled = newValue } + } + + var keepsFallbackModelLoaded: Bool { + get { engine.keepsFallbackModelLoaded } + set { engine.keepsFallbackModelLoaded = newValue } + } + var batteryEngine: SuggestionEngineKind { get { engine.batteryEngine } set { engine.batteryEngine = newValue } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 61644320..4fdddbb9 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -158,6 +158,11 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride] @Published private(set) var acceptanceGranularity: AcceptanceGranularity @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool + /// Retry with the Open Source model when Apple Intelligence rejects the text's language. + /// Read live by `SuggestionEngineRouter` at request time. + @Published private(set) var isAppleLanguageFallbackEnabled: Bool + /// Keep the fallback model loaded while Apple Intelligence is selected (see `AppDelegate`). + @Published private(set) var keepsFallbackModelLoaded: Bool @Published private(set) var batteryEngine: SuggestionEngineKind @Published private(set) var batteryModelFilename: String @Published private(set) var batteryEndpointModelName: String @@ -286,6 +291,8 @@ final class SuggestionSettingsModel: ObservableObject { perAppShortcutOverrides = data.perAppShortcutOverrides acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled + isAppleLanguageFallbackEnabled = data.isAppleLanguageFallbackEnabled + keepsFallbackModelLoaded = data.keepsFallbackModelLoaded batteryEngine = data.batteryEngine batteryModelFilename = data.batteryModelFilename batteryEndpointModelName = data.batteryEndpointModelName @@ -369,6 +376,8 @@ final class SuggestionSettingsModel: ObservableObject { perAppShortcutOverrides = data.perAppShortcutOverrides acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled + isAppleLanguageFallbackEnabled = data.isAppleLanguageFallbackEnabled + keepsFallbackModelLoaded = data.keepsFallbackModelLoaded batteryEngine = data.batteryEngine batteryModelFilename = data.batteryModelFilename batteryEndpointModelName = data.batteryEndpointModelName @@ -410,7 +419,9 @@ final class SuggestionSettingsModel: ObservableObject { batteryEndpointModelName: batteryEndpointModelName, pluggedInEngine: pluggedInEngine, pluggedInModelFilename: pluggedInModelFilename, - pluggedInEndpointModelName: pluggedInEndpointModelName + pluggedInEndpointModelName: pluggedInEndpointModelName, + isAppleLanguageFallbackEnabled: isAppleLanguageFallbackEnabled, + keepsFallbackModelLoaded: keepsFallbackModelLoaded ), completion: SuggestionCompletionSettings( selectedWordCountPreset: selectedWordCountPreset, @@ -571,6 +582,18 @@ final class SuggestionSettingsModel: ObservableObject { endpointCredentialRevision &+= 1 } + func setAppleLanguageFallbackEnabled(_ enabled: Bool) { + guard isAppleLanguageFallbackEnabled != enabled else { return } + isAppleLanguageFallbackEnabled = enabled + store.saveAppleLanguageFallbackEnabled(enabled) + } + + func setKeepsFallbackModelLoaded(_ enabled: Bool) { + guard keepsFallbackModelLoaded != enabled else { return } + keepsFallbackModelLoaded = enabled + store.saveKeepsFallbackModelLoaded(enabled) + } + func setPowerBasedModelSwitchingEnabled(_ enabled: Bool) { guard isPowerBasedModelSwitchingEnabled != enabled else { return diff --git a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift index 0c184347..3daea7d4 100644 --- a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift +++ b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift @@ -62,6 +62,18 @@ final class SuggestionEngineRouter { recordQualityOutcome(result) return result } catch SuggestionClientError.unsupportedLanguageOrLocale(let message) { + // The user can turn the fallback off (Engine & Model → Apple Intelligence). Then an + // unsupported language simply gets no suggestion, and the local model never loads. + guard suggestionSettings.isAppleLanguageFallbackEnabled else { + CotabbyLogger.suggestion.info( + "Apple Intelligence unsupported for locale; fallback is turned off", + metadata: metadata.merging(["reason": .string(message)]) { _, new in new } + ) + return SuggestionResult( + generation: request.generation, rawText: "", text: "", latency: 0, + suppressionReason: "appleLanguageUnsupported" + ) + } CotabbyLogger.suggestion.info( "Apple Intelligence unsupported for locale, falling back to open-source: \(message)", metadata: metadata.merging([ diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 2f724e93..81466e1c 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -189,6 +189,8 @@ struct SuggestionSettingsStore { private static let globalToggleKeyLabelDefaultsKey = "cotabbyGlobalToggleKeyLabel" private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity" + private static let appleLanguageFallbackEnabledDefaultsKey = "cotabbyAppleLanguageFallbackEnabled" + private static let keepFallbackModelLoadedDefaultsKey = "cotabbyKeepFallbackModelLoaded" private static let powerModelSwitchingEnabledDefaultsKey = "cotabbyPowerBasedModelSwitchingEnabled" private static let batteryEngineDefaultsKey = "cotabbyBatteryEngine" private static let batteryModelFilenameDefaultsKey = "cotabbyBatteryModelFilename" @@ -270,6 +272,8 @@ struct SuggestionSettingsStore { globalToggleKeyLabelDefaultsKey, acceptanceGranularityDefaultsKey, powerModelSwitchingEnabledDefaultsKey, + appleLanguageFallbackEnabledDefaultsKey, + keepFallbackModelLoadedDefaultsKey, batteryEngineDefaultsKey, batteryModelFilenameDefaultsKey, batteryEndpointModelNameDefaultsKey, @@ -549,6 +553,11 @@ struct SuggestionSettingsStore { let resolvedPowerBasedModelSwitchingEnabled = userDefaults.object(forKey: Self.powerModelSwitchingEnabledDefaultsKey) as? Bool ?? false + // On by default: falling back is what Cotabby has always done for unsupported languages. + let resolvedAppleLanguageFallbackEnabled = + userDefaults.object(forKey: Self.appleLanguageFallbackEnabledDefaultsKey) as? Bool ?? true + let resolvedKeepsFallbackModelLoaded = + userDefaults.object(forKey: Self.keepFallbackModelLoadedDefaultsKey) as? Bool ?? false let resolvedBatteryEngine = userDefaults.string(forKey: Self.batteryEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource let resolvedBatteryModelFilename = userDefaults.string(forKey: Self.batteryModelFilenameDefaultsKey) ?? "" @@ -580,7 +589,9 @@ struct SuggestionSettingsStore { batteryEndpointModelName: resolvedBatteryEndpointModelName, pluggedInEngine: resolvedPluggedInEngine, pluggedInModelFilename: resolvedPluggedInModelFilename, - pluggedInEndpointModelName: resolvedPluggedInEndpointModelName + pluggedInEndpointModelName: resolvedPluggedInEndpointModelName, + isAppleLanguageFallbackEnabled: resolvedAppleLanguageFallbackEnabled, + keepsFallbackModelLoaded: resolvedKeepsFallbackModelLoaded ), completion: SuggestionCompletionSettings( selectedWordCountPreset: resolvedWordCountPreset, @@ -724,6 +735,8 @@ struct SuggestionSettingsStore { savePerAppShortcutOverrides(data.perAppShortcutOverrides) saveAcceptanceGranularity(data.acceptanceGranularity) savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled) + saveAppleLanguageFallbackEnabled(data.isAppleLanguageFallbackEnabled) + saveKeepsFallbackModelLoaded(data.keepsFallbackModelLoaded) saveBatteryEngine(data.batteryEngine) saveBatteryModelFilename(data.batteryModelFilename) saveBatteryEndpointModelName(data.batteryEndpointModelName) @@ -851,6 +864,14 @@ struct SuggestionSettingsStore { userDefaults.set(mode.rawValue, forKey: Self.openAICompatibleAPIModeDefaultsKey) } + func saveAppleLanguageFallbackEnabled(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.appleLanguageFallbackEnabledDefaultsKey) + } + + func saveKeepsFallbackModelLoaded(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.keepFallbackModelLoadedDefaultsKey) + } + func savePowerBasedModelSwitchingEnabled(_ enabled: Bool) { userDefaults.set(enabled, forKey: Self.powerModelSwitchingEnabledDefaultsKey) } diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift index c3de29ef..760299b8 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI /// Apple Intelligence availability presentation. @@ -23,6 +24,45 @@ extension EngineAndModelPaneView { ) } .settingsItem(.appleIntelligenceAvailability) + + Toggle(isOn: Binding( + get: { suggestionSettings.isAppleLanguageFallbackEnabled }, + set: { suggestionSettings.setAppleLanguageFallbackEnabled($0) } + )) { + SettingsRowLabel( + title: "Fall Back to Open Source Model", + description: "When Apple Intelligence doesn't support the language you're writing in, " + + "suggest with \(fallbackModelName) instead. Turn off to get no suggestion in those languages.", + systemImage: "arrow.triangle.branch" + ) + } + .settingsItem(.appleLanguageFallback) + + Toggle(isOn: Binding( + get: { suggestionSettings.keepsFallbackModelLoaded }, + set: { suggestionSettings.setKeepsFallbackModelLoaded($0) } + )) { + SettingsRowLabel( + title: "Keep Fallback Model Loaded", + description: "Load the fallback model in advance so its first suggestion doesn't wait for it " + + "to load. Uses the model's memory (several GB) while Apple Intelligence is selected.", + systemImage: "memorychip" + ) + } + .disabled(!suggestionSettings.isAppleLanguageFallbackEnabled) + + if suggestionSettings.isAppleLanguageFallbackEnabled, runtimeModel.selectedModelFilename == nil { + Text("No Open Source model is selected, so there is nothing to fall back to. " + + "Choose one under Engine → Open Source.") + .font(.caption) + .foregroundStyle(.orange) + } } } + + /// The model the fallback uses: the selected Open Source model. The local runtime holds one + /// model at a time, so the fallback cannot use a different one without swapping it in. + private var fallbackModelName: String { + runtimeModel.selectedModelFilename.map { ($0 as NSString).deletingPathExtension } ?? "your Open Source model" + } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..04fb7e21 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -63,6 +63,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { // Engine & Model case engine case appleIntelligenceAvailability + case appleLanguageFallback case modelStatus case selectedModel case lowPowerModeAutoDisable @@ -152,6 +153,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" + case .appleLanguageFallback: return "Fall Back to Open Source Model" case .modelStatus: return "Model Status" case .selectedModel: return "Selected Model" case .lowPowerModeAutoDisable: return "Pause in Low Power Mode" @@ -236,6 +238,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" + case .appleLanguageFallback: return "arrow.triangle.branch" case .modelStatus: return "info.circle" case .selectedModel: return "shippingbox" case .lowPowerModeAutoDisable: return "bolt.slash.circle" @@ -293,7 +296,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .writing case .extendedContext, .contextLivePreview: return .context - case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, + case .engine, .appleIntelligenceAvailability, .appleLanguageFallback, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: @@ -363,6 +366,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .contextLivePreview: return "A real field that exercises the full pipeline." case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." + case .appleLanguageFallback: return "Use the local model for languages Apple Intelligence doesn't support." case .modelStatus: return "Whether the local model is loaded and ready." case .selectedModel: return "Which downloaded model generates suggestions." case .lowPowerModeAutoDisable: return "Pause suggestions while Low Power Mode is active." @@ -537,6 +541,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { "provider", "runtime", "foundation models", "oss", "local", "endpoint", "ollama", "openai compatible", "lm studio", "vllm", "on-device", "model engine"] + case .appleLanguageFallback: + return ["fallback", "fall back", "unsupported language", "language", "macedonian", + "open source", "local model", "gemma", "llama", "keep loaded", "preload", "memory"] case .appleIntelligenceAvailability: return ["apple intelligence", "availability", "available", "supported", "compatibility", "status", "macos", "device support"] diff --git a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift index a5677310..29b5adbc 100644 --- a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift +++ b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift @@ -164,6 +164,36 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { XCTAssertEqual(rig.metrics.entries.first?.modelName, "test-model.gguf") } + func test_unsupportedLocale_withFallbackOff_returnsNoSuggestionAndSkipsTheLocalModel() async throws { + let rig = makeRig(engine: .appleIntelligence) + rig.settings.setAppleLanguageFallbackEnabled(false) + rig.foundation.script = { _ in + throw SuggestionClientError.unsupportedLanguageOrLocale("Locale not supported.") + } + + let result = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, "") + XCTAssertEqual(result.suppressionReason, "appleLanguageUnsupported") + XCTAssertTrue(rig.llama.requests.isEmpty, "With the fallback off the local model must not run") + } + + func test_fallbackSettingsDefaultToTodaysBehaviorAndPersist() { + let defaults = makeDefaults() + let settings = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + Self.retained.append(settings) + XCTAssertTrue(settings.isAppleLanguageFallbackEnabled) + XCTAssertFalse(settings.keepsFallbackModelLoaded) + + settings.setAppleLanguageFallbackEnabled(false) + settings.setKeepsFallbackModelLoaded(true) + + let reloaded = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + Self.retained.append(reloaded) + XCTAssertFalse(reloaded.isAppleLanguageFallbackEnabled) + XCTAssertTrue(reloaded.keepsFallbackModelLoaded) + } + func test_unsupportedLocale_fallbackFailureComposesBothMessages() async { let rig = makeRig(engine: .appleIntelligence) rig.foundation.script = { _ in From 2a3803f4c3553a5ecc4d4f82a901f164cc702742 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 15:40:59 +0200 Subject: [PATCH 2/2] Pick the fallback model in the Apple Intelligence section; find models in a linked folder - The Open Source section is hidden while Apple Intelligence is the engine, so the fallback model could not be chosen anywhere visible. The Apple Intelligence section now has a Fallback Model picker (the shared Open Source selection, since the runtime holds one model). The warning now reports "no downloaded models" instead of reading the stopped runtime's selection, which is nil while it is unloaded. - Model discovery resolves a symlinked models folder before walking it: FileManager.enumerator does not descend into a symlinked root, so a folder linked to an external drive or another location listed nothing and the app cleared the saved model selection. Co-Authored-By: Claude Opus 5.5 --- .../Runtime/BundledRuntimeLocator.swift | 6 ++++- ...neAndModelPaneView+AppleIntelligence.swift | 25 ++++++++++++++++--- Cotabby/UI/Settings/SettingsIndex.swift | 8 +++++- .../Runtime/BundledRuntimeLocatorTests.swift | 15 +++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Cotabby/Support/Runtime/BundledRuntimeLocator.swift b/Cotabby/Support/Runtime/BundledRuntimeLocator.swift index 165e6ca1..ab8b6e68 100644 --- a/Cotabby/Support/Runtime/BundledRuntimeLocator.swift +++ b/Cotabby/Support/Runtime/BundledRuntimeLocator.swift @@ -103,8 +103,12 @@ struct BundledRuntimeLocator { /// them out of the model picker. static func discoverGGUFModelURLs(in directoryURL: URL, maxDepth: Int = 4) -> [URL] { let fileManager = FileManager.default + // `FileManager.enumerator` does not descend into a root that is itself a symbolic link, so a + // models folder linked to another location (an external drive, another app's folder) would + // list nothing. Resolve the root first; links deeper inside stay unfollowed, which keeps + // the bounded walk from escaping into arbitrary trees. guard let enumerator = fileManager.enumerator( - at: directoryURL, + at: directoryURL.resolvingSymlinksInPath(), includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift index 760299b8..7d340e5f 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+AppleIntelligence.swift @@ -51,11 +51,30 @@ extension EngineAndModelPaneView { } .disabled(!suggestionSettings.isAppleLanguageFallbackEnabled) - if suggestionSettings.isAppleLanguageFallbackEnabled, runtimeModel.selectedModelFilename == nil { - Text("No Open Source model is selected, so there is nothing to fall back to. " + - "Choose one under Engine → Open Source.") + // The Open Source section is hidden while Apple Intelligence is the engine, so the + // fallback model is chosen here. It is the same selection the Open Source engine uses: + // the local runtime holds one model at a time. + if runtimeModel.availableModels.isEmpty { + Text("No downloaded models were found, so there is nothing to fall back to. " + + "Switch the engine to Open Source to download one.") .font(.caption) .foregroundStyle(.orange) + } else { + Picker(selection: selectedModelBinding) { + ForEach(runtimeModel.availableModels) { model in + Text(model.displayName).tag(model.filename) + } + } label: { + SettingsRowLabel( + title: "Fallback Model", + description: "The downloaded model used when Apple Intelligence can't handle the " + + "language. It is also your Open Source engine's model.", + systemImage: "shippingbox" + ) + } + .disabled(!suggestionSettings.isAppleLanguageFallbackEnabled + || suggestionSettings.isPowerBasedModelSwitchingEnabled) + .settingsItem(.appleLanguageFallbackModel) } } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 04fb7e21..c26e2133 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -64,6 +64,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case engine case appleIntelligenceAvailability case appleLanguageFallback + case appleLanguageFallbackModel case modelStatus case selectedModel case lowPowerModeAutoDisable @@ -154,6 +155,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" case .appleLanguageFallback: return "Fall Back to Open Source Model" + case .appleLanguageFallbackModel: return "Fallback Model" case .modelStatus: return "Model Status" case .selectedModel: return "Selected Model" case .lowPowerModeAutoDisable: return "Pause in Low Power Mode" @@ -239,6 +241,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" case .appleLanguageFallback: return "arrow.triangle.branch" + case .appleLanguageFallbackModel: return "shippingbox" case .modelStatus: return "info.circle" case .selectedModel: return "shippingbox" case .lowPowerModeAutoDisable: return "bolt.slash.circle" @@ -296,7 +299,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .writing case .extendedContext, .contextLivePreview: return .context - case .engine, .appleIntelligenceAvailability, .appleLanguageFallback, .modelStatus, .selectedModel, + case .engine, .appleIntelligenceAvailability, .appleLanguageFallback, .appleLanguageFallbackModel, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: @@ -367,6 +370,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." case .appleLanguageFallback: return "Use the local model for languages Apple Intelligence doesn't support." + case .appleLanguageFallbackModel: return "Which downloaded model the language fallback uses." case .modelStatus: return "Whether the local model is loaded and ready." case .selectedModel: return "Which downloaded model generates suggestions." case .lowPowerModeAutoDisable: return "Pause suggestions while Low Power Mode is active." @@ -541,6 +545,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { "provider", "runtime", "foundation models", "oss", "local", "endpoint", "ollama", "openai compatible", "lm studio", "vllm", "on-device", "model engine"] + case .appleLanguageFallbackModel: + return ["fallback model", "fallback", "model", "gemma", "gguf", "local model", "choose model"] case .appleLanguageFallback: return ["fallback", "fall back", "unsupported language", "language", "macedonian", "open source", "local model", "gemma", "llama", "keep loaded", "preload", "memory"] diff --git a/CotabbyTests/Support/Runtime/BundledRuntimeLocatorTests.swift b/CotabbyTests/Support/Runtime/BundledRuntimeLocatorTests.swift index b64978f4..142ea79d 100644 --- a/CotabbyTests/Support/Runtime/BundledRuntimeLocatorTests.swift +++ b/CotabbyTests/Support/Runtime/BundledRuntimeLocatorTests.swift @@ -467,4 +467,19 @@ final class BundledRuntimeLocatorTests: XCTestCase { gpuLayerCount: -1 ) } + + func test_discoverFindsModelsThroughASymlinkedModelsFolder() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("gguf-link-\(UUID().uuidString)") + let real = root.appendingPathComponent("real") + let link = root.appendingPathComponent("LlamaRuntime") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("gguf".utf8).write(to: real.appendingPathComponent("model.gguf")) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + let found = BundledRuntimeLocator.discoverGGUFModelURLs(in: link) + + XCTAssertEqual(found.map(\.lastPathComponent), ["model.gguf"]) + } + }