Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -49,6 +50,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
A60100000000000000000001 /* AIModelRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelRefreshTests.swift; sourceTree = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -124,6 +126,7 @@
children = (
7CDB0A262F3C4D5600FB7CAD /* Helpers */,
7CDB0A272F3C4D5600FB7CAD /* Resources */,
A60100000000000000000001 /* AIModelRefreshTests.swift */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
Expand Down Expand Up @@ -281,6 +284,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 */,
Expand Down
3 changes: 3 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ 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.
// swiftlint:disable:next discouraged_optional_collection
let customModelsByProvider: [String: [String]]?
let savedProviders: [SettingsStore.SavedProvider]
let modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig]
let privateAIPrefixKVCacheEnabled: Bool?
Expand Down
132 changes: 132 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ final class SettingsStore: ObservableObject {
static let privateAIDictationMinimumOutputTokens = 256
static let privateAIDictationRoundTripTokenCost = 2.75
static let privateAIBackendPreferenceDefaultsKey = "FluidIntelligenceBackendPreference"
static let customModelsByProviderDefaultsKey = "CustomModelsByProvider"
static let legacyModelCandidatesDefaultsKey = "LegacyModelCandidatesByProvider"
private static let forcedOnboardingResetIntroducedAt = Date(timeIntervalSince1970: 1_782_091_732)
private let defaults = UserDefaults.standard
private let keychain = KeychainService.shared
Expand Down Expand Up @@ -1412,6 +1414,127 @@ final class SettingsStore: ObservableObject {
}
}

var customModelsByProvider: [String: [String]] {
Comment thread
floze-the-genius marked this conversation as resolved.
get {
(self.defaults.dictionary(forKey: Self.customModelsByProviderDefaultsKey) as? [String: [String]]) ?? [:]
}
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Self.customModelsByProviderDefaultsKey)
}
}

var hasStoredCustomModelsByProvider: Bool {
self.defaults.object(forKey: Self.customModelsByProviderDefaultsKey) != nil
}

func clearStoredCustomModelsByProvider() {
objectWillChange.send()
self.defaults.removeObject(forKey: Self.customModelsByProviderDefaultsKey)
}

var legacyModelCandidatesByProvider: [String: [String]] {
get {
(self.defaults.dictionary(
forKey: Self.legacyModelCandidatesDefaultsKey
) as? [String: [String]]) ?? [:]
}
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Self.legacyModelCandidatesDefaultsKey)
}
}

func clearStoredLegacyModelCandidatesByProvider() {
objectWillChange.send()
self.defaults.removeObject(forKey: Self.legacyModelCandidatesDefaultsKey)
}

static func availableModelsAfterRestore(
customModelsByProvider: [String: [String]],
savedProviders: [SavedProvider]
) -> [String: [String]] {
func normalized(_ models: [String]) -> [String] {
var seen: Set<String> = []
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(savedProviders: [SavedProvider]) {
self.availableModels = []
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)
Expand Down Expand Up @@ -2994,6 +3117,7 @@ final class SettingsStore: ObservableObject {
SettingsBackupPayload(
selectedProviderID: self.selectedProviderID,
selectedModelByProvider: self.selectedModelByProvider,
customModelsByProvider: self.customModelsByProviderForBackup(),
savedProviders: self.savedProviders,
modelReasoningConfigs: self.modelReasoningConfigs,
privateAIPrefixKVCacheEnabled: self.privateAIPrefixKVCacheEnabled,
Expand Down Expand Up @@ -3095,6 +3219,14 @@ final class SettingsStore: ObservableObject {
self.savedProviders = payload.savedProviders
self.selectedProviderID = payload.selectedProviderID
self.selectedModelByProvider = payload.selectedModelByProvider
if let customModelsByProvider = payload.customModelsByProvider {
self.restoreModelCatalogState(
customModelsByProvider: customModelsByProvider,
savedProviders: self.savedProviders
)
} else {
Comment thread
floze-the-genius marked this conversation as resolved.
self.prepareLegacyModelCatalogRestore(savedProviders: self.savedProviders)
}
self.modelReasoningConfigs = payload.modelReasoningConfigs
if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled {
self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled
Expand Down
Loading
Loading