Skip to content
Closed
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
77 changes: 77 additions & 0 deletions Sources/APIProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation

enum APIProvider: String, CaseIterable, Identifiable {
case groq
case openai
case custom

var id: String { rawValue }

var displayName: String {
switch self {
case .groq: return "Groq"
case .openai: return "OpenAI"
case .custom: return "Custom"
}
}

var defaultBaseURL: String {
switch self {
case .groq: return "https://api.groq.com/openai/v1"
case .openai: return "https://api.openai.com/v1"
case .custom: return ""
}
}

var transcriptionModel: String {
switch self {
case .groq: return "whisper-large-v3"
case .openai: return "whisper-1"
case .custom: return "whisper-large-v3"
}
}

var chatModel: String {
switch self {
case .groq: return "meta-llama/llama-4-scout-17b-16e-instruct"
case .openai: return "gpt-5-mini-2025-08-07"
case .custom: return "meta-llama/llama-4-scout-17b-16e-instruct"
}
}

var visionModel: String {
chatModel
}

var apiKeyStorageKey: String {
switch self {
case .groq: return "groq_api_key"
case .openai: return "openai_api_key"
case .custom: return "custom_api_key"
}
}

var apiKeyPlaceholder: String {
switch self {
case .groq: return "Paste your Groq API key"
case .openai: return "Paste your OpenAI API key"
case .custom: return "Paste your API key"
}
}

var keyInstructionURL: String {
switch self {
case .groq: return "https://console.groq.com/keys"
case .openai: return "https://platform.openai.com/api-keys"
case .custom: return ""
}
}

var keyInstructionDisplayURL: String {
switch self {
case .groq: return "console.groq.com/keys"
case .openai: return "platform.openai.com/api-keys"
case .custom: return ""
}
}
}
8 changes: 5 additions & 3 deletions Sources/AppContextService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ Return only two sentences, no labels, no markdown, no extra commentary.
private let apiKey: String
private let baseURL: String
private let customContextPrompt: String
private let fallbackTextModel = "meta-llama/llama-4-scout-17b-16e-instruct"
private let visionModel = "meta-llama/llama-4-scout-17b-16e-instruct"
private let fallbackTextModel: String
private let visionModel: String
private let maxScreenshotDataURILength = 500_000
private let screenshotCompressionPrimary = 0.5
private let screenshotMaxDimension: CGFloat = 1024

init(apiKey: String, baseURL: String = "https://api.groq.com/openai/v1", customContextPrompt: String = "") {
init(apiKey: String, baseURL: String, chatModel: String, visionModel: String, customContextPrompt: String = "") {
self.apiKey = apiKey
self.baseURL = baseURL
self.fallbackTextModel = chatModel
self.visionModel = visionModel
self.customContextPrompt = customContextPrompt
}

Expand Down
50 changes: 39 additions & 11 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ enum SettingsTab: String, CaseIterable, Identifiable {
}

final class AppState: ObservableObject, @unchecked Sendable {
private let apiKeyStorageKey = "groq_api_key"
private let apiBaseURLStorageKey = "api_base_url"
private let selectedProviderStorageKey = "selected_provider"
private let customVocabularyStorageKey = "custom_vocabulary"
private let selectedMicrophoneStorageKey = "selected_microphone_id"
private let customSystemPromptStorageKey = "custom_system_prompt"
Expand All @@ -52,17 +52,28 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

@Published var selectedProvider: APIProvider {
didSet {
UserDefaults.standard.set(selectedProvider.rawValue, forKey: selectedProviderStorageKey)
apiKey = Self.loadStoredAPIKey(account: selectedProvider.apiKeyStorageKey)
if selectedProvider != .custom {
apiBaseURL = selectedProvider.defaultBaseURL
}
rebuildContextService()
}
}

@Published var apiKey: String {
didSet {
persistAPIKey(apiKey)
contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, customContextPrompt: customContextPrompt)
rebuildContextService()
}
}

@Published var apiBaseURL: String {
didSet {
persistAPIBaseURL(apiBaseURL)
contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, customContextPrompt: customContextPrompt)
rebuildContextService()
}
}

Expand All @@ -88,7 +99,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
@Published var customContextPrompt: String {
didSet {
UserDefaults.standard.set(customContextPrompt, forKey: customContextPromptStorageKey)
contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, customContextPrompt: customContextPrompt)
rebuildContextService()
}
}

Expand Down Expand Up @@ -149,8 +160,14 @@ final class AppState: ObservableObject, @unchecked Sendable {

init() {
let hasCompletedSetup = UserDefaults.standard.bool(forKey: "hasCompletedSetup")
let apiKey = Self.loadStoredAPIKey(account: apiKeyStorageKey)
let apiBaseURL = Self.loadStoredAPIBaseURL(account: "api_base_url")
let selectedProvider = APIProvider(rawValue: UserDefaults.standard.string(forKey: "selected_provider") ?? "") ?? .groq
let apiKey = Self.loadStoredAPIKey(account: selectedProvider.apiKeyStorageKey)
let apiBaseURL: String
if selectedProvider == .custom {
apiBaseURL = Self.loadStoredAPIBaseURL(account: "api_base_url")
} else {
apiBaseURL = selectedProvider.defaultBaseURL
}
let selectedHotkey = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "hotkey_option") ?? "fn") ?? .fnKey
let customVocabulary = UserDefaults.standard.string(forKey: customVocabularyStorageKey) ?? ""
let customSystemPrompt = UserDefaults.standard.string(forKey: customSystemPromptStorageKey) ?? ""
Expand All @@ -172,8 +189,9 @@ final class AppState: ObservableObject, @unchecked Sendable {

let selectedMicrophoneID = UserDefaults.standard.string(forKey: selectedMicrophoneStorageKey) ?? "default"

self.contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, customContextPrompt: customContextPrompt)
self.contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, chatModel: selectedProvider.chatModel, visionModel: selectedProvider.visionModel, customContextPrompt: customContextPrompt)
self.hasCompletedSetup = hasCompletedSetup
self.selectedProvider = selectedProvider
self.apiKey = apiKey
self.apiBaseURL = apiBaseURL
self.selectedHotkey = selectedHotkey
Expand Down Expand Up @@ -222,9 +240,9 @@ final class AppState: ObservableObject, @unchecked Sendable {
private func persistAPIKey(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
AppSettingsStorage.delete(account: apiKeyStorageKey)
AppSettingsStorage.delete(account: selectedProvider.apiKeyStorageKey)
} else {
AppSettingsStorage.save(trimmed, account: apiKeyStorageKey)
AppSettingsStorage.save(trimmed, account: selectedProvider.apiKeyStorageKey)
}
}

Expand All @@ -246,6 +264,16 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

private func rebuildContextService() {
contextService = AppContextService(
apiKey: apiKey,
baseURL: apiBaseURL,
chatModel: selectedProvider.chatModel,
visionModel: selectedProvider.visionModel,
customContextPrompt: customContextPrompt
)
}

static func audioStorageDirectory() -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "FreeFlow"
Expand Down Expand Up @@ -630,8 +658,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
} catch {}
}

let transcriptionService = TranscriptionService(apiKey: apiKey, baseURL: apiBaseURL)
let postProcessingService = PostProcessingService(apiKey: apiKey, baseURL: apiBaseURL)
let transcriptionService = TranscriptionService(apiKey: apiKey, baseURL: apiBaseURL, transcriptionModel: selectedProvider.transcriptionModel)
let postProcessingService = PostProcessingService(apiKey: apiKey, baseURL: apiBaseURL, model: selectedProvider.chatModel)

Task {
do {
Expand Down
7 changes: 4 additions & 3 deletions Sources/PostProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ Output rules:

private let apiKey: String
private let baseURL: String
private let defaultModel = "meta-llama/llama-4-scout-17b-16e-instruct"
private let model: String
private let postProcessingTimeoutSeconds: TimeInterval = 20

init(apiKey: String, baseURL: String = "https://api.groq.com/openai/v1") {
init(apiKey: String, baseURL: String, model: String) {
self.apiKey = apiKey
self.baseURL = baseURL
self.model = model
}

func postProcess(
Expand All @@ -67,7 +68,7 @@ Output rules:
return try await self.process(
transcript: transcript,
contextSummary: context.contextSummary,
model: defaultModel,
model: model,
customVocabulary: vocabularyTerms,
customSystemPrompt: customSystemPrompt
)
Expand Down
77 changes: 47 additions & 30 deletions Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ struct GeneralSettingsView: View {
SettingsCard("Updates", icon: "arrow.triangle.2.circlepath") {
updatesSection
}
SettingsCard("API Key", icon: "key.fill") {
apiKeySection
SettingsCard("API Provider", icon: "server.rack") {
apiProviderSection
}
SettingsCard("Push-to-Talk Key", icon: "keyboard.fill") {
hotkeySection
Expand Down Expand Up @@ -394,16 +394,32 @@ struct GeneralSettingsView: View {
}
}

// MARK: API Key
// MARK: API Provider

private var apiKeySection: some View {
private var apiProviderSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("FreeFlow uses Groq's whisper-large-v3 model for transcription.")
Picker("Provider", selection: Binding(
get: { appState.selectedProvider },
set: { newProvider in
appState.selectedProvider = newProvider
apiKeyInput = appState.apiKey
apiBaseURLInput = appState.apiBaseURL
keyValidationError = nil
keyValidationSuccess = false
}
)) {
ForEach(APIProvider.allCases) { provider in
Text(provider.displayName).tag(provider)
}
}
.pickerStyle(.segmented)

Text("FreeFlow uses \(appState.selectedProvider.displayName) for transcription and post-processing.")
.font(.caption)
.foregroundStyle(.secondary)

HStack(spacing: 8) {
SecureField("Enter your Groq API key", text: $apiKeyInput)
SecureField(appState.selectedProvider.apiKeyPlaceholder, text: $apiKeyInput)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.disabled(isValidatingKey)
Expand All @@ -418,6 +434,12 @@ struct GeneralSettingsView: View {
.disabled(apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isValidatingKey)
}

if !appState.selectedProvider.keyInstructionURL.isEmpty {
Text("Get an API key at [\(appState.selectedProvider.keyInstructionDisplayURL)](\(appState.selectedProvider.keyInstructionURL))")
.font(.caption)
.tint(.blue)
}

if let error = keyValidationError {
Label(error, systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
Expand All @@ -428,44 +450,37 @@ struct GeneralSettingsView: View {
.font(.caption)
}

Divider()

Text("API Base URL")
.font(.caption.weight(.semibold))
if appState.selectedProvider == .custom {
Divider()

Text("Change this to use a different OpenAI-compatible API provider.")
.font(.caption)
.foregroundStyle(.secondary)
Text("API Base URL")
.font(.caption.weight(.semibold))

HStack(spacing: 8) {
TextField("https://api.groq.com/openai/v1", text: $apiBaseURLInput)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onChange(of: apiBaseURLInput) { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
appState.apiBaseURL = trimmed
HStack(spacing: 8) {
TextField("https://api.example.com/v1", text: $apiBaseURLInput)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onChange(of: apiBaseURLInput) { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
appState.apiBaseURL = trimmed
}
}
}

Button("Reset to Default") {
apiBaseURLInput = "https://api.groq.com/openai/v1"
appState.apiBaseURL = "https://api.groq.com/openai/v1"
}
.font(.caption)
}
}
}

private func validateAndSaveKey() {
let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
let baseURL = apiBaseURLInput.trimmingCharacters(in: .whitespacesAndNewlines)
isValidatingKey = true
keyValidationError = nil
keyValidationSuccess = false

let baseURL = appState.apiBaseURL

Task {
let valid = await TranscriptionService.validateAPIKey(key, baseURL: baseURL.isEmpty ? "https://api.groq.com/openai/v1" : baseURL)
let valid = await TranscriptionService.validateAPIKey(key, baseURL: baseURL)
await MainActor.run {
isValidatingKey = false
if valid {
Expand Down Expand Up @@ -881,7 +896,7 @@ struct PromptsSettingsView: View {
systemTestError = nil
systemTestPrompt = nil

let service = PostProcessingService(apiKey: appState.apiKey, baseURL: appState.apiBaseURL)
let service = PostProcessingService(apiKey: appState.apiKey, baseURL: appState.apiBaseURL, model: appState.selectedProvider.chatModel)
let input = systemTestInput
let customPrompt = appState.customSystemPrompt
let vocabulary = appState.customVocabulary
Expand Down Expand Up @@ -1097,6 +1112,8 @@ struct PromptsSettingsView: View {
let service = AppContextService(
apiKey: appState.apiKey,
baseURL: appState.apiBaseURL,
chatModel: appState.selectedProvider.chatModel,
visionModel: appState.selectedProvider.visionModel,
customContextPrompt: appState.customContextPrompt
)

Expand Down
Loading