Skip to content
Merged
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
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
70 changes: 70 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ final class SettingsStore: ObservableObject {
static let transcriptionPreviewCharLimitRange: ClosedRange<Int> = 50...800
static let transcriptionPreviewCharLimitStep = 50
static let defaultTranscriptionPreviewCharLimit = 150
static let privateAIContextTokenLimitRange: ClosedRange<Int> = 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
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion Sources/Fluid/Services/DictationPostProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
2 changes: 2 additions & 0 deletions Sources/Fluid/Services/PrivateAIIntegrationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Fluid/Services/PrivateAIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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] { [] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading