From 6b7bb2f91e1f520c882fea77deb1b657d29eeba2 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:16:45 +0400 Subject: [PATCH 01/15] fix(ai): preserve custom model IDs Expose manual model entry in the active provider UI and persist custom IDs separately from discovery results. Merge explicit custom IDs during refresh while retaining the existing fallback for retired discovered models. --- Fluid.xcodeproj/project.pbxproj | 8 ++ Sources/Fluid/Persistence/SettingsStore.swift | 9 ++ .../AIEnhancementSettingsViewModel.swift | 117 +++++++++++++----- .../Fluid/UI/AISettings/AIModelCatalog.swift | 32 +++++ .../UI/AISettingsView+AIConfiguration.swift | 25 ++++ .../AIModelRefreshTests.swift | 67 ++++++++++ 6 files changed, 224 insertions(+), 34 deletions(-) create mode 100644 Sources/Fluid/UI/AISettings/AIModelCatalog.swift create mode 100644 Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 673a31ed..c1bd7098 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7C5AF14C2F15041600DE21B0 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + A60100000000000000000002 /* AIModelRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60100000000000000000001 /* AIModelRefreshTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; @@ -49,6 +50,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + A60100000000000000000001 /* AIModelRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelRefreshTests.swift; sourceTree = ""; }; + 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; @@ -124,6 +130,7 @@ children = ( 7CDB0A262F3C4D5600FB7CAD /* Helpers */, 7CDB0A272F3C4D5600FB7CAD /* Resources */, + A60100000000000000000001 /* AIModelRefreshTests.swift */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, @@ -281,6 +288,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A60100000000000000000002 /* AIModelRefreshTests.swift in Sources */, 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 8a823273..3c65c0f4 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1412,6 +1412,14 @@ final class SettingsStore: ObservableObject { } } + var customModelsByProvider: [String: [String]] { + get { (self.defaults.dictionary(forKey: Keys.customModelsByProvider) as? [String: [String]]) ?? [:] } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.customModelsByProvider) + } + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -4872,6 +4880,7 @@ private extension SettingsStore { static let enableDebugLogs = "EnableDebugLogs" static let availableAIModels = "AvailableAIModels" static let availableModelsByProvider = "AvailableModelsByProvider" + static let customModelsByProvider = "CustomModelsByProvider" static let selectedAIModel = "SelectedAIModel" static let selectedModelByProvider = "SelectedModelByProvider" static let selectedProviderID = "SelectedProviderID" diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 08a869b1..50bfd5b4 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -18,6 +18,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // Model Management @Published var availableModelsByProvider: [String: [String]] = [:] + @Published var customModelsByProvider: [String: [String]] = [:] @Published var selectedModelByProvider: [String: String] = [:] @Published var availableModels: [String] = [] @Published var selectedModel: String = "" { @@ -158,6 +159,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.selectedProviderID = self.settings.selectedProviderID self.availableModelsByProvider = self.settings.availableModelsByProvider + self.customModelsByProvider = self.settings.customModelsByProvider self.selectedModelByProvider = self.settings.selectedModelByProvider self.providerAPIKeys = self.settings.providerAPIKeys self.savedProviders = self.settings.savedProviders @@ -190,8 +192,24 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let clean = Array(Set(models.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })).sorted() if !clean.isEmpty { normalized[newKey] = clean } } + var normalizedCustom: [String: [String]] = [:] + for (key, models) in self.customModelsByProvider { + let providerKey = self.providerKey(for: key) + let clean = AIModelCatalog.normalized(models) + if !providerKey.isEmpty, !clean.isEmpty { + normalizedCustom[providerKey] = clean + } + } + for (key, customModels) in normalizedCustom { + normalized[key] = AIModelCatalog.merged( + discoveredModels: normalized[key] ?? [], + customModels: customModels + ) + } self.availableModelsByProvider = normalized self.settings.availableModelsByProvider = normalized + self.customModelsByProvider = normalizedCustom + self.settings.customModelsByProvider = normalizedCustom // Normalize selected model by provider var normalizedSel: [String: String] = [:] @@ -535,6 +553,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { func saveSavedProviders() { self.settings.savedProviders = self.savedProviders self.settings.availableModelsByProvider = self.availableModelsByProvider + self.settings.customModelsByProvider = self.customModelsByProvider self.settings.selectedModelByProvider = self.selectedModelByProvider self.settings.selectedProviderID = self.selectedProviderID self.refreshProviderItems() @@ -555,33 +574,37 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } func addNewModel() { - guard !self.newModelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let modelName = self.newModelName.trimmingCharacters(in: .whitespacesAndNewlines) let key = self.providerKey(for: self.selectedProviderID) + let visibleModels = self.availableModelsByProvider[key] ?? self.availableModels + guard let visibleAddition = AIModelCatalog.adding(self.newModelName, to: visibleModels) else { return } + let modelName = visibleAddition.modelID + let list = visibleAddition.models - var list = self.availableModelsByProvider[key] ?? self.availableModels - if !list.contains(modelName) { - list.append(modelName) - self.availableModelsByProvider[key] = list - self.settings.availableModelsByProvider = self.availableModelsByProvider - - if let providerIndex = savedProviders.firstIndex(where: { $0.id == selectedProviderID }) { - let updatedProvider = SettingsStore.SavedProvider( - id: self.savedProviders[providerIndex].id, - name: self.savedProviders[providerIndex].name, - baseURL: self.savedProviders[providerIndex].baseURL, - models: list - ) - self.savedProviders[providerIndex] = updatedProvider - self.saveSavedProviders() - } + let customModels = self.customModelsByProvider[key] ?? [] + if let customAddition = AIModelCatalog.adding(modelName, to: customModels) { + self.customModelsByProvider[key] = customAddition.models + } + self.settings.customModelsByProvider = self.customModelsByProvider - self.availableModels = list - self.selectedModel = modelName - self.selectedModelByProvider[key] = modelName - self.settings.selectedModelByProvider = self.selectedModelByProvider + self.availableModelsByProvider[key] = list + self.settings.availableModelsByProvider = self.availableModelsByProvider + + if let providerIndex = savedProviders.firstIndex(where: { $0.id == selectedProviderID }) { + let updatedProvider = SettingsStore.SavedProvider( + id: self.savedProviders[providerIndex].id, + name: self.savedProviders[providerIndex].name, + baseURL: self.savedProviders[providerIndex].baseURL, + models: list + ) + self.savedProviders[providerIndex] = updatedProvider + self.saveSavedProviders() } + self.availableModels = list + self.selectedModel = modelName + self.selectedModelByProvider[key] = modelName + self.settings.selectedModelByProvider = self.selectedModelByProvider + self.showingAddModel = false self.newModelName = "" } @@ -1083,11 +1106,13 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.saveSavedProviders() let key = self.providerKey(for: self.selectedProviderID) self.availableModelsByProvider.removeValue(forKey: key) + self.customModelsByProvider.removeValue(forKey: key) self.selectedModelByProvider.removeValue(forKey: key) self.providerAPIKeys.removeValue(forKey: key) self.saveProviderAPIKeys() self.settings.verifiedProviderFingerprints.removeValue(forKey: key) self.settings.availableModelsByProvider = self.availableModelsByProvider + self.settings.customModelsByProvider = self.customModelsByProvider self.settings.selectedModelByProvider = self.selectedModelByProvider self.selectedProviderID = "" self.openAIBaseURL = "" @@ -1098,6 +1123,16 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.selectSoleVerifiedProviderIfNeeded() } + private func modelsAfterRefresh( + discoveredModels: [String], + providerKey: String + ) -> [String] { + return AIModelCatalog.merged( + discoveredModels: discoveredModels, + customModels: self.customModelsByProvider[providerKey] ?? [] + ) + } + func saveEditedProvider() { let name = self.editProviderName.trimmingCharacters(in: .whitespacesAndNewlines) let base = self.editProviderBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1127,6 +1162,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { func deleteSelectedModel() { let key = self.providerKey(for: self.selectedProviderID) + self.customModelsByProvider[key]?.removeAll { $0 == self.selectedModel } + if self.customModelsByProvider[key]?.isEmpty == true { + self.customModelsByProvider.removeValue(forKey: key) + } + self.settings.customModelsByProvider = self.customModelsByProvider + var list = self.availableModelsByProvider[key] ?? self.availableModels list.removeAll { $0 == self.selectedModel } if list.isEmpty { list = ModelRepository.shared.defaultModels(for: key) } @@ -1181,8 +1222,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // Keep existing models if fetch returned empty self.fetchModelsError = "No models returned from API" } else { - self.availableModels = models - self.availableModelsByProvider[key] = models + let mergedModels = self.modelsAfterRefresh( + discoveredModels: models, + providerKey: key + ) + self.availableModels = mergedModels + self.availableModelsByProvider[key] = mergedModels self.settings.availableModelsByProvider = self.availableModelsByProvider self.fetchedModelsProviders.insert(key) @@ -1191,15 +1236,15 @@ final class AIEnhancementSettingsViewModel: ObservableObject { id: self.savedProviders[providerIndex].id, name: self.savedProviders[providerIndex].name, baseURL: self.savedProviders[providerIndex].baseURL, - models: models + models: mergedModels ) self.savedProviders[providerIndex] = updatedProvider self.saveSavedProviders() } // Select first model if current selection not in list - if !models.contains(self.selectedModel) { - self.selectedModel = models.first ?? "" + if !mergedModels.contains(self.selectedModel) { + self.selectedModel = mergedModels.first ?? "" self.selectedModelByProvider[key] = self.selectedModel self.settings.selectedModelByProvider = self.selectedModelByProvider } @@ -1285,19 +1330,23 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return } - self.availableModelsByProvider[key] = models + let selectedForProvider = self.selectedModelByProvider[key] ?? "" + let mergedModels = self.modelsAfterRefresh( + discoveredModels: models, + providerKey: key + ) + self.availableModelsByProvider[key] = mergedModels self.settings.availableModelsByProvider = self.availableModelsByProvider self.fetchedModelsProviders.insert(key) if providerID == self.selectedProviderID { - self.availableModels = models - if !models.contains(self.selectedModel) { - self.selectedModel = models.first ?? "" + self.availableModels = mergedModels + if !mergedModels.contains(self.selectedModel) { + self.selectedModel = mergedModels.first ?? "" } } - let selectedForProvider = self.selectedModelByProvider[key] ?? "" - if !models.contains(selectedForProvider), let first = models.first { + if !mergedModels.contains(selectedForProvider), let first = mergedModels.first { self.selectedModelByProvider[key] = first self.settings.selectedModelByProvider = self.selectedModelByProvider } @@ -1307,7 +1356,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { id: self.savedProviders[providerIndex].id, name: self.savedProviders[providerIndex].name, baseURL: self.savedProviders[providerIndex].baseURL, - models: models + models: mergedModels ) self.savedProviders[providerIndex] = updatedProvider self.saveSavedProviders() diff --git a/Sources/Fluid/UI/AISettings/AIModelCatalog.swift b/Sources/Fluid/UI/AISettings/AIModelCatalog.swift new file mode 100644 index 00000000..773b12b8 --- /dev/null +++ b/Sources/Fluid/UI/AISettings/AIModelCatalog.swift @@ -0,0 +1,32 @@ +import Foundation + +enum AIModelCatalog { + struct Addition: Equatable { + let modelID: String + let models: [String] + } + + static func merged( + discoveredModels: [String], + customModels: [String] + ) -> [String] { + self.normalized(discoveredModels + customModels) + } + + static func adding(_ enteredModel: String, to models: [String]) -> Addition? { + guard let modelID = self.normalized([enteredModel]).first else { return nil } + return Addition( + modelID: modelID, + models: self.normalized(models + [modelID]) + ) + } + + static func normalized(_ models: [String]) -> [String] { + var seen: Set = [] + return models.compactMap { model in + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + } +} diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 72b45111..abb6c81e 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -1436,6 +1436,16 @@ extension AIEnhancementSettingsView { self.activateProvider(item.id) Task { await self.viewModel.fetchModelsForCurrentProvider() } } + + self.companionIconButton(systemName: "plus", help: "Add custom model") { + self.activateProvider(item.id) + self.viewModel.newModelName = "" + self.viewModel.showingAddModel = true + } + } + + if self.viewModel.showingAddModel && self.viewModel.selectedProviderID == item.id { + self.addModelSection } HStack(spacing: 8) { @@ -1715,6 +1725,13 @@ extension AIEnhancementSettingsView { } .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) + self.companionIconButton(systemName: "plus", help: "Add custom model") { + self.activateProvider(item.id) + self.viewModel.newModelName = "" + self.viewModel.showingAddModel = true + } + .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) + self.reasoningButton(for: item.id) .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) @@ -1750,6 +1767,14 @@ extension AIEnhancementSettingsView { .padding(.top, 8) } + if !isPrivateAIProvider, + self.viewModel.showingAddModel, + self.viewModel.selectedProviderID == item.id + { + self.addModelSection + .padding(.top, 8) + } + if isPrivateAIProvider, isEditing { Divider() .background(self.theme.palette.separator.opacity(0.5)) diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift new file mode 100644 index 00000000..df6baf2f --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -0,0 +1,67 @@ +@testable import FluidVoice_Debug +import XCTest + +@MainActor +final class AIModelRefreshTests: XCTestCase { + private let customModelsByProviderKey = "CustomModelsByProvider" + + func testCustomModelsPersistByProvider() { + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: self.customModelsByProviderKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: self.customModelsByProviderKey) + } else { + defaults.removeObject(forKey: self.customModelsByProviderKey) + } + } + + let providerKey = "custom:issue-601-test" + SettingsStore.shared.customModelsByProvider = [providerKey: ["model/custom:nitro"]] + + XCTAssertEqual( + SettingsStore.shared.customModelsByProvider[providerKey], + ["model/custom:nitro"] + ) + } + + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { + let selectedModel = "model/retired" + let merged = AIModelCatalog.merged( + discoveredModels: ["model/a"], + customModels: [] + ) + + XCTAssertFalse(merged.contains(selectedModel)) + } + + func testRefreshKeepsSelectedCustomModel() { + let selectedModel = "model/custom:nitro" + let merged = AIModelCatalog.merged( + discoveredModels: ["model/a"], + customModels: [selectedModel] + ) + + XCTAssertTrue(merged.contains(selectedModel)) + } + + func testAddingExistingModelReturnsNormalizedSelectionWithoutDuplicate() { + XCTAssertEqual( + AIModelCatalog.adding(" model/a ", to: ["model/a", "model/b"]), + AIModelCatalog.Addition( + modelID: "model/a", + models: ["model/a", "model/b"] + ) + ) + } + + func testRefreshDropsBlankModelIDs() { + XCTAssertEqual( + AIModelCatalog.merged( + discoveredModels: ["", " \n "], + customModels: [" model/custom "] + ), + ["model/custom"] + ) + } +} From cc0e49aba5aa18feba319ba528e7615ab97b0aa9 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:10:06 +0400 Subject: [PATCH 02/15] fix(ai): avoid promoting discovered models Keep existing discovery-only IDs selectable without persisting them as custom models, while continuing to retain genuinely new manual IDs. Reuse the SettingsStore defaults key in integration-test cleanup and cover both entry paths. --- Sources/Fluid/Persistence/SettingsStore.swift | 8 ++-- .../AIEnhancementSettingsViewModel.swift | 44 ++++++++++++++++--- .../AIModelRefreshTests.swift | 38 +++++++++++++--- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 3c65c0f4..aa6c472b 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -22,6 +22,7 @@ final class SettingsStore: ObservableObject { static let privateAIDictationMinimumOutputTokens = 256 static let privateAIDictationRoundTripTokenCost = 2.75 static let privateAIBackendPreferenceDefaultsKey = "FluidIntelligenceBackendPreference" + static let customModelsByProviderDefaultsKey = "CustomModelsByProvider" private static let forcedOnboardingResetIntroducedAt = Date(timeIntervalSince1970: 1_782_091_732) private let defaults = UserDefaults.standard private let keychain = KeychainService.shared @@ -1413,10 +1414,12 @@ final class SettingsStore: ObservableObject { } var customModelsByProvider: [String: [String]] { - get { (self.defaults.dictionary(forKey: Keys.customModelsByProvider) as? [String: [String]]) ?? [:] } + get { + (self.defaults.dictionary(forKey: Self.customModelsByProviderDefaultsKey) as? [String: [String]]) ?? [:] + } set { objectWillChange.send() - self.defaults.set(newValue, forKey: Keys.customModelsByProvider) + self.defaults.set(newValue, forKey: Self.customModelsByProviderDefaultsKey) } } @@ -4880,7 +4883,6 @@ private extension SettingsStore { static let enableDebugLogs = "EnableDebugLogs" static let availableAIModels = "AvailableAIModels" static let availableModelsByProvider = "AvailableModelsByProvider" - static let customModelsByProvider = "CustomModelsByProvider" static let selectedAIModel = "SelectedAIModel" static let selectedModelByProvider = "SelectedModelByProvider" static let selectedProviderID = "SelectedProviderID" diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 50bfd5b4..4230798f 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -102,6 +102,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } } + struct ManualModelAddition: Equatable { + let modelID: String + let visibleModels: [String] + let customModels: [String] + } + @Published var cachedProviderItems: [ProviderItemData] = [] @Published var cachedVerifiedProviderItems: [ProviderItemData] = [] @Published var cachedUnverifiedProviderItems: [ProviderItemData] = [] @@ -573,16 +579,42 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return self.settings.isReasoningModel(self.selectedModel) } + static func manualModelAddition( + _ enteredModel: String, + visibleModels: [String], + customModels: [String] + ) -> ManualModelAddition? { + guard let visibleAddition = AIModelCatalog.adding(enteredModel, to: visibleModels) else { return nil } + let wasAlreadyVisible = AIModelCatalog.normalized(visibleModels).contains(visibleAddition.modelID) + let updatedCustomModels: [String] + if wasAlreadyVisible { + updatedCustomModels = AIModelCatalog.normalized(customModels) + } else { + updatedCustomModels = AIModelCatalog.normalized(customModels + [visibleAddition.modelID]) + } + return ManualModelAddition( + modelID: visibleAddition.modelID, + visibleModels: visibleAddition.models, + customModels: updatedCustomModels + ) + } + func addNewModel() { let key = self.providerKey(for: self.selectedProviderID) let visibleModels = self.availableModelsByProvider[key] ?? self.availableModels - guard let visibleAddition = AIModelCatalog.adding(self.newModelName, to: visibleModels) else { return } - let modelName = visibleAddition.modelID - let list = visibleAddition.models - let customModels = self.customModelsByProvider[key] ?? [] - if let customAddition = AIModelCatalog.adding(modelName, to: customModels) { - self.customModelsByProvider[key] = customAddition.models + guard let addition = Self.manualModelAddition( + self.newModelName, + visibleModels: visibleModels, + customModels: customModels + ) else { return } + let modelName = addition.modelID + let list = addition.visibleModels + + if addition.customModels.isEmpty { + self.customModelsByProvider.removeValue(forKey: key) + } else { + self.customModelsByProvider[key] = addition.customModels } self.settings.customModelsByProvider = self.customModelsByProvider diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index df6baf2f..3523adff 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -3,16 +3,14 @@ import XCTest @MainActor final class AIModelRefreshTests: XCTestCase { - private let customModelsByProviderKey = "CustomModelsByProvider" - func testCustomModelsPersistByProvider() { let defaults = UserDefaults.standard - let previousValue = defaults.object(forKey: self.customModelsByProviderKey) + let previousValue = defaults.object(forKey: SettingsStore.customModelsByProviderDefaultsKey) defer { if let previousValue { - defaults.set(previousValue, forKey: self.customModelsByProviderKey) + defaults.set(previousValue, forKey: SettingsStore.customModelsByProviderDefaultsKey) } else { - defaults.removeObject(forKey: self.customModelsByProviderKey) + defaults.removeObject(forKey: SettingsStore.customModelsByProviderDefaultsKey) } } @@ -25,6 +23,36 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testEnteringDiscoveredModelSelectsWithoutPersistingAsCustom() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.manualModelAddition( + " model/discovered ", + visibleModels: ["model/discovered", "model/other"], + customModels: [] + ), + AIEnhancementSettingsViewModel.ManualModelAddition( + modelID: "model/discovered", + visibleModels: ["model/discovered", "model/other"], + customModels: [] + ) + ) + } + + func testEnteringNewManualModelPersistsAsCustom() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.manualModelAddition( + " model/custom:nitro ", + visibleModels: ["model/discovered"], + customModels: [] + ), + AIEnhancementSettingsViewModel.ManualModelAddition( + modelID: "model/custom:nitro", + visibleModels: ["model/discovered", "model/custom:nitro"], + customModels: ["model/custom:nitro"] + ) + ) + } + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { let selectedModel = "model/retired" let merged = AIModelCatalog.merged( From b45687b426421309b102a6eddeae22220088d49f Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:39:26 +0400 Subject: [PATCH 03/15] fix(ai): address custom model review feedback Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Fluid.xcodeproj/project.pbxproj | 4 -- Sources/Fluid/Persistence/BackupService.swift | 2 + Sources/Fluid/Persistence/SettingsStore.swift | 6 +++ .../AIEnhancementSettingsViewModel.swift | 52 +++++++++++++++++++ .../UI/AISettingsView+AIConfiguration.swift | 2 +- .../AIModelRefreshTests.swift | 18 +++++++ 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index c1bd7098..8bf6dda1 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -50,11 +50,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; A60100000000000000000001 /* AIModelRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelRefreshTests.swift; sourceTree = ""; }; - 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 03528d67..9a2a3599 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -10,6 +10,8 @@ struct BackupFileVersion: Codable, Equatable { struct SettingsBackupPayload: Codable, Equatable { let selectedProviderID: String let selectedModelByProvider: [String: String] + // Optional so backups created before custom model persistence still decode. + let customModelsByProvider: [String: [String]]? let savedProviders: [SettingsStore.SavedProvider] let modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig] let privateAIPrefixKVCacheEnabled: Bool? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index aa6c472b..eb39b43f 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1423,6 +1423,10 @@ final class SettingsStore: ObservableObject { } } + var hasStoredCustomModelsByProvider: Bool { + self.defaults.object(forKey: Self.customModelsByProviderDefaultsKey) != nil + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -3005,6 +3009,7 @@ final class SettingsStore: ObservableObject { SettingsBackupPayload( selectedProviderID: self.selectedProviderID, selectedModelByProvider: self.selectedModelByProvider, + customModelsByProvider: self.customModelsByProvider, savedProviders: self.savedProviders, modelReasoningConfigs: self.modelReasoningConfigs, privateAIPrefixKVCacheEnabled: self.privateAIPrefixKVCacheEnabled, @@ -3106,6 +3111,7 @@ final class SettingsStore: ObservableObject { self.savedProviders = payload.savedProviders self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider + self.customModelsByProvider = payload.customModelsByProvider ?? [:] self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 4230798f..a641fea7 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -164,6 +164,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.settings.reconcilePromptStateAfterProfileChanges() self.selectedProviderID = self.settings.selectedProviderID + let hasStoredCustomModels = self.settings.hasStoredCustomModelsByProvider self.availableModelsByProvider = self.settings.availableModelsByProvider self.customModelsByProvider = self.settings.customModelsByProvider self.selectedModelByProvider = self.settings.selectedModelByProvider @@ -206,6 +207,15 @@ final class AIEnhancementSettingsViewModel: ObservableObject { normalizedCustom[providerKey] = clean } } + if !hasStoredCustomModels { + normalizedCustom = Self.migratedLegacyCustomModels( + cachedModelsByProvider: normalized, + savedModelsByProvider: Dictionary( + self.savedProviders.map { ($0.id, $0.models) }, + uniquingKeysWith: { _, newer in newer } + ) + ) + } for (key, customModels) in normalizedCustom { normalized[key] = AIModelCatalog.merged( discoveredModels: normalized[key] ?? [], @@ -599,6 +609,42 @@ final class AIEnhancementSettingsViewModel: ObservableObject { ) } + static func migratedLegacyCustomModels( + cachedModelsByProvider: [String: [String]], + savedModelsByProvider: [String: [String]] + ) -> [String: [String]] { + var migrated: [String: [String]] = [:] + + func providerKey(_ providerID: String) -> String { + let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let lower = trimmed.lowercased() + if ModelRepository.shared.isBuiltIn(lower) { + return lower + } + return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" + } + + for (providerID, models) in cachedModelsByProvider.merging( + savedModelsByProvider, + uniquingKeysWith: { cached, saved in AIModelCatalog.merged( + discoveredModels: cached, + customModels: saved + ) } + ) { + let key = providerKey(providerID) + let normalized = AIModelCatalog.normalized(models) + if !key.isEmpty, !normalized.isEmpty { + migrated[key] = AIModelCatalog.merged( + discoveredModels: migrated[key] ?? [], + customModels: normalized + ) + } + } + + return migrated + } + func addNewModel() { let key = self.providerKey(for: self.selectedProviderID) let visibleModels = self.availableModelsByProvider[key] ?? self.availableModels @@ -1223,6 +1269,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.settings.selectedModelByProvider = self.selectedModelByProvider } + func canDeleteSelectedModel() -> Bool { + let key = self.providerKey(for: self.selectedProviderID) + return !ModelRepository.shared.isBuiltIn(self.selectedProviderID) || + (self.customModelsByProvider[key]?.contains(self.selectedModel) == true) + } + func fetchModelsForCurrentProvider() async { self.refreshingProviderID = self.selectedProviderID self.isFetchingModels = true diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index abb6c81e..42b61877 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -2420,7 +2420,7 @@ extension AIEnhancementSettingsView { controlHeight: AISettingsLayout.controlHeight ) - if !ModelRepository.shared.isBuiltIn(self.viewModel.selectedProviderID) { + if self.viewModel.canDeleteSelectedModel() { Button(action: { self.viewModel.deleteSelectedModel() }) { HStack(spacing: 4) { Image(systemName: "trash"); Text("Delete") }.font(.caption) } diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index 3523adff..5051b13a 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -53,6 +53,24 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testLegacyCachedAndSavedModelsMigrateBeforeFirstRefresh() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.migratedLegacyCustomModels( + cachedModelsByProvider: [ + "OpenAI": [" gpt-cached ", "gpt-shared"], + "legacy-provider": ["cached-model"], + ], + savedModelsByProvider: [ + "legacy-provider": ["saved-model", "cached-model"], + ] + ), + [ + "openai": ["gpt-cached", "gpt-shared"], + "custom:legacy-provider": ["cached-model", "saved-model"], + ] + ) + } + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { let selectedModel = "model/retired" let merged = AIModelCatalog.merged( From 867f6c74007340eb1cf1a4e3bf4ce5a6ca5973cf Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:41:59 +0400 Subject: [PATCH 04/15] chore(lint): allow legacy optional model backup Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/BackupService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 9a2a3599..9b4e5020 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -11,6 +11,7 @@ struct SettingsBackupPayload: Codable, Equatable { let selectedProviderID: String let selectedModelByProvider: [String: String] // Optional so backups created before custom model persistence still decode. + // swiftlint:disable:next discouraged_optional_collection let customModelsByProvider: [String: [String]]? let savedProviders: [SettingsStore.SavedProvider] let modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig] From 60fad9355218199e791857d3617bf7b2475ed476 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:58:40 +0400 Subject: [PATCH 05/15] fix(ai): narrow legacy model migration Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 11 +++- .../AIEnhancementSettingsViewModel.swift | 51 ++++++++++++------- .../AIModelRefreshTests.swift | 12 ++--- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index eb39b43f..a3aaab25 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1427,6 +1427,11 @@ final class SettingsStore: ObservableObject { self.defaults.object(forKey: Self.customModelsByProviderDefaultsKey) != nil } + func clearStoredCustomModelsByProvider() { + objectWillChange.send() + self.defaults.removeObject(forKey: Self.customModelsByProviderDefaultsKey) + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -3111,7 +3116,11 @@ final class SettingsStore: ObservableObject { self.savedProviders = payload.savedProviders self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider - self.customModelsByProvider = payload.customModelsByProvider ?? [:] + if let customModelsByProvider = payload.customModelsByProvider { + self.customModelsByProvider = customModelsByProvider + } else { + self.clearStoredCustomModelsByProvider() + } self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index a641fea7..4fb2c487 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -178,6 +178,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.isDictationPromptOff = self.settings.isDictationPromptOff self.isEditPromptOff = self.settings.isEditPromptOff + let legacyCachedModels = self.availableModelsByProvider + let legacySavedModels = Dictionary( + self.savedProviders.map { ($0.id, $0.models) }, + uniquingKeysWith: { _, newer in newer } + ) + if !self.selectedProviderID.isEmpty, !ModelRepository.shared.isBuiltIn(self.selectedProviderID), self.savedProviders.contains(where: { $0.id == self.selectedProviderID }) == false @@ -209,11 +215,8 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } if !hasStoredCustomModels { normalizedCustom = Self.migratedLegacyCustomModels( - cachedModelsByProvider: normalized, - savedModelsByProvider: Dictionary( - self.savedProviders.map { ($0.id, $0.models) }, - uniquingKeysWith: { _, newer in newer } - ) + cachedModelsByProvider: legacyCachedModels, + savedModelsByProvider: legacySavedModels ) } for (key, customModels) in normalizedCustom { @@ -625,20 +628,30 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" } - for (providerID, models) in cachedModelsByProvider.merging( - savedModelsByProvider, - uniquingKeysWith: { cached, saved in AIModelCatalog.merged( - discoveredModels: cached, - customModels: saved - ) } - ) { - let key = providerKey(providerID) - let normalized = AIModelCatalog.normalized(models) - if !key.isEmpty, !normalized.isEmpty { - migrated[key] = AIModelCatalog.merged( - discoveredModels: migrated[key] ?? [], - customModels: normalized - ) + func appendedManualModels(_ models: [String]) -> [String] { + let normalizedInOrder = models + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard normalizedInOrder.count > 1, + let firstAppendedIndex = (1 ..< normalizedInOrder.count).first(where: { + normalizedInOrder[$0] < normalizedInOrder[$0 - 1] + }) + else { + return [] + } + return AIModelCatalog.normalized(Array(normalizedInOrder[firstAppendedIndex...])) + } + + for source in [cachedModelsByProvider, savedModelsByProvider] { + for (providerID, models) in source { + let key = providerKey(providerID) + let appendedModels = appendedManualModels(models) + if !key.isEmpty, !appendedModels.isEmpty { + migrated[key] = AIModelCatalog.merged( + discoveredModels: migrated[key] ?? [], + customModels: appendedModels + ) + } } } diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index 5051b13a..d3865167 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -53,20 +53,20 @@ final class AIModelRefreshTests: XCTestCase { ) } - func testLegacyCachedAndSavedModelsMigrateBeforeFirstRefresh() { + func testLegacyAppendedModelsMigrateWithoutPromotingSortedCatalogs() { XCTAssertEqual( AIEnhancementSettingsViewModel.migratedLegacyCustomModels( cachedModelsByProvider: [ - "OpenAI": [" gpt-cached ", "gpt-shared"], - "legacy-provider": ["cached-model"], + "OpenAI": ["gpt-a", "gpt-z", " custom-mid "], + "sorted-provider": ["model-a", "model-b", "model-c"], ], savedModelsByProvider: [ - "legacy-provider": ["saved-model", "cached-model"], + "legacy-provider": ["model-a", "model-z", "manual-a", "manual-b"], ] ), [ - "openai": ["gpt-cached", "gpt-shared"], - "custom:legacy-provider": ["cached-model", "saved-model"], + "openai": ["custom-mid"], + "custom:legacy-provider": ["manual-a", "manual-b"], ] ) } From ce138ac12b27e81590233dcc20a30101f9e7abc1 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:54:37 +0400 Subject: [PATCH 06/15] fix(ai): reconcile legacy models on refresh Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 19 +++++ .../AIEnhancementSettingsViewModel.swift | 70 +++++++++++++++++++ .../AIModelRefreshTests.swift | 17 +++++ 3 files changed, 106 insertions(+) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index a3aaab25..834196f1 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -23,6 +23,7 @@ final class SettingsStore: ObservableObject { static let privateAIDictationRoundTripTokenCost = 2.75 static let privateAIBackendPreferenceDefaultsKey = "FluidIntelligenceBackendPreference" static let customModelsByProviderDefaultsKey = "CustomModelsByProvider" + static let legacyModelCandidatesByProviderDefaultsKey = "LegacyModelCandidatesByProvider" private static let forcedOnboardingResetIntroducedAt = Date(timeIntervalSince1970: 1_782_091_732) private let defaults = UserDefaults.standard private let keychain = KeychainService.shared @@ -1432,6 +1433,23 @@ final class SettingsStore: ObservableObject { self.defaults.removeObject(forKey: Self.customModelsByProviderDefaultsKey) } + var legacyModelCandidatesByProvider: [String: [String]] { + get { + (self.defaults.dictionary( + forKey: Self.legacyModelCandidatesByProviderDefaultsKey + ) as? [String: [String]]) ?? [:] + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Self.legacyModelCandidatesByProviderDefaultsKey) + } + } + + func clearStoredLegacyModelCandidatesByProvider() { + objectWillChange.send() + self.defaults.removeObject(forKey: Self.legacyModelCandidatesByProviderDefaultsKey) + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -3116,6 +3134,7 @@ final class SettingsStore: ObservableObject { self.savedProviders = payload.savedProviders self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider + self.clearStoredLegacyModelCandidatesByProvider() if let customModelsByProvider = payload.customModelsByProvider { self.customModelsByProvider = customModelsByProvider } else { diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 4fb2c487..ee85963f 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -21,6 +21,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var customModelsByProvider: [String: [String]] = [:] @Published var selectedModelByProvider: [String: String] = [:] @Published var availableModels: [String] = [] + private var legacyModelCandidatesByProvider: [String: [String]] = [:] @Published var selectedModel: String = "" { didSet { guard self.selectedModel != "__ADD_MODEL__" else { return } @@ -167,6 +168,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let hasStoredCustomModels = self.settings.hasStoredCustomModelsByProvider self.availableModelsByProvider = self.settings.availableModelsByProvider self.customModelsByProvider = self.settings.customModelsByProvider + self.legacyModelCandidatesByProvider = self.settings.legacyModelCandidatesByProvider self.selectedModelByProvider = self.settings.selectedModelByProvider self.providerAPIKeys = self.settings.providerAPIKeys self.savedProviders = self.settings.savedProviders @@ -214,6 +216,13 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } } if !hasStoredCustomModels { + if self.legacyModelCandidatesByProvider.isEmpty { + self.legacyModelCandidatesByProvider = Self.legacyModelCandidates( + cachedModelsByProvider: legacyCachedModels, + savedModelsByProvider: legacySavedModels + ) + self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider + } normalizedCustom = Self.migratedLegacyCustomModels( cachedModelsByProvider: legacyCachedModels, savedModelsByProvider: legacySavedModels @@ -658,6 +667,46 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return migrated } + static func legacyModelCandidates( + cachedModelsByProvider: [String: [String]], + savedModelsByProvider: [String: [String]] + ) -> [String: [String]] { + var candidates: [String: [String]] = [:] + + func providerKey(_ providerID: String) -> String { + let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let lower = trimmed.lowercased() + if ModelRepository.shared.isBuiltIn(lower) { + return lower + } + return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" + } + + for source in [cachedModelsByProvider, savedModelsByProvider] { + for (providerID, models) in source { + let key = providerKey(providerID) + let normalized = AIModelCatalog.normalized(models) + if !key.isEmpty, !normalized.isEmpty { + candidates[key] = AIModelCatalog.merged( + discoveredModels: candidates[key] ?? [], + customModels: normalized + ) + } + } + } + + return candidates + } + + static func reconciledLegacyCustomModels( + legacyModels: [String], + discoveredModels: [String] + ) -> [String] { + let discovered = Set(AIModelCatalog.normalized(discoveredModels)) + return AIModelCatalog.normalized(legacyModels).filter { !discovered.contains($0) } + } + func addNewModel() { let key = self.providerKey(for: self.selectedProviderID) let visibleModels = self.availableModelsByProvider[key] ?? self.availableModels @@ -1198,12 +1247,14 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let key = self.providerKey(for: self.selectedProviderID) self.availableModelsByProvider.removeValue(forKey: key) self.customModelsByProvider.removeValue(forKey: key) + self.legacyModelCandidatesByProvider.removeValue(forKey: key) self.selectedModelByProvider.removeValue(forKey: key) self.providerAPIKeys.removeValue(forKey: key) self.saveProviderAPIKeys() self.settings.verifiedProviderFingerprints.removeValue(forKey: key) self.settings.availableModelsByProvider = self.availableModelsByProvider self.settings.customModelsByProvider = self.customModelsByProvider + self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider self.settings.selectedModelByProvider = self.selectedModelByProvider self.selectedProviderID = "" self.openAIBaseURL = "" @@ -1218,6 +1269,20 @@ final class AIEnhancementSettingsViewModel: ObservableObject { discoveredModels: [String], providerKey: String ) -> [String] { + if let legacyModels = self.legacyModelCandidatesByProvider.removeValue(forKey: providerKey) { + let reconciled = Self.reconciledLegacyCustomModels( + legacyModels: legacyModels, + discoveredModels: discoveredModels + ) + if !reconciled.isEmpty { + self.customModelsByProvider[providerKey] = AIModelCatalog.merged( + discoveredModels: self.customModelsByProvider[providerKey] ?? [], + customModels: reconciled + ) + } + self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider + self.settings.customModelsByProvider = self.customModelsByProvider + } return AIModelCatalog.merged( discoveredModels: discoveredModels, customModels: self.customModelsByProvider[providerKey] ?? [] @@ -1254,10 +1319,15 @@ final class AIEnhancementSettingsViewModel: ObservableObject { func deleteSelectedModel() { let key = self.providerKey(for: self.selectedProviderID) self.customModelsByProvider[key]?.removeAll { $0 == self.selectedModel } + self.legacyModelCandidatesByProvider[key]?.removeAll { $0 == self.selectedModel } if self.customModelsByProvider[key]?.isEmpty == true { self.customModelsByProvider.removeValue(forKey: key) } + if self.legacyModelCandidatesByProvider[key]?.isEmpty == true { + self.legacyModelCandidatesByProvider.removeValue(forKey: key) + } self.settings.customModelsByProvider = self.customModelsByProvider + self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider var list = self.availableModelsByProvider[key] ?? self.availableModels list.removeAll { $0 == self.selectedModel } diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index d3865167..e1f2ba70 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -71,6 +71,23 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testLegacyModelsReconcileAgainstFirstFreshCatalog() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.reconciledLegacyCustomModels( + legacyModels: ["gpt-4.1", "gpt-5-custom"], + discoveredModels: ["gpt-4.1"] + ), + ["gpt-5-custom"] + ) + XCTAssertEqual( + AIEnhancementSettingsViewModel.reconciledLegacyCustomModels( + legacyModels: ["model-a", "model-b", "model-c"], + discoveredModels: ["model-a", "model-b", "model-c"] + ), + [] + ) + } + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { let selectedModel = "model/retired" let merged = AIModelCatalog.merged( From b9ec6e8d7ceeadb8308ecc447d43455372d67636 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:56:28 +0400 Subject: [PATCH 07/15] chore(lint): shorten migration defaults key Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 834196f1..15bd08e4 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -23,7 +23,7 @@ final class SettingsStore: ObservableObject { static let privateAIDictationRoundTripTokenCost = 2.75 static let privateAIBackendPreferenceDefaultsKey = "FluidIntelligenceBackendPreference" static let customModelsByProviderDefaultsKey = "CustomModelsByProvider" - static let legacyModelCandidatesByProviderDefaultsKey = "LegacyModelCandidatesByProvider" + static let legacyModelCandidatesDefaultsKey = "LegacyModelCandidatesByProvider" private static let forcedOnboardingResetIntroducedAt = Date(timeIntervalSince1970: 1_782_091_732) private let defaults = UserDefaults.standard private let keychain = KeychainService.shared @@ -1436,18 +1436,18 @@ final class SettingsStore: ObservableObject { var legacyModelCandidatesByProvider: [String: [String]] { get { (self.defaults.dictionary( - forKey: Self.legacyModelCandidatesByProviderDefaultsKey + forKey: Self.legacyModelCandidatesDefaultsKey ) as? [String: [String]]) ?? [:] } set { objectWillChange.send() - self.defaults.set(newValue, forKey: Self.legacyModelCandidatesByProviderDefaultsKey) + self.defaults.set(newValue, forKey: Self.legacyModelCandidatesDefaultsKey) } } func clearStoredLegacyModelCandidatesByProvider() { objectWillChange.send() - self.defaults.removeObject(forKey: Self.legacyModelCandidatesByProviderDefaultsKey) + self.defaults.removeObject(forKey: Self.legacyModelCandidatesDefaultsKey) } var enableDebugLogs: Bool { From 3acf752a5bddfd174be412bf8c8fd1e19d5982a2 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:07:57 +0400 Subject: [PATCH 08/15] fix(ai): preserve custom models across migration Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 2 - .../AIEnhancementSettingsViewModel.swift | 49 ++++++++----------- .../AIModelRefreshTests.swift | 27 ++++++++++ 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 15bd08e4..4ae35bf9 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -3137,8 +3137,6 @@ final class SettingsStore: ObservableObject { self.clearStoredLegacyModelCandidatesByProvider() if let customModelsByProvider = payload.customModelsByProvider { self.customModelsByProvider = customModelsByProvider - } else { - self.clearStoredCustomModelsByProvider() } self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index ee85963f..a93d4a66 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -229,9 +229,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { ) } for (key, customModels) in normalizedCustom { - normalized[key] = AIModelCatalog.merged( - discoveredModels: normalized[key] ?? [], - customModels: customModels + normalized[key] = Self.modelsByMergingCustomModels( + normalized[key], + customModels: customModels, + providerKey: key ) } self.availableModelsByProvider = normalized @@ -671,32 +672,24 @@ final class AIEnhancementSettingsViewModel: ObservableObject { cachedModelsByProvider: [String: [String]], savedModelsByProvider: [String: [String]] ) -> [String: [String]] { - var candidates: [String: [String]] = [:] - - func providerKey(_ providerID: String) -> String { - let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - let lower = trimmed.lowercased() - if ModelRepository.shared.isBuiltIn(lower) { - return lower - } - return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" - } - - for source in [cachedModelsByProvider, savedModelsByProvider] { - for (providerID, models) in source { - let key = providerKey(providerID) - let normalized = AIModelCatalog.normalized(models) - if !key.isEmpty, !normalized.isEmpty { - candidates[key] = AIModelCatalog.merged( - discoveredModels: candidates[key] ?? [], - customModels: normalized - ) - } - } - } + self.migratedLegacyCustomModels( + cachedModelsByProvider: cachedModelsByProvider, + savedModelsByProvider: savedModelsByProvider + ) + } - return candidates + static func modelsByMergingCustomModels( + _ discoveredModels: [String]?, + customModels: [String], + providerKey: String + ) -> [String] { + let discoveredFallback = ModelRepository.shared.isBuiltIn(providerKey) + ? ModelRepository.shared.defaultModels(for: providerKey) + : [] + return AIModelCatalog.merged( + discoveredModels: discoveredModels ?? discoveredFallback, + customModels: customModels + ) } static func reconciledLegacyCustomModels( diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index e1f2ba70..1a997757 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -88,6 +88,33 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testLegacyCandidatesExcludeSortedDiscoveryOnlyCatalogs() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.legacyModelCandidates( + cachedModelsByProvider: [ + "openai": ["gpt-a", "gpt-b", "gpt-retired"], + "groq": ["model-a", "model-z", "manual-model"], + ], + savedModelsByProvider: [:] + ), + ["groq": ["manual-model"]] + ) + } + + func testCustomModelsMergeWithBuiltInDefaultsWhenCacheIsMissing() { + let defaultModels = ModelRepository.shared.defaultModels(for: "openai") + XCTAssertFalse(defaultModels.isEmpty) + + XCTAssertEqual( + AIEnhancementSettingsViewModel.modelsByMergingCustomModels( + nil, + customModels: ["gpt-custom"], + providerKey: "openai" + ), + AIModelCatalog.normalized(defaultModels + ["gpt-custom"]) + ) + } + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { let selectedModel = "model/retired" let merged = AIModelCatalog.merged( From b6b1cd114c47b1bbe339cc03d698dd12f33ac2ad Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:12:32 +0400 Subject: [PATCH 09/15] chore(lint): avoid optional model catalog Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- .../AIEnhancementSettingsViewModel.swift | 17 ++++++++++------- .../AIModelRefreshTests.swift | 5 +++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index a93d4a66..a87f1926 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -229,10 +229,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { ) } for (key, customModels) in normalizedCustom { + let hasDiscoveredModels = normalized[key] != nil normalized[key] = Self.modelsByMergingCustomModels( - normalized[key], + normalized[key] ?? [], customModels: customModels, - providerKey: key + providerKey: key, + useDefaultModels: !hasDiscoveredModels ) } self.availableModelsByProvider = normalized @@ -679,15 +681,16 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } static func modelsByMergingCustomModels( - _ discoveredModels: [String]?, + _ discoveredModels: [String], customModels: [String], - providerKey: String + providerKey: String, + useDefaultModels: Bool ) -> [String] { - let discoveredFallback = ModelRepository.shared.isBuiltIn(providerKey) + let discoveredFallback = useDefaultModels && ModelRepository.shared.isBuiltIn(providerKey) ? ModelRepository.shared.defaultModels(for: providerKey) - : [] + : discoveredModels return AIModelCatalog.merged( - discoveredModels: discoveredModels ?? discoveredFallback, + discoveredModels: discoveredFallback, customModels: customModels ) } diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index 1a997757..14503510 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -107,9 +107,10 @@ final class AIModelRefreshTests: XCTestCase { XCTAssertEqual( AIEnhancementSettingsViewModel.modelsByMergingCustomModels( - nil, + [], customModels: ["gpt-custom"], - providerKey: "openai" + providerKey: "openai", + useDefaultModels: true ), AIModelCatalog.normalized(defaultModels + ["gpt-custom"]) ) From a1f7e85b24b805396afd01d95ca486122d08f8f1 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:35:42 +0400 Subject: [PATCH 10/15] fix(ai): handle empty model state transitions --- Sources/Fluid/Persistence/SettingsStore.swift | 14 ++++++-- .../AIEnhancementSettingsViewModel.swift | 15 +++++++- .../AIModelRefreshTests.swift | 36 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 4ae35bf9..f4a45b3e 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1433,6 +1433,16 @@ final class SettingsStore: ObservableObject { self.defaults.removeObject(forKey: Self.customModelsByProviderDefaultsKey) } + func restoreCustomModelsByProvider(_ models: [String: [String]]?) { + if let models { + self.customModelsByProvider = models + } else { + // Legacy backups predate the dedicated custom-model store. Remove + // local state so the restored provider catalogs can be migrated. + self.clearStoredCustomModelsByProvider() + } + } + var legacyModelCandidatesByProvider: [String: [String]] { get { (self.defaults.dictionary( @@ -3135,9 +3145,7 @@ final class SettingsStore: ObservableObject { self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider self.clearStoredLegacyModelCandidatesByProvider() - if let customModelsByProvider = payload.customModelsByProvider { - self.customModelsByProvider = customModelsByProvider - } + self.restoreCustomModelsByProvider(payload.customModelsByProvider) self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index a87f1926..88174fff 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -703,9 +703,22 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return AIModelCatalog.normalized(legacyModels).filter { !discovered.contains($0) } } + static func visibleModelsForManualAddition( + _ cachedModels: [String], + providerKey: String + ) -> [String] { + self.modelsByMergingCustomModels( + cachedModels, + customModels: [], + providerKey: providerKey, + useDefaultModels: cachedModels.isEmpty + ) + } + func addNewModel() { let key = self.providerKey(for: self.selectedProviderID) - let visibleModels = self.availableModelsByProvider[key] ?? self.availableModels + let cachedModels = self.availableModelsByProvider[key] ?? self.availableModels + let visibleModels = Self.visibleModelsForManualAddition(cachedModels, providerKey: key) let customModels = self.customModelsByProvider[key] ?? [] guard let addition = Self.manualModelAddition( self.newModelName, diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index 14503510..cbceb454 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -23,6 +23,24 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testRestoringLegacyBackupClearsStoredCustomModels() { + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: SettingsStore.customModelsByProviderDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: SettingsStore.customModelsByProviderDefaultsKey) + } else { + defaults.removeObject(forKey: SettingsStore.customModelsByProviderDefaultsKey) + } + } + + SettingsStore.shared.customModelsByProvider = ["openai": ["stale-local-model"]] + SettingsStore.shared.restoreCustomModelsByProvider(nil) + + XCTAssertFalse(SettingsStore.shared.hasStoredCustomModelsByProvider) + XCTAssertTrue(SettingsStore.shared.customModelsByProvider.isEmpty) + } + func testEnteringDiscoveredModelSelectsWithoutPersistingAsCustom() { XCTAssertEqual( AIEnhancementSettingsViewModel.manualModelAddition( @@ -116,6 +134,24 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testManualAdditionStartsWithBuiltInDefaultsWhenCacheIsMissing() { + let defaultModels = ModelRepository.shared.defaultModels(for: "openai") + let visibleModels = AIEnhancementSettingsViewModel.visibleModelsForManualAddition( + [], + providerKey: "openai" + ) + + XCTAssertEqual(visibleModels, AIModelCatalog.normalized(defaultModels)) + XCTAssertEqual( + AIEnhancementSettingsViewModel.manualModelAddition( + "gpt-custom", + visibleModels: visibleModels, + customModels: [] + )?.visibleModels, + AIModelCatalog.normalized(defaultModels + ["gpt-custom"]) + ) + } + func testRefreshDropsSelectedNonCustomModelMissingFromCatalog() { let selectedModel = "model/retired" let merged = AIModelCatalog.merged( From c1f44ce464feb06a48de68760d1c2553b5ad63fb Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:42:51 +0400 Subject: [PATCH 11/15] chore(lint): keep backup restore signature concrete --- Sources/Fluid/Persistence/SettingsStore.swift | 18 +++++++----------- .../AIModelRefreshTests.swift | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index f4a45b3e..23aeeebc 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1433,16 +1433,6 @@ final class SettingsStore: ObservableObject { self.defaults.removeObject(forKey: Self.customModelsByProviderDefaultsKey) } - func restoreCustomModelsByProvider(_ models: [String: [String]]?) { - if let models { - self.customModelsByProvider = models - } else { - // Legacy backups predate the dedicated custom-model store. Remove - // local state so the restored provider catalogs can be migrated. - self.clearStoredCustomModelsByProvider() - } - } - var legacyModelCandidatesByProvider: [String: [String]] { get { (self.defaults.dictionary( @@ -3145,7 +3135,13 @@ final class SettingsStore: ObservableObject { self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider self.clearStoredLegacyModelCandidatesByProvider() - self.restoreCustomModelsByProvider(payload.customModelsByProvider) + if let customModelsByProvider = payload.customModelsByProvider { + self.customModelsByProvider = customModelsByProvider + } else { + // Legacy backups predate the dedicated custom-model store. Remove + // local state so the restored provider catalogs can be migrated. + self.clearStoredCustomModelsByProvider() + } self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index cbceb454..e181ac7a 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -35,7 +35,7 @@ final class AIModelRefreshTests: XCTestCase { } SettingsStore.shared.customModelsByProvider = ["openai": ["stale-local-model"]] - SettingsStore.shared.restoreCustomModelsByProvider(nil) + SettingsStore.shared.clearStoredCustomModelsByProvider() XCTAssertFalse(SettingsStore.shared.hasStoredCustomModelsByProvider) XCTAssertTrue(SettingsStore.shared.customModelsByProvider.isEmpty) From d13a5f21d4878acf5717d16e8fed21396077a405 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:20 +0400 Subject: [PATCH 12/15] fix(ai): restore model catalog state consistently Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 78 ++++++++++++++++-- .../AIEnhancementSettingsViewModel.swift | 31 ++++++-- .../AIModelRefreshTests.swift | 79 ++++++++++++++++++- 3 files changed, 175 insertions(+), 13 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 23aeeebc..3ff64f29 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1450,6 +1450,74 @@ final class SettingsStore: ObservableObject { self.defaults.removeObject(forKey: Self.legacyModelCandidatesDefaultsKey) } + static func availableModelsAfterRestore( + customModelsByProvider: [String: [String]], + savedProviders: [SavedProvider] + ) -> [String: [String]] { + func normalized(_ models: [String]) -> [String] { + var seen: Set = [] + return models.compactMap { model in + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + } + + func providerKey(_ providerID: String) -> String { + let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let lower = trimmed.lowercased() + if ModelRepository.shared.isBuiltIn(lower) { + return lower + } + return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" + } + + var restored: [String: [String]] = [:] + for provider in savedProviders { + let key = providerKey(provider.id) + let models = normalized(provider.models) + if !key.isEmpty, !models.isEmpty { + restored[key] = models + } + } + + for (providerID, customModels) in customModelsByProvider { + let key = providerKey(providerID) + guard !key.isEmpty else { continue } + let defaults = ModelRepository.shared.isBuiltIn(key) + ? ModelRepository.shared.defaultModels(for: key) + : restored[key] ?? [] + let models = normalized(defaults + customModels) + if models.isEmpty { + restored.removeValue(forKey: key) + } else { + restored[key] = models + } + } + return restored + } + + func restoreModelCatalogState( + customModelsByProvider: [String: [String]], + savedProviders: [SavedProvider] + ) { + self.availableModels = [] + self.clearStoredLegacyModelCandidatesByProvider() + self.customModelsByProvider = customModelsByProvider + self.availableModelsByProvider = Self.availableModelsAfterRestore( + customModelsByProvider: customModelsByProvider, + savedProviders: savedProviders + ) + } + + func prepareLegacyModelCatalogRestore() { + self.availableModels = [] + self.availableModelsByProvider = [:] + self.clearStoredCustomModelsByProvider() + self.clearStoredLegacyModelCandidatesByProvider() + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -3134,13 +3202,13 @@ final class SettingsStore: ObservableObject { self.savedProviders = payload.savedProviders self.selectedProviderID = payload.selectedProviderID self.selectedModelByProvider = payload.selectedModelByProvider - self.clearStoredLegacyModelCandidatesByProvider() if let customModelsByProvider = payload.customModelsByProvider { - self.customModelsByProvider = customModelsByProvider + self.restoreModelCatalogState( + customModelsByProvider: customModelsByProvider, + savedProviders: self.savedProviders + ) } else { - // Legacy backups predate the dedicated custom-model store. Remove - // local state so the restored provider catalogs can be migrated. - self.clearStoredCustomModelsByProvider() + self.prepareLegacyModelCatalogRestore() } self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 88174fff..ec6716d8 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -703,6 +703,23 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return AIModelCatalog.normalized(legacyModels).filter { !discovered.contains($0) } } + static func customModelsAfterReconcilingLegacyCandidates( + customModels: [String], + legacyCandidates: [String], + discoveredModels: [String] + ) -> [String] { + let candidates = Set(AIModelCatalog.normalized(legacyCandidates)) + let retainedCustomModels = AIModelCatalog.normalized(customModels).filter { + !candidates.contains($0) + } + return AIModelCatalog.normalized( + retainedCustomModels + self.reconciledLegacyCustomModels( + legacyModels: legacyCandidates, + discoveredModels: discoveredModels + ) + ) + } + static func visibleModelsForManualAddition( _ cachedModels: [String], providerKey: String @@ -1279,15 +1296,15 @@ final class AIEnhancementSettingsViewModel: ObservableObject { providerKey: String ) -> [String] { if let legacyModels = self.legacyModelCandidatesByProvider.removeValue(forKey: providerKey) { - let reconciled = Self.reconciledLegacyCustomModels( - legacyModels: legacyModels, + let reconciledCustomModels = Self.customModelsAfterReconcilingLegacyCandidates( + customModels: self.customModelsByProvider[providerKey] ?? [], + legacyCandidates: legacyModels, discoveredModels: discoveredModels ) - if !reconciled.isEmpty { - self.customModelsByProvider[providerKey] = AIModelCatalog.merged( - discoveredModels: self.customModelsByProvider[providerKey] ?? [], - customModels: reconciled - ) + if reconciledCustomModels.isEmpty { + self.customModelsByProvider.removeValue(forKey: providerKey) + } else { + self.customModelsByProvider[providerKey] = reconciledCustomModels } self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider self.settings.customModelsByProvider = self.customModelsByProvider diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index e181ac7a..390c8385 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -26,19 +26,77 @@ final class AIModelRefreshTests: XCTestCase { func testRestoringLegacyBackupClearsStoredCustomModels() { let defaults = UserDefaults.standard let previousValue = defaults.object(forKey: SettingsStore.customModelsByProviderDefaultsKey) + let previousAvailableModels = SettingsStore.shared.availableModels + let previousAvailableModelsByProvider = SettingsStore.shared.availableModelsByProvider + let previousLegacyCandidates = SettingsStore.shared.legacyModelCandidatesByProvider defer { if let previousValue { defaults.set(previousValue, forKey: SettingsStore.customModelsByProviderDefaultsKey) } else { defaults.removeObject(forKey: SettingsStore.customModelsByProviderDefaultsKey) } + SettingsStore.shared.availableModels = previousAvailableModels + SettingsStore.shared.availableModelsByProvider = previousAvailableModelsByProvider + SettingsStore.shared.legacyModelCandidatesByProvider = previousLegacyCandidates } SettingsStore.shared.customModelsByProvider = ["openai": ["stale-local-model"]] - SettingsStore.shared.clearStoredCustomModelsByProvider() + SettingsStore.shared.availableModels = ["stale-local-model"] + SettingsStore.shared.availableModelsByProvider = ["openai": ["gpt-default", "stale-local-model"]] + SettingsStore.shared.legacyModelCandidatesByProvider = ["openai": ["stale-local-model"]] + SettingsStore.shared.prepareLegacyModelCatalogRestore() XCTAssertFalse(SettingsStore.shared.hasStoredCustomModelsByProvider) XCTAssertTrue(SettingsStore.shared.customModelsByProvider.isEmpty) + XCTAssertTrue(SettingsStore.shared.availableModels.isEmpty) + XCTAssertTrue(SettingsStore.shared.availableModelsByProvider.isEmpty) + XCTAssertTrue(SettingsStore.shared.legacyModelCandidatesByProvider.isEmpty) + } + + func testRestoringCustomModelsRehydratesAvailableCatalog() { + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: SettingsStore.customModelsByProviderDefaultsKey) + let previousAvailableModels = SettingsStore.shared.availableModels + let previousAvailableModelsByProvider = SettingsStore.shared.availableModelsByProvider + let previousLegacyCandidates = SettingsStore.shared.legacyModelCandidatesByProvider + defer { + if let previousValue { + defaults.set(previousValue, forKey: SettingsStore.customModelsByProviderDefaultsKey) + } else { + defaults.removeObject(forKey: SettingsStore.customModelsByProviderDefaultsKey) + } + SettingsStore.shared.availableModels = previousAvailableModels + SettingsStore.shared.availableModelsByProvider = previousAvailableModelsByProvider + SettingsStore.shared.legacyModelCandidatesByProvider = previousLegacyCandidates + } + + SettingsStore.shared.availableModelsByProvider = [:] + SettingsStore.shared.restoreModelCatalogState( + customModelsByProvider: [ + "openai": ["gpt-custom"], + "custom:provider-id": ["provider-custom"], + ], + savedProviders: [ + SettingsStore.SavedProvider( + id: "provider-id", + name: "Provider", + baseURL: "https://example.com", + models: ["provider-discovered"] + ), + ] + ) + + XCTAssertEqual(SettingsStore.shared.customModelsByProvider["openai"], ["gpt-custom"]) + XCTAssertEqual( + SettingsStore.shared.availableModelsByProvider["openai"], + AIModelCatalog.normalized( + ModelRepository.shared.defaultModels(for: "openai") + ["gpt-custom"] + ) + ) + XCTAssertEqual( + SettingsStore.shared.availableModelsByProvider["custom:provider-id"], + ["provider-discovered", "provider-custom"] + ) } func testEnteringDiscoveredModelSelectsWithoutPersistingAsCustom() { @@ -106,6 +164,25 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testLegacyCandidateReconciliationReplacesCandidateSubset() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.customModelsAfterReconcilingLegacyCandidates( + customModels: ["official-model", "new-manual-model", "legacy-only-model"], + legacyCandidates: ["official-model", "legacy-only-model"], + discoveredModels: ["official-model"] + ), + ["new-manual-model", "legacy-only-model"] + ) + XCTAssertEqual( + AIEnhancementSettingsViewModel.customModelsAfterReconcilingLegacyCandidates( + customModels: ["official-model"], + legacyCandidates: ["official-model"], + discoveredModels: ["official-model"] + ), + [] + ) + } + func testLegacyCandidatesExcludeSortedDiscoveryOnlyCatalogs() { XCTAssertEqual( AIEnhancementSettingsViewModel.legacyModelCandidates( From c056f5168620620b281af93ae29c49f3b05234ec Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:50:12 +0400 Subject: [PATCH 13/15] fix(ai): preserve legacy models through backup restore Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- Sources/Fluid/Persistence/SettingsStore.swift | 25 +++++-- .../AIEnhancementSettingsViewModel.swift | 44 ++---------- .../Fluid/UI/AISettings/AIModelCatalog.swift | 46 ++++++++++++ .../AIModelRefreshTests.swift | 70 ++++++++++++++++++- 4 files changed, 140 insertions(+), 45 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 3ff64f29..944aa6d5 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1511,13 +1511,30 @@ final class SettingsStore: ObservableObject { ) } - func prepareLegacyModelCatalogRestore() { + func prepareLegacyModelCatalogRestore(savedProviders: [SavedProvider]) { self.availableModels = [] - self.availableModelsByProvider = [:] + self.availableModelsByProvider = Self.availableModelsAfterRestore( + customModelsByProvider: [:], + savedProviders: savedProviders + ) self.clearStoredCustomModelsByProvider() self.clearStoredLegacyModelCandidatesByProvider() } + func customModelsByProviderForBackup() -> [String: [String]] { + guard !self.hasStoredCustomModelsByProvider else { + return self.customModelsByProvider + } + let savedModelsByProvider = Dictionary( + self.savedProviders.map { ($0.id, $0.models) }, + uniquingKeysWith: { _, newer in newer } + ) + return AIModelCatalog.migratedLegacyCustomModels( + cachedModelsByProvider: self.availableModelsByProvider, + savedModelsByProvider: savedModelsByProvider + ) + } + var enableDebugLogs: Bool { get { let value = self.defaults.object(forKey: Keys.enableDebugLogs) @@ -3100,7 +3117,7 @@ final class SettingsStore: ObservableObject { SettingsBackupPayload( selectedProviderID: self.selectedProviderID, selectedModelByProvider: self.selectedModelByProvider, - customModelsByProvider: self.customModelsByProvider, + customModelsByProvider: self.customModelsByProviderForBackup(), savedProviders: self.savedProviders, modelReasoningConfigs: self.modelReasoningConfigs, privateAIPrefixKVCacheEnabled: self.privateAIPrefixKVCacheEnabled, @@ -3208,7 +3225,7 @@ final class SettingsStore: ObservableObject { savedProviders: self.savedProviders ) } else { - self.prepareLegacyModelCatalogRestore() + self.prepareLegacyModelCatalogRestore(savedProviders: self.savedProviders) } self.modelReasoningConfigs = payload.modelReasoningConfigs if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index ec6716d8..d8c74cab 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -628,46 +628,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { cachedModelsByProvider: [String: [String]], savedModelsByProvider: [String: [String]] ) -> [String: [String]] { - var migrated: [String: [String]] = [:] - - func providerKey(_ providerID: String) -> String { - let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - let lower = trimmed.lowercased() - if ModelRepository.shared.isBuiltIn(lower) { - return lower - } - return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" - } - - func appendedManualModels(_ models: [String]) -> [String] { - let normalizedInOrder = models - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard normalizedInOrder.count > 1, - let firstAppendedIndex = (1 ..< normalizedInOrder.count).first(where: { - normalizedInOrder[$0] < normalizedInOrder[$0 - 1] - }) - else { - return [] - } - return AIModelCatalog.normalized(Array(normalizedInOrder[firstAppendedIndex...])) - } - - for source in [cachedModelsByProvider, savedModelsByProvider] { - for (providerID, models) in source { - let key = providerKey(providerID) - let appendedModels = appendedManualModels(models) - if !key.isEmpty, !appendedModels.isEmpty { - migrated[key] = AIModelCatalog.merged( - discoveredModels: migrated[key] ?? [], - customModels: appendedModels - ) - } - } - } - - return migrated + AIModelCatalog.migratedLegacyCustomModels( + cachedModelsByProvider: cachedModelsByProvider, + savedModelsByProvider: savedModelsByProvider + ) } static func legacyModelCandidates( diff --git a/Sources/Fluid/UI/AISettings/AIModelCatalog.swift b/Sources/Fluid/UI/AISettings/AIModelCatalog.swift index 773b12b8..831a2da5 100644 --- a/Sources/Fluid/UI/AISettings/AIModelCatalog.swift +++ b/Sources/Fluid/UI/AISettings/AIModelCatalog.swift @@ -29,4 +29,50 @@ enum AIModelCatalog { return trimmed } } + + static func migratedLegacyCustomModels( + cachedModelsByProvider: [String: [String]], + savedModelsByProvider: [String: [String]] + ) -> [String: [String]] { + var migrated: [String: [String]] = [:] + + func providerKey(_ providerID: String) -> String { + let trimmed = providerID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let lower = trimmed.lowercased() + if ModelRepository.shared.isBuiltIn(lower) { + return lower + } + return trimmed.hasPrefix("custom:") ? trimmed : "custom:\(trimmed)" + } + + func appendedManualModels(_ models: [String]) -> [String] { + let normalizedInOrder = models + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard normalizedInOrder.count > 1, + let firstAppendedIndex = (1.. Date: Thu, 23 Jul 2026 13:56:49 +0400 Subject: [PATCH 14/15] test(ai): model legacy append ordering Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- .../FluidDictationIntegrationTests/AIModelRefreshTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index 6f61382e..fb3a0f06 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -101,7 +101,7 @@ final class AIModelRefreshTests: XCTestCase { SettingsStore.shared.clearStoredCustomModelsByProvider() SettingsStore.shared.availableModelsByProvider = [ - "openai": ["gpt-a", "gpt-z", "manual-a"], + "openai": ["gpt-a", "gpt-z", "custom-mid"], ] SettingsStore.shared.savedProviders = [ SettingsStore.SavedProvider( @@ -114,7 +114,7 @@ final class AIModelRefreshTests: XCTestCase { let payload = SettingsStore.shared.makeBackupPayload() - XCTAssertEqual(payload.customModelsByProvider?["openai"], ["manual-a"]) + XCTAssertEqual(payload.customModelsByProvider?["openai"], ["custom-mid"]) XCTAssertEqual( payload.customModelsByProvider?["custom:provider-id"], ["manual-provider"] From 4917898a81b5eb57d112dcf4fa7c3848f886fb53 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:20:38 +0400 Subject: [PATCH 15/15] fix(ai): allow deleting custom provider models Signed-off-by: Floze <88098863+floze-the-genius@users.noreply.github.com> --- .../AIEnhancementSettingsViewModel.swift | 80 +++++++++++++++---- .../UI/AISettingsView+AIConfiguration.swift | 15 ++++ .../AIModelRefreshTests.swift | 47 +++++++++++ 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index d8c74cab..ce8627f4 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -109,6 +109,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let customModels: [String] } + struct ManualModelDeletion: Equatable { + let visibleModels: [String] + let customModels: [String] + let selectedModel: String + } + @Published var cachedProviderItems: [ProviderItemData] = [] @Published var cachedVerifiedProviderItems: [ProviderItemData] = [] @Published var cachedUnverifiedProviderItems: [ProviderItemData] = [] @@ -1306,46 +1312,86 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.invalidateVerification(for: self.selectedProviderID) } - func deleteSelectedModel() { - let key = self.providerKey(for: self.selectedProviderID) - self.customModelsByProvider[key]?.removeAll { $0 == self.selectedModel } - self.legacyModelCandidatesByProvider[key]?.removeAll { $0 == self.selectedModel } - if self.customModelsByProvider[key]?.isEmpty == true { + func deleteSelectedModel(for providerID: String) { + let key = self.providerKey(for: providerID) + let selectedModel = self.selectedModelByProvider[key] ?? "" + let visibleModels = self.availableModelsByProvider[key] ?? [] + guard let deletion = Self.manualModelDeletion( + selectedModel, + visibleModels: visibleModels, + customModels: self.customModelsByProvider[key] ?? [], + fallbackModels: ModelRepository.shared.defaultModels(for: key) + ) else { return } + + if deletion.customModels.isEmpty { self.customModelsByProvider.removeValue(forKey: key) + } else { + self.customModelsByProvider[key] = deletion.customModels } + self.legacyModelCandidatesByProvider[key]?.removeAll { $0 == selectedModel } if self.legacyModelCandidatesByProvider[key]?.isEmpty == true { self.legacyModelCandidatesByProvider.removeValue(forKey: key) } self.settings.customModelsByProvider = self.customModelsByProvider self.settings.legacyModelCandidatesByProvider = self.legacyModelCandidatesByProvider - var list = self.availableModelsByProvider[key] ?? self.availableModels - list.removeAll { $0 == self.selectedModel } - if list.isEmpty { list = ModelRepository.shared.defaultModels(for: key) } - self.availableModelsByProvider[key] = list + self.availableModelsByProvider[key] = deletion.visibleModels self.settings.availableModelsByProvider = self.availableModelsByProvider - if let providerIndex = savedProviders.firstIndex(where: { $0.id == selectedProviderID }) { + if let providerIndex = savedProviders.firstIndex(where: { $0.id == providerID }) { let updatedProvider = SettingsStore.SavedProvider( id: self.savedProviders[providerIndex].id, name: self.savedProviders[providerIndex].name, baseURL: self.savedProviders[providerIndex].baseURL, - models: list + models: deletion.visibleModels ) self.savedProviders[providerIndex] = updatedProvider self.saveSavedProviders() } - self.availableModels = list - self.selectedModel = list.first ?? "" - self.selectedModelByProvider[key] = self.selectedModel + self.selectedModelByProvider[key] = deletion.selectedModel self.settings.selectedModelByProvider = self.selectedModelByProvider + if self.selectedProviderID == providerID { + self.availableModels = deletion.visibleModels + self.selectedModel = deletion.selectedModel + } + } + + func deleteSelectedModel() { + self.deleteSelectedModel(for: self.selectedProviderID) + } + + func canDeleteSelectedModel(for providerID: String) -> Bool { + let key = self.providerKey(for: providerID) + guard let selectedModel = self.selectedModelByProvider[key] else { return false } + return self.customModelsByProvider[key]?.contains(selectedModel) == true } func canDeleteSelectedModel() -> Bool { - let key = self.providerKey(for: self.selectedProviderID) - return !ModelRepository.shared.isBuiltIn(self.selectedProviderID) || - (self.customModelsByProvider[key]?.contains(self.selectedModel) == true) + self.canDeleteSelectedModel(for: self.selectedProviderID) + } + + static func manualModelDeletion( + _ selectedModel: String, + visibleModels: [String], + customModels: [String], + fallbackModels: [String] + ) -> ManualModelDeletion? { + let customModels = AIModelCatalog.normalized(customModels) + guard customModels.contains(selectedModel) else { return nil } + + let remainingCustomModels = customModels.filter { $0 != selectedModel } + var remainingVisibleModels = AIModelCatalog.normalized( + visibleModels.filter { $0 != selectedModel } + ) + if remainingVisibleModels.isEmpty { + remainingVisibleModels = AIModelCatalog.normalized(fallbackModels) + } + return ManualModelDeletion( + visibleModels: remainingVisibleModels, + customModels: remainingCustomModels, + selectedModel: remainingVisibleModels.first ?? "" + ) } func fetchModelsForCurrentProvider() async { diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 42b61877..6ad81922 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -1442,6 +1442,8 @@ extension AIEnhancementSettingsView { self.viewModel.newModelName = "" self.viewModel.showingAddModel = true } + + self.deleteCustomModelButton(for: item.id) } if self.viewModel.showingAddModel && self.viewModel.selectedProviderID == item.id { @@ -1732,6 +1734,9 @@ extension AIEnhancementSettingsView { } .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) + self.deleteCustomModelButton(for: item.id) + .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) + self.reasoningButton(for: item.id) .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) @@ -1971,6 +1976,16 @@ extension AIEnhancementSettingsView { ) } + @ViewBuilder + private func deleteCustomModelButton(for providerID: String) -> some View { + if self.viewModel.canDeleteSelectedModel(for: providerID) { + self.companionIconButton(systemName: "trash", help: "Delete custom model") { + self.activateProvider(providerID) + self.viewModel.deleteSelectedModel(for: providerID) + } + } + } + private func reasoningButton(for providerID: String) -> some View { let hasEnabledConfig = self.viewModel.isReasoningEnabled(for: providerID) diff --git a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift index fb3a0f06..24a27e77 100644 --- a/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift +++ b/Tests/FluidDictationIntegrationTests/AIModelRefreshTests.swift @@ -54,9 +54,32 @@ final class AIModelRefreshTests: XCTestCase { } func testRestoringLegacyBackupRehydratesSavedProviderCatalog() { + let defaults = UserDefaults.standard + let previousCustomModels = defaults.object( + forKey: SettingsStore.customModelsByProviderDefaultsKey + ) + let previousLegacyCandidates = defaults.object( + forKey: SettingsStore.legacyModelCandidatesDefaultsKey + ) let previousAvailableModels = SettingsStore.shared.availableModels let previousAvailableModelsByProvider = SettingsStore.shared.availableModelsByProvider defer { + if let previousCustomModels { + defaults.set( + previousCustomModels, + forKey: SettingsStore.customModelsByProviderDefaultsKey + ) + } else { + defaults.removeObject(forKey: SettingsStore.customModelsByProviderDefaultsKey) + } + if let previousLegacyCandidates { + defaults.set( + previousLegacyCandidates, + forKey: SettingsStore.legacyModelCandidatesDefaultsKey + ) + } else { + defaults.removeObject(forKey: SettingsStore.legacyModelCandidatesDefaultsKey) + } SettingsStore.shared.availableModels = previousAvailableModels SettingsStore.shared.availableModelsByProvider = previousAvailableModelsByProvider } @@ -327,6 +350,30 @@ final class AIModelRefreshTests: XCTestCase { ) } + func testDeletingCustomModelRemovesOnlyManualCatalogEntries() { + XCTAssertEqual( + AIEnhancementSettingsViewModel.manualModelDeletion( + "model/custom", + visibleModels: ["model/discovered", "model/custom"], + customModels: ["model/custom"], + fallbackModels: ["model/fallback"] + ), + AIEnhancementSettingsViewModel.ManualModelDeletion( + visibleModels: ["model/discovered"], + customModels: [], + selectedModel: "model/discovered" + ) + ) + XCTAssertNil( + AIEnhancementSettingsViewModel.manualModelDeletion( + "model/discovered", + visibleModels: ["model/discovered", "model/custom"], + customModels: ["model/custom"], + fallbackModels: ["model/fallback"] + ) + ) + } + func testRefreshDropsBlankModelIDs() { XCTAssertEqual( AIModelCatalog.merged(