From 510b425ea29442c5a78aca91a56eb74df3f59aa6 Mon Sep 17 00:00:00 2001 From: Alexey Haidamaka Date: Tue, 26 Aug 2025 22:11:02 +0200 Subject: [PATCH 1/3] Added Retry Last Transcription Shortcut --- VoiceInk/HotkeyManager.swift | 13 +++++++++++++ VoiceInk/Views/Settings/SettingsView.swift | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 4bf891c..f3eb877 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -7,6 +7,7 @@ extension KeyboardShortcuts.Name { static let toggleMiniRecorder = Self("toggleMiniRecorder") static let toggleMiniRecorder2 = Self("toggleMiniRecorder2") static let pasteLastTranscription = Self("pasteLastTranscription") + static let retryLastTranscription = Self("retryLastTranscription") } @MainActor @@ -133,6 +134,18 @@ class HotkeyManager: ObservableObject { } } + // Add retry last transcription shortcut + if KeyboardShortcuts.getShortcut(for: .retryLastTranscription) == nil { + let defaultRetryShortcut = KeyboardShortcuts.Shortcut(.r, modifiers: [.command, .option]) + KeyboardShortcuts.setShortcut(defaultRetryShortcut, for: .retryLastTranscription) + } + + KeyboardShortcuts.onKeyUp(for: .retryLastTranscription) { [weak self] in + guard let self = self else { return } + Task { @MainActor in + LastTranscriptionService.retryLastTranscription(from: self.whisperState.modelContext, whisperState: self.whisperState) + } + } Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) self.setupHotkeyMonitoring() diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index a9aa573..bf1098f 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -118,6 +118,23 @@ struct SettingsView: View { } } + SettingsSection( + icon: "arrow.clockwise.circle.fill", + title: "Retry Last Transcription", + subtitle: "Configure shortcut to retry transcribing your most recent audio" + ) { + HStack(spacing: 12) { + Text("Retry Shortcut") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + KeyboardShortcuts.Recorder(for: .retryLastTranscription) + .controlSize(.small) + + Spacer() + } + } + SettingsSection( icon: "speaker.wave.2.bubble.left.fill", title: "Recording Feedback", From c83afac0311c3abe67680cfde48d63ac162c71c9 Mon Sep 17 00:00:00 2001 From: Alexey Haidamaka Date: Thu, 28 Aug 2025 03:34:12 +0200 Subject: [PATCH 2/3] feat: Add retry logic to AI enhancement service --- VoiceInk/Services/AIEnhancementService.swift | 58 +++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift index 663ef34..7ae13d8 100644 --- a/VoiceInk/Services/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancementService.swift @@ -261,6 +261,8 @@ class AIEnhancementService: ObservableObject { let filteredText = AIEnhancementOutputFilter.filter(enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)) return filteredText + } else if (500...599).contains(httpResponse.statusCode) { + throw EnhancementError.serverError } else { let errorString = String(data: data, encoding: .utf8) ?? "Could not decode error response." throw EnhancementError.customError("HTTP \(httpResponse.statusCode): \(errorString)") @@ -268,6 +270,8 @@ class AIEnhancementService: ObservableObject { } catch let error as EnhancementError { throw error + } catch let error as URLError { + throw error } catch { throw EnhancementError.customError(error.localizedDescription) } @@ -312,6 +316,8 @@ class AIEnhancementService: ObservableObject { let filteredText = AIEnhancementOutputFilter.filter(enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)) return filteredText + } else if (500...599).contains(httpResponse.statusCode) { + throw EnhancementError.serverError } else { let errorString = String(data: data, encoding: .utf8) ?? "Could not decode error response." throw EnhancementError.customError("HTTP \(httpResponse.statusCode): \(errorString)") @@ -319,19 +325,66 @@ class AIEnhancementService: ObservableObject { } catch let error as EnhancementError { throw error + } catch let error as URLError { + throw error } catch { throw EnhancementError.customError(error.localizedDescription) } } } + private func makeRequestWithRetry(text: String, mode: EnhancementPrompt, maxRetries: Int = 3, initialDelay: TimeInterval = 1.0) async throws -> String { + var retries = 0 + var currentDelay = initialDelay + + while retries < maxRetries { + do { + return try await makeRequest(text: text, mode: mode) + } catch let error as EnhancementError { + switch error { + case .networkError, .serverError: + retries += 1 + if retries < maxRetries { + logger.warning("Request failed, retrying in \(currentDelay)s... (Attempt \(retries)/\(maxRetries))") + try await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000)) + currentDelay *= 2 // Exponential backoff + } else { + logger.error("Request failed after \(maxRetries) retries.") + throw error + } + default: + throw error + } + } catch { + // For other errors, check if it's a network-related URLError + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && [NSURLErrorNotConnectedToInternet, NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost].contains(nsError.code) { + retries += 1 + if retries < maxRetries { + logger.warning("Request failed with network error, retrying in \(currentDelay)s... (Attempt \(retries)/\(maxRetries))") + try await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000)) + currentDelay *= 2 // Exponential backoff + } else { + logger.error("Request failed after \(maxRetries) retries with network error.") + throw EnhancementError.networkError + } + } else { + throw error + } + } + } + + // This part should ideally not be reached, but as a fallback: + throw EnhancementError.enhancementFailed + } + func enhance(_ text: String) async throws -> (String, TimeInterval, String?) { let startTime = Date() let enhancementPrompt: EnhancementPrompt = .transcriptionEnhancement let promptName = activePrompt?.title do { - let result = try await makeRequest(text: text, mode: enhancementPrompt) + let result = try await makeRequestWithRetry(text: text, mode: enhancementPrompt) let endTime = Date() let duration = endTime.timeIntervalSince(startTime) return (result, duration, promptName) @@ -404,6 +457,7 @@ enum EnhancementError: Error { case invalidResponse case enhancementFailed case networkError + case serverError case customError(String) } @@ -418,6 +472,8 @@ extension EnhancementError: LocalizedError { return "AI enhancement failed to process the text." case .networkError: return "Network connection failed. Check your internet." + case .serverError: + return "The AI provider's server encountered an error. Please try again later." case .customError(let message): return message } From fb943445375249a36515c231d5c27f2eb8d0c261 Mon Sep 17 00:00:00 2001 From: Alexey Haidamaka Date: Thu, 28 Aug 2025 03:57:59 +0200 Subject: [PATCH 3/3] chore: Remove unintended changes from HotkeyManager and SettingsView --- VoiceInk/HotkeyManager.swift | 13 ------------- VoiceInk/Views/Settings/SettingsView.swift | 17 ----------------- 2 files changed, 30 deletions(-) diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index f3eb877..4bf891c 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -7,7 +7,6 @@ extension KeyboardShortcuts.Name { static let toggleMiniRecorder = Self("toggleMiniRecorder") static let toggleMiniRecorder2 = Self("toggleMiniRecorder2") static let pasteLastTranscription = Self("pasteLastTranscription") - static let retryLastTranscription = Self("retryLastTranscription") } @MainActor @@ -134,18 +133,6 @@ class HotkeyManager: ObservableObject { } } - // Add retry last transcription shortcut - if KeyboardShortcuts.getShortcut(for: .retryLastTranscription) == nil { - let defaultRetryShortcut = KeyboardShortcuts.Shortcut(.r, modifiers: [.command, .option]) - KeyboardShortcuts.setShortcut(defaultRetryShortcut, for: .retryLastTranscription) - } - - KeyboardShortcuts.onKeyUp(for: .retryLastTranscription) { [weak self] in - guard let self = self else { return } - Task { @MainActor in - LastTranscriptionService.retryLastTranscription(from: self.whisperState.modelContext, whisperState: self.whisperState) - } - } Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) self.setupHotkeyMonitoring() diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index bf1098f..a9aa573 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -118,23 +118,6 @@ struct SettingsView: View { } } - SettingsSection( - icon: "arrow.clockwise.circle.fill", - title: "Retry Last Transcription", - subtitle: "Configure shortcut to retry transcribing your most recent audio" - ) { - HStack(spacing: 12) { - Text("Retry Shortcut") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - - KeyboardShortcuts.Recorder(for: .retryLastTranscription) - .controlSize(.small) - - Spacer() - } - } - SettingsSection( icon: "speaker.wave.2.bubble.left.fill", title: "Recording Feedback",