diff --git a/VoiceInk/Models/Transcription.swift b/VoiceInk/Models/Transcription.swift index 0ee5512..46964ae 100644 --- a/VoiceInk/Models/Transcription.swift +++ b/VoiceInk/Models/Transcription.swift @@ -14,8 +14,10 @@ final class Transcription { var promptName: String? var transcriptionDuration: TimeInterval? var enhancementDuration: TimeInterval? + var aiRequestSystemMessage: String? + var aiRequestUserMessage: String? - init(text: String, duration: TimeInterval, enhancedText: String? = nil, audioFileURL: String? = nil, transcriptionModelName: String? = nil, aiEnhancementModelName: String? = nil, promptName: String? = nil, transcriptionDuration: TimeInterval? = nil, enhancementDuration: TimeInterval? = nil) { + init(text: String, duration: TimeInterval, enhancedText: String? = nil, audioFileURL: String? = nil, transcriptionModelName: String? = nil, aiEnhancementModelName: String? = nil, promptName: String? = nil, transcriptionDuration: TimeInterval? = nil, enhancementDuration: TimeInterval? = nil, aiRequestSystemMessage: String? = nil, aiRequestUserMessage: String? = nil) { self.id = UUID() self.text = text self.enhancedText = enhancedText @@ -27,5 +29,7 @@ final class Transcription { self.promptName = promptName self.transcriptionDuration = transcriptionDuration self.enhancementDuration = enhancementDuration + self.aiRequestSystemMessage = aiRequestSystemMessage + self.aiRequestUserMessage = aiRequestUserMessage } } diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift index a9d4c05..a827b5f 100644 --- a/VoiceInk/Services/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancementService.swift @@ -10,7 +10,7 @@ enum EnhancementPrompt { class AIEnhancementService: ObservableObject { private let logger = Logger(subsystem: "com.voiceink.enhancement", category: "AIEnhancementService") - + @Published var isEnhancementEnabled: Bool { didSet { UserDefaults.standard.set(isEnhancementEnabled, forKey: "isAIEnhancementEnabled") @@ -21,20 +21,20 @@ class AIEnhancementService: ObservableObject { NotificationCenter.default.post(name: .enhancementToggleChanged, object: nil) } } - + @Published var useClipboardContext: Bool { didSet { UserDefaults.standard.set(useClipboardContext, forKey: "useClipboardContext") } } - + @Published var useScreenCaptureContext: Bool { didSet { UserDefaults.standard.set(useScreenCaptureContext, forKey: "useScreenCaptureContext") NotificationCenter.default.post(name: .AppSettingsDidChange, object: nil) } } - + @Published var customPrompts: [CustomPrompt] { didSet { if let encoded = try? JSONEncoder().encode(customPrompts) { @@ -42,7 +42,7 @@ class AIEnhancementService: ObservableObject { } } } - + @Published var selectedPromptId: UUID? { didSet { UserDefaults.standard.set(selectedPromptId?.uuidString, forKey: "selectedPromptId") @@ -50,15 +50,18 @@ class AIEnhancementService: ObservableObject { NotificationCenter.default.post(name: .promptSelectionChanged, object: nil) } } - + + @Published var lastSystemMessageSent: String? + @Published var lastUserMessageSent: String? + var activePrompt: CustomPrompt? { allPrompts.first { $0.id == selectedPromptId } } - + var allPrompts: [CustomPrompt] { return customPrompts } - + private let aiService: AIService private let screenCaptureService: ScreenCaptureService private let dictionaryContextService: DictionaryContextService @@ -66,41 +69,41 @@ class AIEnhancementService: ObservableObject { private let rateLimitInterval: TimeInterval = 1.0 private var lastRequestTime: Date? private let modelContext: ModelContext - + init(aiService: AIService = AIService(), modelContext: ModelContext) { self.aiService = aiService self.modelContext = modelContext self.screenCaptureService = ScreenCaptureService() self.dictionaryContextService = DictionaryContextService.shared - + self.isEnhancementEnabled = UserDefaults.standard.bool(forKey: "isAIEnhancementEnabled") self.useClipboardContext = UserDefaults.standard.bool(forKey: "useClipboardContext") self.useScreenCaptureContext = UserDefaults.standard.bool(forKey: "useScreenCaptureContext") - + self.customPrompts = PromptMigrationService.migratePromptsIfNeeded() - + if let savedPromptId = UserDefaults.standard.string(forKey: "selectedPromptId") { self.selectedPromptId = UUID(uuidString: savedPromptId) } - + if isEnhancementEnabled && (selectedPromptId == nil || !allPrompts.contains(where: { $0.id == selectedPromptId })) { self.selectedPromptId = allPrompts.first?.id } - + NotificationCenter.default.addObserver( self, selector: #selector(handleAPIKeyChange), name: .aiProviderKeyChanged, object: nil ) - + initializePredefinedPrompts() } - + deinit { NotificationCenter.default.removeObserver(self) } - + @objc private func handleAPIKeyChange() { DispatchQueue.main.async { self.objectWillChange.send() @@ -109,15 +112,15 @@ class AIEnhancementService: ObservableObject { } } } - + func getAIService() -> AIService? { return aiService } - + var isConfigured: Bool { aiService.isAPIKeyValid } - + private func waitForRateLimit() async throws { if let lastRequest = lastRequestTime { let timeSinceLastRequest = Date().timeIntervalSince(lastRequest) @@ -127,14 +130,14 @@ class AIEnhancementService: ObservableObject { } lastRequestTime = Date() } - + private func getSystemMessage(for mode: EnhancementPrompt) -> String { let selectedText = SelectedTextService.fetchSelectedText() - + if let activePrompt = activePrompt, activePrompt.id == PredefinedPrompts.assistantPromptId, let selectedText = selectedText, !selectedText.isEmpty { - + let selectedTextContext = "\n\nSelected Text: \(selectedText)" let generalContextSection = "\n\n\(selectedTextContext)\n" let dictionaryContextSection = if !dictionaryContextService.getDictionaryContext().isEmpty { @@ -144,7 +147,7 @@ class AIEnhancementService: ObservableObject { } return activePrompt.promptText + generalContextSection + dictionaryContextSection } - + let clipboardContext = if useClipboardContext, let clipboardText = NSPasteboard.general.string(forType: .string), !clipboardText.isEmpty { @@ -152,7 +155,7 @@ class AIEnhancementService: ObservableObject { } else { "" } - + let screenCaptureContext = if useScreenCaptureContext, let capturedText = screenCaptureService.lastCapturedText, !capturedText.isEmpty { @@ -160,21 +163,21 @@ class AIEnhancementService: ObservableObject { } else { "" } - + let dictionaryContext = dictionaryContextService.getDictionaryContext() - + let generalContextSection = if !clipboardContext.isEmpty || !screenCaptureContext.isEmpty { "\n\n\(clipboardContext)\(screenCaptureContext)\n" } else { "" } - + let dictionaryContextSection = if !dictionaryContext.isEmpty { "\n\n\(dictionaryContext)\n" } else { "" } - + guard let activePrompt = activePrompt else { if let defaultPrompt = allPrompts.first(where: { $0.id == PredefinedPrompts.defaultPromptId }) { var systemMessage = String(format: AIPrompts.customPromptTemplate, defaultPrompt.promptText) @@ -183,32 +186,36 @@ class AIEnhancementService: ObservableObject { } return AIPrompts.assistantMode + generalContextSection + dictionaryContextSection } - + if activePrompt.id == PredefinedPrompts.assistantPromptId { return activePrompt.promptText + generalContextSection + dictionaryContextSection } - + var systemMessage = String(format: AIPrompts.customPromptTemplate, activePrompt.promptText) systemMessage += generalContextSection + dictionaryContextSection return systemMessage } - + private func makeRequest(text: String, mode: EnhancementPrompt) async throws -> String { guard isConfigured else { throw EnhancementError.notConfigured } - + guard !text.isEmpty else { return "" // Silently return empty string instead of throwing error } - + let formattedText = "\n\n\(text)\n" let systemMessage = getSystemMessage(for: mode) + // Persist the exact payload being sent (also used for UI) + self.lastSystemMessageSent = systemMessage + self.lastUserMessageSent = formattedText + // Log the message being sent to AI enhancement logger.notice("AI Enhancement - System Message: \(systemMessage, privacy: .public)") logger.notice("AI Enhancement - User Message: \(formattedText, privacy: .public)") - + if aiService.selectedProvider == .ollama { do { let result = try await aiService.enhanceWithOllama(text: formattedText, systemPrompt: systemMessage) @@ -222,9 +229,9 @@ class AIEnhancementService: ObservableObject { } } } - + try await waitForRateLimit() - + switch aiService.selectedProvider { case .anthropic: let requestBody: [String: Any] = [ @@ -235,7 +242,7 @@ class AIEnhancementService: ObservableObject { ["role": "user", "content": formattedText] ] ] - + var request = URLRequest(url: URL(string: aiService.selectedProvider.baseURL)!) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") @@ -243,14 +250,14 @@ class AIEnhancementService: ObservableObject { request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.timeoutInterval = baseTimeout request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) - + do { let (data, response) = try await URLSession.shared.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse else { throw EnhancementError.invalidResponse } - + if httpResponse.statusCode == 200 { guard let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let content = jsonResponse["content"] as? [[String: Any]], @@ -258,7 +265,7 @@ class AIEnhancementService: ObservableObject { let enhancedText = firstContent["text"] as? String else { throw EnhancementError.enhancementFailed } - + let filteredText = AIEnhancementOutputFilter.filter(enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)) return filteredText } else if httpResponse.statusCode == 429 { @@ -269,7 +276,7 @@ class AIEnhancementService: ObservableObject { let errorString = String(data: data, encoding: .utf8) ?? "Could not decode error response." throw EnhancementError.customError("HTTP \(httpResponse.statusCode): \(errorString)") } - + } catch let error as EnhancementError { throw error } catch let error as URLError { @@ -277,7 +284,7 @@ class AIEnhancementService: ObservableObject { } catch { throw EnhancementError.customError(error.localizedDescription) } - + default: let url = URL(string: aiService.selectedProvider.baseURL)! var request = URLRequest(url: url) @@ -336,7 +343,7 @@ class AIEnhancementService: ObservableObject { } } } - + private func makeRequestWithRetry(text: String, mode: EnhancementPrompt, maxRetries: Int = 3, initialDelay: TimeInterval = 1.0) async throws -> String { var retries = 0 var currentDelay = initialDelay @@ -386,7 +393,7 @@ class AIEnhancementService: ObservableObject { let startTime = Date() let enhancementPrompt: EnhancementPrompt = .transcriptionEnhancement let promptName = activePrompt?.title - + do { let result = try await makeRequestWithRetry(text: text, mode: enhancementPrompt) let endTime = Date() @@ -396,17 +403,17 @@ class AIEnhancementService: ObservableObject { throw error } } - + func captureScreenContext() async { guard useScreenCaptureContext else { return } - + if let capturedText = await screenCaptureService.captureAndExtractText() { await MainActor.run { self.objectWillChange.send() } } } - + func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil, triggerWords: [String] = []) { let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false, triggerWords: triggerWords) customPrompts.append(newPrompt) @@ -414,27 +421,27 @@ class AIEnhancementService: ObservableObject { selectedPromptId = newPrompt.id } } - + func updatePrompt(_ prompt: CustomPrompt) { if let index = customPrompts.firstIndex(where: { $0.id == prompt.id }) { customPrompts[index] = prompt } } - + func deletePrompt(_ prompt: CustomPrompt) { customPrompts.removeAll { $0.id == prompt.id } if selectedPromptId == prompt.id { selectedPromptId = allPrompts.first?.id } } - + func setActivePrompt(_ prompt: CustomPrompt) { selectedPromptId = prompt.id } - + private func initializePredefinedPrompts() { let predefinedTemplates = PredefinedPrompts.createDefaultPrompts() - + for template in predefinedTemplates { if let existingIndex = customPrompts.firstIndex(where: { $0.id == template.id }) { var updatedPrompt = customPrompts[existingIndex] diff --git a/VoiceInk/Services/AudioFileTranscriptionManager.swift b/VoiceInk/Services/AudioFileTranscriptionManager.swift index c80e0b2..727cd3b 100644 --- a/VoiceInk/Services/AudioFileTranscriptionManager.swift +++ b/VoiceInk/Services/AudioFileTranscriptionManager.swift @@ -124,6 +124,7 @@ class AudioTranscriptionManager: ObservableObject { enhancementService.isConfigured { processingPhase = .enhancing do { + // inside the enhancement success path where transcription is created let (enhancedText, enhancementDuration, promptName) = try await enhancementService.enhance(text) let transcription = Transcription( text: text, @@ -134,7 +135,9 @@ class AudioTranscriptionManager: ObservableObject { aiEnhancementModelName: enhancementService.getAIService()?.currentModel, promptName: promptName, transcriptionDuration: transcriptionDuration, - enhancementDuration: enhancementDuration + enhancementDuration: enhancementDuration, + aiRequestSystemMessage: enhancementService.lastSystemMessageSent, + aiRequestUserMessage: enhancementService.lastUserMessageSent ) modelContext.insert(transcription) try modelContext.save() @@ -211,4 +214,4 @@ enum TranscriptionError: Error, LocalizedError { return "Transcription was cancelled" } } -} +} diff --git a/VoiceInk/Services/AudioFileTranscriptionService.swift b/VoiceInk/Services/AudioFileTranscriptionService.swift index f43f112..c811496 100644 --- a/VoiceInk/Services/AudioFileTranscriptionService.swift +++ b/VoiceInk/Services/AudioFileTranscriptionService.swift @@ -95,8 +95,8 @@ class AudioTranscriptionService: ObservableObject { enhancementService.isEnhancementEnabled, enhancementService.isConfigured { do { + // inside the enhancement success path where newTranscription is created let (enhancedText, enhancementDuration, promptName) = try await enhancementService.enhance(text) - let newTranscription = Transcription( text: text, duration: duration, @@ -106,7 +106,9 @@ class AudioTranscriptionService: ObservableObject { aiEnhancementModelName: enhancementService.getAIService()?.currentModel, promptName: promptName, transcriptionDuration: transcriptionDuration, - enhancementDuration: enhancementDuration + enhancementDuration: enhancementDuration, + aiRequestSystemMessage: enhancementService.lastSystemMessageSent, + aiRequestUserMessage: enhancementService.lastUserMessageSent ) modelContext.insert(newTranscription) do { diff --git a/VoiceInk/Views/TranscriptionCard.swift b/VoiceInk/Views/TranscriptionCard.swift index 608825b..22d920e 100644 --- a/VoiceInk/Views/TranscriptionCard.swift +++ b/VoiceInk/Views/TranscriptionCard.swift @@ -7,6 +7,7 @@ struct TranscriptionCard: View { let isSelected: Bool let onDelete: () -> Void let onToggleSelection: () -> Void + @State private var isAIRequestExpanded: Bool = false var body: some View { HStack(spacing: 12) { @@ -77,6 +78,63 @@ struct TranscriptionCard: View { } } + // NEW: AI Request payload (System + User messages) - folded by default + if isExpanded, (transcription.aiRequestSystemMessage != nil || transcription.aiRequestUserMessage != nil) { + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "paperplane.fill") + .foregroundColor(.purple) + Text("AI Request") + .fontWeight(.semibold) + .foregroundColor(.purple) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut) { + isAIRequestExpanded.toggle() + } + } + + if isAIRequestExpanded { + VStack(alignment: .leading, spacing: 12) { + if let systemMsg = transcription.aiRequestSystemMessage, !systemMsg.isEmpty { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("System Prompt") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + Spacer() + AnimatedCopyButton(textToCopy: systemMsg) + } + Text(systemMsg) + .font(.system(size: 13, weight: .regular, design: .monospaced)) + .lineSpacing(2) + } + } + + if let userMsg = transcription.aiRequestUserMessage, !userMsg.isEmpty { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("User Message") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + Spacer() + AnimatedCopyButton(textToCopy: userMsg) + } + Text(userMsg) + .font(.system(size: 13, weight: .regular, design: .monospaced)) + .lineSpacing(2) + } + } + } + } + } + } + // Audio player (if available) if isExpanded, let urlString = transcription.audioFileURL, let url = URL(string: urlString), diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index db95d84..acc3b1e 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -298,17 +298,19 @@ class WhisperState: NSObject, ObservableObject { await MainActor.run { self.recordingState = .enhancing } let textForAI = promptDetectionResult?.processedText ?? text let (enhancedText, enhancementDuration, promptName) = try await enhancementService.enhance(textForAI) - let newTranscription = Transcription( - text: originalText, - duration: actualDuration, - enhancedText: enhancedText, - audioFileURL: url.absoluteString, - transcriptionModelName: model.displayName, - aiEnhancementModelName: enhancementService.getAIService()?.currentModel, - promptName: promptName, - transcriptionDuration: transcriptionDuration, - enhancementDuration: enhancementDuration - ) + let newTranscription = Transcription( + text: originalText, + duration: actualDuration, + enhancedText: enhancedText, + audioFileURL: url.absoluteString, + transcriptionModelName: model.displayName, + aiEnhancementModelName: enhancementService.getAIService()?.currentModel, + promptName: promptName, + transcriptionDuration: transcriptionDuration, + enhancementDuration: enhancementDuration, + aiRequestSystemMessage: enhancementService.lastSystemMessageSent, + aiRequestUserMessage: enhancementService.lastUserMessageSent + ) modelContext.insert(newTranscription) try? modelContext.save() NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription)