diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 82d0fbec..ea13409e 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -339,6 +339,14 @@ final class SettingsStore: ObservableObject { } } + var sendCustomPromptOnly: Bool { + get { self.defaults.bool(forKey: Keys.sendCustomPromptOnly) } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.sendCustomPromptOnly) + } + } + var isEditPromptOff: Bool { get { self.defaults.bool(forKey: Keys.editPromptOff) } set { @@ -1034,7 +1042,7 @@ final class SettingsStore: ObservableObject { profile: profile, appBinding: binding, promptBody: body, - systemPrompt: Self.combineBasePrompt(for: normalizedMode, with: body) + systemPrompt: self.systemPrompt(forCustomProfileBody: body, mode: normalizedMode) ) } } @@ -1063,7 +1071,7 @@ final class SettingsStore: ObservableObject { profile: profile, appBinding: nil, promptBody: body, - systemPrompt: Self.combineBasePrompt(for: normalizedMode, with: body) + systemPrompt: self.systemPrompt(forCustomProfileBody: body, mode: normalizedMode) ) } } @@ -1113,7 +1121,7 @@ final class SettingsStore: ObservableObject { } let body = Self.stripBasePrompt(for: .dictate, from: profile.prompt) if !body.isEmpty { - return Self.combineBasePrompt(for: .dictate, with: body) + return self.systemPrompt(forCustomProfileBody: body, mode: .dictate) } return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) } @@ -1188,6 +1196,15 @@ final class SettingsStore: ObservableObject { ) } + private func systemPrompt(forCustomProfileBody body: String, mode: PromptMode) -> String { + let normalizedMode = mode.normalized + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedMode == .dictate, self.sendCustomPromptOnly { + return trimmedBody + } + return Self.combineBasePrompt(for: normalizedMode, with: trimmedBody) + } + // MARK: - Model Reasoning Configuration /// Configuration for model-specific reasoning/thinking parameters @@ -4444,6 +4461,7 @@ private extension SettingsStore { static let dictationPromptProfiles = "DictationPromptProfiles" static let appPromptBindings = "AppPromptBindings" static let selectedDictationPromptID = "SelectedDictationPromptID" + static let sendCustomPromptOnly = "SendCustomPromptOnly" static let editPromptOff = "EditPromptOff" static let selectedEditPromptID = "SelectedEditPromptID" static let selectedWritePromptID = "SelectedWritePromptID" // legacy fallback key diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift index bd23b61e..524ff9f2 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift @@ -4,7 +4,9 @@ enum AIEnhancementConfigurationSection: String, CaseIterable, Identifiable { case providers case advancedPrompts - var id: String { self.rawValue } + var id: String { + self.rawValue + } var title: String { switch self { @@ -142,4 +144,43 @@ struct AIEnhancementSettingsView: View { Text(self.viewModel.appPromptBindingErrorMessage) } } + + var customPromptOnlyToggleRow: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "text.quote") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.theme.palette.accent) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 3) { + Text("Send Custom Prompt Only") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.theme.palette.primaryText) + Text("For custom Dictate prompts, send your prompt without prepending the built-in dictation prompt.") + .font(.caption2) + .foregroundStyle(self.theme.palette.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + Toggle("", isOn: Binding( + get: { self.viewModel.sendCustomPromptOnly }, + set: { self.viewModel.setSendCustomPromptOnly($0) } + )) + .toggleStyle(.switch) + .labelsHidden() + .help("Send custom Dictate prompts without prepending the built-in dictation prompt.") + } + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.theme.palette.cardBackground.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.32), lineWidth: 1) + ) + ) + } } diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 7f402991..15a0b256 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -81,12 +81,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var showKeychainPermissionAlert: Bool = false @Published var keychainPermissionMessage: String = "" - // Reasoning config change tracker (triggers view updates) + /// Reasoning config change tracker (triggers view updates) @Published var reasoningConfigVersion: Int = 0 // MARK: - Cached Provider Items (for scroll performance) - // These are cached to avoid recomputing on every view body evaluation + /// These are cached to avoid recomputing on every view body evaluation struct ProviderItemData: Identifiable, Hashable { let id: String let name: String @@ -97,7 +97,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let bundleID: String let name: String - var id: String { self.bundleID } + var id: String { + self.bundleID + } } @Published var cachedProviderItems: [ProviderItemData] = [] @@ -110,6 +112,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var appPromptBindingErrorMessage: String = "" @Published var selectedDictationPromptID: String? = nil @Published var selectedEditPromptID: String? = nil + @Published var sendCustomPromptOnly: Bool = false @Published var promptEditorMode: PromptEditorMode? = nil @Published var draftPromptName: String = "" @Published var draftPromptText: String = "" @@ -165,6 +168,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.appPromptBindings = self.settings.appPromptBindings self.selectedDictationPromptID = self.settings.selectedDictationPromptID self.selectedEditPromptID = self.settings.selectedEditPromptID + self.sendCustomPromptOnly = self.settings.sendCustomPromptOnly self.isDictationPromptOff = self.settings.isDictationPromptOff self.isEditPromptOff = self.settings.isEditPromptOff @@ -1876,6 +1880,11 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.isEditPromptOff = self.settings.isEditPromptOff } + func setSendCustomPromptOnly(_ sendOnly: Bool) { + self.settings.sendCustomPromptOnly = sendOnly + self.sendCustomPromptOnly = self.settings.sendCustomPromptOnly + } + func isPrimaryDictationPromptSelectionOff() -> Bool { return self.settings.isDictationPromptOff } @@ -1984,6 +1993,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { private func refreshPromptSelectionState() { self.selectedDictationPromptID = self.settings.selectedDictationPromptID self.selectedEditPromptID = self.settings.selectedEditPromptID + self.sendCustomPromptOnly = self.settings.sendCustomPromptOnly self.isDictationPromptOff = self.settings.isDictationPromptOff self.isEditPromptOff = self.settings.isEditPromptOff } diff --git a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift index 15f4580a..8b4e8c40 100644 --- a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift +++ b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift @@ -962,6 +962,10 @@ extension AIEnhancementSettingsView { } else { self.promptRoutingScopeRow(mode: mode) + if mode.normalized == .dictate { + self.customPromptOnlyToggleRow + } + Text( isSelectedAppsOnly ? "Custom prompts only run in apps listed in App Overrides." @@ -1692,6 +1696,10 @@ extension AIEnhancementSettingsView { self.promptTest.updateDraftPromptText(combined) } } + + if self.viewModel.draftPromptMode == .dictate { + self.baseDictationPromptReference + } } if self.viewModel.draftPromptMode != .dictate { @@ -1910,6 +1918,32 @@ extension AIEnhancementSettingsView { } } + private var baseDictationPromptReference: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Built-in Base Prompt") + .font(.caption) + .foregroundStyle(.secondary) + Text("Reference only. Copy any parts you want into a custom prompt.") + .font(.caption2) + .foregroundStyle(self.theme.palette.secondaryText) + + PromptTextView( + text: .constant(SettingsStore.baseDictationPromptText()), + isEditable: false, + font: NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + ) + .frame(height: 110) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder, lineWidth: 1) + ) + ) + } + } + private func autoDisablePromptTestIfNeeded() { guard self.promptTest.isActive else { return } if !self.viewModel.isAIPostProcessingConfiguredForDictation() { diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 2611ebeb..25add37b 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -7,7 +7,6 @@ import XCTest @MainActor final class LLMClientRequestBodyTests: XCTestCase { - private func config(streaming: Bool) -> LLMClient.Config { LLMClient.Config( messages: [["role": "user", "content": "hello"]], @@ -18,29 +17,183 @@ final class LLMClientRequestBodyTests: XCTestCase { ) } + private func config(messages: [[String: Any]]) -> LLMClient.Config { + LLMClient.Config( + messages: messages, + model: "llama3", + baseURL: "http://localhost:11434/v1", + apiKey: "", + streaming: false + ) + } + // MARK: - Chat Completions endpoint func testChatCompletionsBody_streamFalse_keyIsPresentAndFalse() { - let body = LLMClient.shared.buildChatCompletionsBody(config(streaming: false)) + let body = LLMClient.shared.buildChatCompletionsBody(self.config(streaming: false)) XCTAssertNotNil(body["stream"], "stream key must be present when streaming=false — absent key breaks Ollama-compatible providers") XCTAssertEqual(body["stream"] as? Bool, false) } func testChatCompletionsBody_streamTrue_keyIsPresentAndTrue() { - let body = LLMClient.shared.buildChatCompletionsBody(config(streaming: true)) + let body = LLMClient.shared.buildChatCompletionsBody(self.config(streaming: true)) XCTAssertEqual(body["stream"] as? Bool, true) } // MARK: - Responses endpoint func testResponsesBody_streamFalse_keyIsPresentAndFalse() { - let body = LLMClient.shared.buildResponsesBody(config(streaming: false)) + let body = LLMClient.shared.buildResponsesBody(self.config(streaming: false)) XCTAssertNotNil(body["stream"], "stream key must be present when streaming=false") XCTAssertEqual(body["stream"] as? Bool, false) } func testResponsesBody_streamTrue_keyIsPresentAndTrue() { - let body = LLMClient.shared.buildResponsesBody(config(streaming: true)) + let body = LLMClient.shared.buildResponsesBody(self.config(streaming: true)) XCTAssertEqual(body["stream"] as? Bool, true) } + + // MARK: - Dictation custom prompt resolution + + func testCustomPromptOnly_omitsBasePromptFromEffectivePromptAndRequestBody() { + self.withPromptSettingsRestored { + let settings = SettingsStore.shared + self.resetPromptSettings(settings) + + let profile = SettingsStore.DictationPromptProfile( + name: "Gemma", + prompt: "Clean this transcript. Return corrected text only.", + mode: .dictate + ) + settings.dictationPromptProfiles = [profile] + settings.selectedDictationPromptID = profile.id + settings.sendCustomPromptOnly = true + + let prompt = settings.effectiveDictationSystemPrompt(for: .primary) + XCTAssertEqual(prompt, profile.prompt) + + let userMessage = SettingsStore.renderDictationUserMessage( + promptText: prompt, + transcript: "hello comma world" + ) + let body = LLMClient.shared.buildChatCompletionsBody(self.config(messages: [["role": "user", "content": userMessage]])) + let messageContents = self.chatMessageContents(from: body) + + XCTAssertFalse(messageContents.contains { $0.contains(Self.basePromptMarker) }) + XCTAssertTrue(messageContents.contains { $0.contains(profile.prompt) }) + } + } + + func testCustomPromptOnly_defaultFalsePrependsBasePrompt() { + self.withPromptSettingsRestored { + let settings = SettingsStore.shared + self.resetPromptSettings(settings) + + let profile = SettingsStore.DictationPromptProfile( + name: "Back Compat", + prompt: "Use my cleanup rules.", + mode: .dictate + ) + settings.dictationPromptProfiles = [profile] + settings.selectedDictationPromptID = profile.id + settings.sendCustomPromptOnly = false + + XCTAssertEqual( + settings.effectiveDictationSystemPrompt(for: .primary), + SettingsStore.combineBasePrompt(for: .dictate, with: profile.prompt) + ) + } + } + + func testCustomPromptOnly_defaultPromptStillUsesBuiltInPrompt() { + self.withPromptSettingsRestored { + let settings = SettingsStore.shared + self.resetPromptSettings(settings) + + settings.sendCustomPromptOnly = true + + let prompt = settings.effectiveDictationSystemPrompt(for: .primary) + XCTAssertFalse(prompt.isEmpty) + XCTAssertEqual(prompt, SettingsStore.defaultSystemPromptText(for: .dictate)) + } + } + + func testCustomPromptOnly_omitsBasePromptForAppBoundCustomPrompt() { + self.withPromptSettingsRestored { + let settings = SettingsStore.shared + self.resetPromptSettings(settings) + + let global = SettingsStore.DictationPromptProfile( + name: "Global", + prompt: "Global cleanup rules.", + mode: .dictate + ) + let mail = SettingsStore.DictationPromptProfile( + name: "Mail", + prompt: "Mail cleanup rules only.", + mode: .dictate + ) + + settings.dictationPromptProfiles = [global, mail] + settings.selectedDictationPromptID = nil + settings.appPromptBindings = [ + SettingsStore.AppPromptBinding( + mode: .dictate, + appBundleID: "com.apple.mail", + appName: "Mail", + promptID: mail.id + ), + ] + settings.sendCustomPromptOnly = true + + XCTAssertEqual( + settings.effectiveDictationSystemPrompt(for: .primary, appBundleID: "com.apple.mail"), + mail.prompt + ) + XCTAssertEqual( + settings.effectiveDictationSystemPrompt(for: .primary, appBundleID: "com.apple.notes"), + SettingsStore.defaultSystemPromptText(for: .dictate) + ) + } + } + + private static let basePromptMarker = "You are a voice-to-text dictation cleaner" + + private func resetPromptSettings(_ settings: SettingsStore) { + settings.dictationPromptProfiles = [] + settings.appPromptBindings = [] + settings.selectedDictationPromptID = nil + settings.isDictationPromptOff = false + settings.dictationPromptRoutingScope = .allApps + settings.defaultDictationPromptOverride = nil + settings.sendCustomPromptOnly = false + } + + private func withPromptSettingsRestored(run: () -> Void) { + let settings = SettingsStore.shared + let profiles = settings.dictationPromptProfiles + let appBindings = settings.appPromptBindings + let selectedDictationPromptID = settings.selectedDictationPromptID + let isDictationPromptOff = settings.isDictationPromptOff + let dictationPromptRoutingScope = settings.dictationPromptRoutingScope + let defaultDictationPromptOverride = settings.defaultDictationPromptOverride + let sendCustomPromptOnly = settings.sendCustomPromptOnly + + defer { + settings.dictationPromptProfiles = profiles + settings.appPromptBindings = appBindings + settings.selectedDictationPromptID = selectedDictationPromptID + settings.isDictationPromptOff = isDictationPromptOff + settings.dictationPromptRoutingScope = dictationPromptRoutingScope + settings.defaultDictationPromptOverride = defaultDictationPromptOverride + settings.sendCustomPromptOnly = sendCustomPromptOnly + } + + run() + } + + private func chatMessageContents(from body: [String: Any]) -> [String] { + guard let messages = body["messages"] as? [[String: Any]] else { return [] } + return messages.compactMap { $0["content"] as? String } + } }