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)