diff --git a/README.md b/README.md index 7406b3d3..e1f37afa 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,6 @@ Open source voice-to-text dictation app for macOS with on-device AI enhancement. **Manual download:** [latest release](https://github.com/altic-dev/FluidVoice/releases/latest) -> [!NOTE] -> FluidVoice is on macOS today. **iOS and Windows are on the way** — join the waitlist to get notified when we launch: **[altic.dev/fluid/waitlist](https://www.altic.dev/fluid/waitlist)** - > [!IMPORTANT] > This project is free and open source under GPLv3. If FluidVoice is useful to you, please star the repository — it helps visibility and keeps development going. diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 19878293..31784b45 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1832,7 +1832,9 @@ struct ContentView: View { model: derivedSelectedModel, apiKey: apiKey, localModelPath: PrivateAIIntegrationService.configuredLocalModelPath, - usesStablePromptPrefixKVCache: SettingsStore.shared.privateAIPrefixKVCacheEnabled + usesStablePromptPrefixKVCache: SettingsStore.shared.privateAIPrefixKVCacheEnabled, + usesFluid1Boost: SettingsStore.shared.privateAIBoostEnabled, + contextTokenLimit: SettingsStore.shared.privateAIContextTokenLimit ), context: PrivateAIIntegrationService.AppContext( appName: appInfo.name, diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 9ca1ced3..8d0b595a 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -13,6 +13,8 @@ struct SettingsBackupPayload: Codable, Equatable { let savedProviders: [SettingsStore.SavedProvider] let modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig] let privateAIPrefixKVCacheEnabled: Bool? + let privateAIBoostEnabled: Bool? + let privateAIContextTokenLimit: Int? let selectedSpeechModel: SettingsStore.SpeechModel let selectedCohereLanguage: SettingsStore.CohereLanguage let selectedNemotronLanguage: SettingsStore.NemotronLanguage? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index a1ed50b8..a4501277 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -15,6 +15,12 @@ final class SettingsStore: ObservableObject { static let transcriptionPreviewCharLimitRange: ClosedRange = 50...800 static let transcriptionPreviewCharLimitStep = 50 static let defaultTranscriptionPreviewCharLimit = 150 + static let privateAIContextTokenLimitRange: ClosedRange = 2048...8192 + static let privateAIContextTokenLimitStep = 512 + static let defaultPrivateAIContextTokenLimit = 4096 + static let privateAIDictationSystemOverheadTokens = 1280 + static let privateAIDictationMinimumOutputTokens = 256 + static let privateAIDictationRoundTripTokenCost = 2.75 private let defaults = UserDefaults.standard private let keychain = KeychainService.shared private(set) var launchAtStartupEnabled = false @@ -35,9 +41,34 @@ final class SettingsStore: ObservableObject { self.normalizeProviderSelectionForCurrentVerificationState() self.enforceOnboardingGenerationIfNeeded() self.migrateOverlayBottomOffsetTo50IfNeeded() + self.migratePrivateAIContextDefaultTo4KIfNeeded() self.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) } + static func clampPrivateAIContextTokenLimit(_ value: Int) -> Int { + min(max(value, self.privateAIContextTokenLimitRange.lowerBound), self.privateAIContextTokenLimitRange.upperBound) + } + + static func estimatedPrivateAIDictationWords(for contextTokenLimit: Int) -> Int { + let availableTokens = max(0, Self.clampPrivateAIContextTokenLimit(contextTokenLimit) - Self.privateAIDictationSystemOverheadTokens) + let inputTokens = Double(availableTokens) / Self.privateAIDictationRoundTripTokenCost + return max(100, Int((inputTokens * 0.75 / 50).rounded(.up)) * 50) + } + + static func privateAIMaxOutputTokens(forInputText inputText: String, contextTokenLimit: Int) -> Int { + let wordCount = inputText.split { $0.isWhitespace || $0.isNewline }.count + let estimatedInputTokens = max(1, Int((Double(wordCount) / 0.75).rounded(.up))) + let requestedOutputTokens = max( + Self.privateAIDictationMinimumOutputTokens, + Int((Double(estimatedInputTokens) * 1.15).rounded(.up)) + 64 + ) + let availableOutputTokens = max( + Self.privateAIDictationMinimumOutputTokens, + Self.clampPrivateAIContextTokenLimit(contextTokenLimit) - Self.privateAIDictationSystemOverheadTokens - estimatedInputTokens + ) + return min(requestedOutputTokens, availableOutputTokens) + } + // MARK: - Prompt Profiles (Unified) enum PromptMode: String, Codable, CaseIterable, Identifiable { @@ -1406,6 +1437,34 @@ final class SettingsStore: ObservableObject { } } + var privateAIBoostEnabled: Bool { + get { self.defaults.object(forKey: PrivateAIProviderFeature.shared.boostDefaultsKey) as? Bool ?? true } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: PrivateAIProviderFeature.shared.boostDefaultsKey) + } + } + + var privateAIContextTokenLimit: Int { + get { + let value = self.defaults.integer(forKey: Keys.privateAIContextTokenLimit) + return Self.clampPrivateAIContextTokenLimit(value == 0 ? Self.defaultPrivateAIContextTokenLimit : value) + } + set { + objectWillChange.send() + self.defaults.set(Self.clampPrivateAIContextTokenLimit(newValue), forKey: Keys.privateAIContextTokenLimit) + } + } + + private func migratePrivateAIContextDefaultTo4KIfNeeded() { + guard self.defaults.bool(forKey: Keys.privateAIContextDefaultMigratedTo4K) == false else { return } + let storedValue = self.defaults.object(forKey: Keys.privateAIContextTokenLimit) as? Int + if storedValue == nil || storedValue == Self.privateAIContextTokenLimitRange.lowerBound { + self.defaults.set(Self.defaultPrivateAIContextTokenLimit, forKey: Keys.privateAIContextTokenLimit) + } + self.defaults.set(true, forKey: Keys.privateAIContextDefaultMigratedTo4K) + } + var savedProviders: [SavedProvider] { get { guard let data = defaults.data(forKey: Keys.savedProviders), @@ -2740,6 +2799,8 @@ final class SettingsStore: ObservableObject { savedProviders: self.savedProviders, modelReasoningConfigs: self.modelReasoningConfigs, privateAIPrefixKVCacheEnabled: self.privateAIPrefixKVCacheEnabled, + privateAIBoostEnabled: self.privateAIBoostEnabled, + privateAIContextTokenLimit: self.privateAIContextTokenLimit, selectedSpeechModel: self.selectedSpeechModel, selectedCohereLanguage: self.selectedCohereLanguage, selectedNemotronLanguage: self.selectedNemotronLanguage, @@ -2832,6 +2893,12 @@ final class SettingsStore: ObservableObject { if let privateAIPrefixKVCacheEnabled = payload.privateAIPrefixKVCacheEnabled { self.privateAIPrefixKVCacheEnabled = privateAIPrefixKVCacheEnabled } + if let privateAIBoostEnabled = payload.privateAIBoostEnabled { + self.privateAIBoostEnabled = privateAIBoostEnabled + } + if let privateAIContextTokenLimit = payload.privateAIContextTokenLimit { + self.privateAIContextTokenLimit = privateAIContextTokenLimit + } self.selectedSpeechModel = payload.selectedSpeechModel self.selectedCohereLanguage = payload.selectedCohereLanguage if let selectedNemotronLanguage = payload.selectedNemotronLanguage { @@ -4367,6 +4434,9 @@ private extension SettingsStore { static let selectedModelByProvider = "SelectedModelByProvider" static let selectedProviderID = "SelectedProviderID" static let privateAIPrefixKVCacheEnabled = "PrivateAIProviderPrefixKVCacheEnabled" + static let privateAIBoostEnabled = "PrivateAIProviderBoostEnabled" + static let privateAIContextTokenLimit = "PrivateAIProviderContextTokenLimit" + static let privateAIContextDefaultMigratedTo4K = "PrivateAIProviderContextDefaultMigratedTo4K" static let providerAPIKeys = "ProviderAPIKeys" static let providerAPIKeyIdentifiers = "ProviderAPIKeyIdentifiers" static let savedProviders = "SavedProviders" diff --git a/Sources/Fluid/Services/DictationPostProcessingService.swift b/Sources/Fluid/Services/DictationPostProcessingService.swift index fd47ca0a..d1499988 100644 --- a/Sources/Fluid/Services/DictationPostProcessingService.swift +++ b/Sources/Fluid/Services/DictationPostProcessingService.swift @@ -57,7 +57,9 @@ final class DictationPostProcessingService { model: resolved.model, apiKey: resolved.apiKey, localModelPath: PrivateAIIntegrationService.configuredLocalModelPath, - usesStablePromptPrefixKVCache: settings.privateAIPrefixKVCacheEnabled + usesStablePromptPrefixKVCache: settings.privateAIPrefixKVCacheEnabled, + usesFluid1Boost: settings.privateAIBoostEnabled, + contextTokenLimit: settings.privateAIContextTokenLimit ), context: PrivateAIIntegrationService.AppContext( appName: "", diff --git a/Sources/Fluid/Services/PrivateAIIntegrationService.swift b/Sources/Fluid/Services/PrivateAIIntegrationService.swift index 3df09b23..5d274bdb 100644 --- a/Sources/Fluid/Services/PrivateAIIntegrationService.swift +++ b/Sources/Fluid/Services/PrivateAIIntegrationService.swift @@ -19,6 +19,8 @@ actor PrivateAIIntegrationService { let apiKey: String let localModelPath: String? let usesStablePromptPrefixKVCache: Bool + let usesFluid1Boost: Bool + let contextTokenLimit: Int } struct AppContext: Sendable, Equatable { diff --git a/Sources/Fluid/Services/PrivateAIProvider.swift b/Sources/Fluid/Services/PrivateAIProvider.swift index 72b53cef..b376cea1 100644 --- a/Sources/Fluid/Services/PrivateAIProvider.swift +++ b/Sources/Fluid/Services/PrivateAIProvider.swift @@ -165,6 +165,7 @@ protocol PrivateAIProviderFeatureProviding: Sendable { var selectedModelDefaultsKey: String { get } var localModelPathDefaultsKey: String { get } var prefixCacheDefaultsKey: String { get } + var boostDefaultsKey: String { get } var modelDirectoryName: String { get } func modelIDs() -> [String] @@ -331,6 +332,7 @@ private struct UnavailablePrivateAIProviderFeature: PrivateAIProviderFeatureProv let selectedModelDefaultsKey = "PrivateAIProviderSelectedModelID" let localModelPathDefaultsKey = "PrivateAIProviderLocalModelPath" let prefixCacheDefaultsKey = "PrivateAIProviderPrefixKVCacheEnabled" + let boostDefaultsKey = "PrivateAIProviderBoostEnabled" let modelDirectoryName = "PrivateAIProvider" func modelIDs() -> [String] { [] } diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 7f402991..80112c78 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -1055,6 +1055,14 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } func startEditingProvider() { + if PrivateFeatures.privateAIProvider, self.selectedProviderID == PrivateAIProviderFeature.shared.providerID { + self.editProviderName = PrivateAIProviderFeature.displayName + self.editProviderBaseURL = "" + self.editProviderApiKey = "" + self.showingEditProvider = true + return + } + // Handle built-in providers if ModelRepository.shared.isBuiltIn(self.selectedProviderID) { self.editProviderName = ModelRepository.shared.displayName(for: self.selectedProviderID) diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 569ae626..9a89c867 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -323,7 +323,11 @@ extension AIEnhancementSettingsView { self.allProvidersSection - if self.viewModel.showingEditProvider { self.editProviderSection } + if self.viewModel.showingEditProvider, + self.viewModel.selectedProviderID != PrivateAIProviderFeature.shared.providerID + { + self.editProviderSection + } } .padding(.top, 4) } @@ -691,6 +695,7 @@ extension AIEnhancementSettingsView { } self.privateAIPrefixCacheRow(isBusy: isBusy) + self.privateAIBoostRow(isBusy: isBusy) if self.viewModel.connectionStatus(for: PrivateAIProviderFeature.shared.providerID) == .failed, !self.viewModel.connectionErrorMessage.isEmpty @@ -753,13 +758,41 @@ extension AIEnhancementSettingsView { } private func privateAIPrefixCacheRow(isBusy: Bool) -> some View { - HStack(spacing: 8) { - Toggle("Fast startup", isOn: self.privateAIPrefixCacheBinding) + HStack(alignment: .center, spacing: 8) { + Text("Fast startup") + .font(.caption) + .frame(width: 124, alignment: .leading) + + Toggle("", isOn: self.privateAIPrefixCacheBinding) .toggleStyle(.switch) .controlSize(.mini) - .font(.caption) + .labelsHidden() .disabled(isBusy) .help("Keeps a reusable local prompt cache for faster first dictation.") + .accessibilityLabel("Fast startup") + + Spacer(minLength: 0) + } + } + + private func privateAIBoostRow(isBusy: Bool) -> some View { + HStack(alignment: .center, spacing: 8) { + Text("Fluid-1 Boost") + .font(.caption) + .frame(width: 124, alignment: .leading) + + Toggle("", isOn: self.privateAIBoostBinding) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + .disabled(isBusy) + .help("Uses extra local acceleration for faster Fluid-1 responses.") + .accessibilityLabel("Fluid-1 Boost") + + Text("Up to 20% faster. Uses about 100 MB more memory.") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) Spacer(minLength: 0) } @@ -831,6 +864,41 @@ extension AIEnhancementSettingsView { ) } + private var privateAIBoostBinding: Binding { + Binding( + get: { self.settings.privateAIBoostEnabled }, + set: { enabled in + guard self.settings.privateAIBoostEnabled != enabled else { return } + self.settings.privateAIBoostEnabled = enabled + self.privateAILoadState = .idle + Task { @MainActor in + await PrivateAIIntegrationService.shared.unloadCachedRuntime( + reason: enabled ? "Fluid-1 Boost enabled" : "Fluid-1 Boost disabled" + ) + self.viewModel.refreshProviderItems() + } + } + ) + } + + private var privateAIContextTokenLimitBinding: Binding { + Binding( + get: { self.settings.privateAIContextTokenLimit }, + set: { value in + let clamped = SettingsStore.clampPrivateAIContextTokenLimit(value) + guard self.settings.privateAIContextTokenLimit != clamped else { return } + self.settings.privateAIContextTokenLimit = clamped + self.privateAILoadState = .idle + Task { @MainActor in + await PrivateAIIntegrationService.shared.unloadCachedRuntime( + reason: "Fluid Intelligence context changed" + ) + self.viewModel.refreshProviderItems() + } + } + ) + } + private var privateAIModelBinding: Binding { Binding( get: { self.privateAISelectedModelID }, @@ -1063,6 +1131,39 @@ extension AIEnhancementSettingsView { } } + private func resetPrivateAIVerification(for model: PrivateAIRegisteredModel) { + self.viewModel.resetVerification(for: PrivateAIProviderFeature.shared.providerID) + self.privateAILoadState = .idle + Task { @MainActor in + await PrivateAIIntegrationService.shared.unloadCachedRuntime(reason: "Fluid Intelligence verification reset") + if PrivateAIIntegrationService.isModelInstalled(model) { + self.privateAILoadState = .idle + } + self.viewModel.refreshProviderItems() + } + } + + private func deletePrivateAIModel(_ model: PrivateAIRegisteredModel) { + guard PrivateAIIntegrationService.canRemoveInstalledModel(model) else { return } + self.privateAILoadState = .loading(modelID: model.id) + Task { @MainActor in + do { + try await PrivateAIIntegrationService.shared.unloadAndRemoveInstalledModel( + model, + reason: "settings-delete" + ) + self.viewModel.resetVerification(for: PrivateAIProviderFeature.shared.providerID) + if self.privateAISelectedModelID == model.id { + self.privateAILoadState = .idle + } + self.viewModel.refreshProviderItems() + } catch { + guard self.privateAISelectedModelID == model.id else { return } + self.privateAILoadState = .failed(modelID: model.id, message: Self.errorMessage(for: error)) + } + } + } + private static func errorMessage(for error: Error) -> String { if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription @@ -1537,44 +1638,21 @@ extension AIEnhancementSettingsView { Color.clear .frame(width: iconColumnWidth, height: AISettingsLayout.providerRowControlHeight) - ZStack { - if isFluidDownloading || !isFluidInstalled { - Button(action: { self.downloadPrivateAIModel(fluidModel) }) { - HStack(spacing: 4) { - if isFluidDownloading { - ProgressView() - .controlSize(.mini) - .fixedSize() - } - Text(isFluidDownloading ? Self.downloadButtonText(progress: fluidDownloadProgress) : "Download") - .font(.system(size: 11, weight: .semibold)) - .lineLimit(1) - } - } - .fluidButton(.accent, size: .small) - .frame(width: actionColumnWidth, height: AISettingsLayout.providerRowControlHeight) - .disabled(!fluidModel.canDownload || isFluidBusy) - .help(fluidModel.canDownload ? "Download and verify selected model" : "Download URL is not configured yet") - } else if !isFluidVerified || hasFluidLoadFailure { - Button(action: { self.verifyPrivateAIConnection(fluidModel) }) { - HStack(spacing: 4) { - if isFluidLoading || isFluidTesting { - ProgressView() - .controlSize(.mini) - .fixedSize() - } - Text((isFluidLoading || isFluidTesting) ? "Loading" : "Verify") - .font(.system(size: 11, weight: .semibold)) - .lineLimit(1) - } - } - .fluidButton(.accent, size: .small) - .frame(width: actionColumnWidth, height: AISettingsLayout.providerRowControlHeight) - .disabled(isFluidBusy) - .help("Verify selected model") + Button(action: { + self.activateProvider(item.id) + if isEditing { + self.viewModel.clearEditProviderDraft() + } else { + self.viewModel.startEditingProvider() } + }) { + Text("Edit") + .font(.system(size: 12, weight: .semibold)) + .frame(width: actionColumnWidth, height: AISettingsLayout.providerRowControlHeight) } + .buttonStyle(SquareIconButtonStyle()) .frame(width: actionColumnWidth, height: AISettingsLayout.providerRowControlHeight) + .help("Edit provider") } else { SearchableModelPicker( models: models, @@ -1630,7 +1708,18 @@ extension AIEnhancementSettingsView { .padding(.top, 8) } - if !isPrivateAIProvider, isEditing { + if isPrivateAIProvider, isEditing { + Divider() + .background(self.theme.palette.separator.opacity(0.5)) + .padding(.vertical, 10) + + self.privateAIEditProviderSection( + model: fluidModel, + isInstalled: isFluidInstalled, + isBusy: isFluidBusy, + isVerified: isFluidVerified + ) + } else if !isPrivateAIProvider, isEditing { Divider() .background(self.theme.palette.separator.opacity(0.5)) .padding(.vertical, 10) @@ -1880,6 +1969,179 @@ extension AIEnhancementSettingsView { ) } + func privateAIEditProviderSection( + model: PrivateAIRegisteredModel, + isInstalled: Bool, + isBusy: Bool, + isVerified: Bool + ) -> some View { + let canDelete = isInstalled && PrivateAIIntegrationService.canRemoveInstalledModel(model) + + return VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(self.theme.palette.accent) + Text("Edit Provider") + .font(.system(size: 14, weight: .semibold)) + Spacer() + } + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + self.privateAISettingLabel("Model", systemImage: "cube.box") + + SearchableModelPicker( + models: PrivateAIModelRegistry.modelIDs(), + selectedModel: self.privateAIModelBinding, + selectionEnabled: !isBusy, + controlWidth: 260, + controlHeight: AISettingsLayout.providerRowControlHeight + ) + + Spacer(minLength: 0) + } + + HStack(alignment: .center, spacing: 12) { + self.privateAISettingLabel("Dictation window", systemImage: "memorychip") + + self.privateAIContextControl(isBusy: isBusy) + + HStack(alignment: .top, spacing: 5) { + Image(systemName: "info.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .padding(.top, 1) + Text("\(self.privateAIContextCueText). Higher values help long transcripts but use more RAM.") + .font(.caption2) + .lineLimit(2) + } + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + } + + self.privateAIPrefixCacheRow(isBusy: isBusy) + self.privateAIBoostRow(isBusy: isBusy) + } + + HStack(spacing: 8) { + if isVerified { + Button { + self.resetPrivateAIVerification(for: model) + self.viewModel.clearEditProviderDraft() + } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.counterclockwise") + Text("Reset Verification") + } + .font(.caption) + } + .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) + } + + if canDelete { + Button(role: .destructive) { + self.deletePrivateAIModel(model) + self.viewModel.clearEditProviderDraft() + } label: { + HStack(spacing: 6) { + Image(systemName: "trash") + Text("Delete Model") + } + .font(.caption) + } + .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) + .disabled(isBusy) + } + + Spacer(minLength: 0) + + Button { + self.viewModel.clearEditProviderDraft() + } label: { + HStack(spacing: 6) { + Image(systemName: "checkmark") + Text("Done") + } + } + .fluidButton(.glass, size: .compact) + } + } + } + + private func privateAISettingLabel(_ title: String, systemImage: String) -> some View { + HStack(spacing: 7) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(width: 110, alignment: .leading) + } + + private func privateAIContextControl(isBusy: Bool) -> some View { + HStack(spacing: 0) { + Button { + self.decreasePrivateAIContextTokenLimit() + } label: { + Image(systemName: "minus") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 32, height: AISettingsLayout.providerRowControlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy || self.settings.privateAIContextTokenLimit <= SettingsStore.privateAIContextTokenLimitRange.lowerBound) + + Text("\(self.settings.privateAIContextTokenLimit.formatted())") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 74, height: AISettingsLayout.providerRowControlHeight) + + Text("tokens") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 48, height: AISettingsLayout.providerRowControlHeight, alignment: .leading) + + Button { + self.increasePrivateAIContextTokenLimit() + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 32, height: AISettingsLayout.providerRowControlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy || self.settings.privateAIContextTokenLimit >= SettingsStore.privateAIContextTokenLimitRange.upperBound) + } + .foregroundStyle(self.theme.palette.primaryText) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.elevatedCardBackground.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.35), lineWidth: 0.8) + ) + .fixedSize() + .help("How much raw dictation Fluid-1 can clean at once. Higher values help long transcripts but use more RAM and can slow first response.") + } + + private func decreasePrivateAIContextTokenLimit() { + self.privateAIContextTokenLimitBinding.wrappedValue = self.settings.privateAIContextTokenLimit - SettingsStore.privateAIContextTokenLimitStep + } + + private func increasePrivateAIContextTokenLimit() { + self.privateAIContextTokenLimitBinding.wrappedValue = self.settings.privateAIContextTokenLimit + SettingsStore.privateAIContextTokenLimitStep + } + + private var privateAIContextCueText: String { + let estimatedWords = SettingsStore.estimatedPrivateAIDictationWords(for: self.settings.privateAIContextTokenLimit) + let estimatedMinutes = max(1, Int((Double(estimatedWords) / 150.0).rounded())) + return "Safe for about \(estimatedWords.formatted()) words / \(estimatedMinutes) min of dictation" + } + var editProviderSection: some View { let isBuiltIn = ModelRepository.shared.isBuiltIn(self.viewModel.selectedProviderID) let apiKeyBinding = Binding( diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 90b49afe..e370d290 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -34,6 +34,13 @@ final class DictationE2ETests: XCTestCase { PrivateAIProviderFeature.shared.prefixCacheDefaultsKey } + private var privateAIBoostEnabledKey: String { + PrivateAIProviderFeature.shared.boostDefaultsKey + } + + private let privateAIContextTokenLimitKey = "PrivateAIProviderContextTokenLimit" + private let privateAIContextDefaultMigratedTo4KKey = "PrivateAIProviderContextDefaultMigratedTo4K" + private let verifiedProviderFingerprintsKey = "VerifiedProviderFingerprints" func testTranscriptionStartSound_noneOptionHasNoFile() { @@ -709,6 +716,39 @@ final class DictationE2ETests: XCTestCase { } } + func testPrivateAIProviderBoost_defaultsOnAndPersistsToggle() { + self.withRestoredDefaults(keys: [self.privateAIBoostEnabledKey]) { + let settings = SettingsStore.shared + + XCTAssertTrue(settings.privateAIBoostEnabled) + + settings.privateAIBoostEnabled = false + XCTAssertFalse(settings.privateAIBoostEnabled) + + settings.privateAIBoostEnabled = true + XCTAssertTrue(settings.privateAIBoostEnabled) + } + } + + func testPrivateAIProviderContextTokenLimit_defaultsPersistsAndClamps() { + self.withRestoredDefaults(keys: [self.privateAIContextTokenLimitKey, self.privateAIContextDefaultMigratedTo4KKey]) { + let settings = SettingsStore.shared + UserDefaults.standard.removeObject(forKey: self.privateAIContextTokenLimitKey) + UserDefaults.standard.removeObject(forKey: self.privateAIContextDefaultMigratedTo4KKey) + + XCTAssertEqual(settings.privateAIContextTokenLimit, 4096) + + settings.privateAIContextTokenLimit = 4096 + XCTAssertEqual(settings.privateAIContextTokenLimit, 4096) + + settings.privateAIContextTokenLimit = 1024 + XCTAssertEqual(settings.privateAIContextTokenLimit, 2048) + + settings.privateAIContextTokenLimit = 16_384 + XCTAssertEqual(settings.privateAIContextTokenLimit, 8192) + } + } + func testPrivateAIProviderLocalRuntimeOnlyHandlesPrivateModels() { self.withRestoredDefaults(keys: [self.privateAILocalModelPathKey]) { let tempURL = FileManager.default.temporaryDirectory