diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index b0f2739..51e2149 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -10,11 +10,15 @@ import ScreenCaptureKit class AudioManager: NSObject, ObservableObject { @Published var transcriptChunks: [TranscriptChunk] = [] @Published var isRecording = false + @Published var errorMessage: String? private var audioEngine = AVAudioEngine() private var micSocketTask: URLSessionWebSocketTask? private var systemSocketTask: URLSessionWebSocketTask? private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")! + + // Unique identifier for the current recording session + private var sessionID = UUID() // ScreenCaptureKit properties private var stream: SCStream? @@ -42,6 +46,14 @@ class AudioManager: NSObject, ObservableObject { func startRecording() { print("Starting recording...") + // Bump session ID so any old async callbacks can be ignored + sessionID = UUID() + + // Clear any previous errors + DispatchQueue.main.async { + self.errorMessage = nil + } + // First ensure everything is stopped and cleaned up stopRecordingInternal() @@ -76,9 +88,7 @@ class AudioManager: NSObject, ObservableObject { systemSocketTask = nil // Reset state - DispatchQueue.main.async { - self.isRecording = false - } + // (isRecording already cleared in stopRecording) print("Internal cleanup completed") } @@ -238,6 +248,8 @@ class AudioManager: NSObject, ObservableObject { } func stopRecording() { + // Immediately mark as not recording to prevent stale callbacks + self.isRecording = false print("Stopping recording...") // Stop system audio capture @@ -256,10 +268,6 @@ class AudioManager: NSObject, ObservableObject { systemSocketTask?.cancel(with: .normalClosure, reason: nil) systemSocketTask = nil - DispatchQueue.main.async { - self.isRecording = false - } - print("Recording stopped") } @@ -295,7 +303,11 @@ class AudioManager: NSObject, ObservableObject { private func connectToOpenAIRealtime(source: AudioSource) { guard let key = KeychainHelper.shared.getAPIKey(), !key.isEmpty else { - print("❌ No OpenAI key found") + let errorMsg = ErrorMessage.noAPIKey + print("❌ \(errorMsg)") + DispatchQueue.main.async { + self.errorMessage = errorMsg + } return } @@ -305,7 +317,21 @@ class AudioManager: NSObject, ObservableObject { request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta") let task = session.webSocketTask(with: request) + + // Add connection monitoring task.resume() + + let thisSession = sessionID + // Monitor connection state (ignore if session changed or recording stopped) + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in + guard let self = self, self.sessionID == thisSession, self.isRecording else { return } + guard let task = task, task.state != .running else { return } + let errorMsg = ErrorMessage.connectionTimeout + print("❌ \(errorMsg)") + DispatchQueue.main.async { + self.errorMessage = errorMsg + } + } // Send initial configuration let config: [String: Any] = [ @@ -328,14 +354,29 @@ class AudioManager: NSObject, ObservableObject { do { let jsonData = try JSONSerialization.data(withJSONObject: config) if let jsonStr = String(data: jsonData, encoding: .utf8) { - task.send(.string(jsonStr)) { error in + task.send(.string(jsonStr)) { [weak self] error in if let error = error { - print("❌ Config send error: \(error)") + guard let self = self, self.sessionID == thisSession else { return } + + // Ignore cancellation errors, which are expected when stopping a session. + if (error as? URLError)?.code == .cancelled { + return + } + + let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))" + print("❌ \(errorMsg)") + DispatchQueue.main.async { + self.errorMessage = errorMsg + } } } } } catch { - print("❌ Config JSON error: \(error)") + let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))" + print("❌ \(errorMsg)") + DispatchQueue.main.async { + self.errorMessage = errorMsg + } } switch source { @@ -345,11 +386,11 @@ class AudioManager: NSObject, ObservableObject { systemSocketTask = task } - receiveMessage(for: source) + receiveMessage(for: source, sessionID: thisSession) print("🌐 Connected to OpenAI Realtime (\(source))") } - private func receiveMessage(for source: AudioSource) { + private func receiveMessage(for source: AudioSource, sessionID: UUID) { let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask task?.receive { [weak self] result in switch result { @@ -362,18 +403,44 @@ class AudioManager: NSObject, ObservableObject { @unknown default: break } - self?.receiveMessage(for: source) // continue loop + // Continue loop for this session + if let self = self, self.sessionID == sessionID { + self.receiveMessage(for: source, sessionID: sessionID) + } case .failure(let error): + guard let self = self, self.sessionID == sessionID else { return } // Stale callback + // Ignore errors caused by intentional socket closure after recording stops + if self.isRecording == false { return } + + let errorMsg = self.handleWebSocketError(error, source: source) print("❌ Receive error (\(source)): \(error)") - // Attempt reconnect if still recording - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - if self?.isRecording == true { - self?.connectToOpenAIRealtime(source: source) + + DispatchQueue.main.async { + self.errorMessage = errorMsg + } + + // Only attempt reconnect for network errors, not API errors + if ErrorHandler.shared.shouldRetry(error) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self = self, self.isRecording, self.sessionID == sessionID else { return } + self.connectToOpenAIRealtime(source: source) } } } } } + + private func handleWebSocketError(_ error: Error, source: AudioSource) -> String { + // Check for WebSocket close codes first + if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int { + return ErrorHandler.shared.handleWebSocketCloseCode(closeCode) + } + + // Use centralized error handler for all other errors + return ErrorHandler.shared.handleError(error) + } + + private func parseRealtimeEvent(_ text: String, source: AudioSource) { guard let data = text.data(using: .utf8), @@ -433,12 +500,19 @@ class AudioManager: NSObject, ObservableObject { let base64 = data.base64EncodedString() let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64] - + + let thisSession = self.sessionID do { let jsonData = try JSONSerialization.data(withJSONObject: message) if let jsonStr = String(data: jsonData, encoding: .utf8) { - socket.send(.string(jsonStr)) { error in + socket.send(.string(jsonStr)) { [weak self] error in if let error = error { + guard let self = self, self.sessionID == thisSession else { return } + + // Ignore cancellation errors, which are expected when stopping recording. + if (error as? URLError)?.code == .cancelled { + return + } print("❌ Send error (\(source)): \(error)") } } diff --git a/meetingnotes/Services/APIKeyValidator.swift b/meetingnotes/Services/APIKeyValidator.swift new file mode 100644 index 0000000..f47bc44 --- /dev/null +++ b/meetingnotes/Services/APIKeyValidator.swift @@ -0,0 +1,89 @@ +// APIKeyValidator.swift +// Service to validate OpenAI API keys and check for sufficient funds + +import Foundation + +/// Service to validate OpenAI API keys +class APIKeyValidator { + static let shared = APIKeyValidator() + + private init() {} + + /// Validates the OpenAI API key by making a test request + /// - Parameter apiKey: The API key to validate + /// - Returns: Result indicating success or failure with error message + func validateAPIKey(_ apiKey: String) async -> Result { + guard !apiKey.isEmpty else { + return .failure(.emptyKey) + } + + // Make a simple request to validate the key + guard let url = URL(string: "https://api.openai.com/v1/models") else { + return .failure(.invalidURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + return .failure(.networkError("Invalid response")) + } + + switch httpResponse.statusCode { + case 200: + // Key is valid - check if models are available + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let models = json["data"] as? [[String: Any]], !models.isEmpty { + return .success(()) + } else { + return .failure(.noModelsAvailable) + } + default: + let errorMessage = ErrorHandler.shared.handleHTTPStatusCode(httpResponse.statusCode) + return .failure(.httpError(errorMessage)) + } + } catch { + let errorMessage = ErrorHandler.shared.handleError(error) + return .failure(.networkError(errorMessage)) + } + } + + /// Validates the currently stored API key + /// - Returns: Result indicating success or failure with error message + func validateCurrentAPIKey() async -> Result { + guard let apiKey = KeychainHelper.shared.getAPIKey() else { + return .failure(.emptyKey) + } + + return await validateAPIKey(apiKey) + } +} + +/// Errors that can occur during API key validation +enum APIKeyValidationError: Error, LocalizedError { + case emptyKey + case invalidURL + case noModelsAvailable + case networkError(String) + case httpError(String) + + var errorDescription: String? { + switch self { + case .emptyKey: + return ErrorMessage.noAPIKey + case .invalidURL: + return ErrorMessage.invalidURL + case .noModelsAvailable: + return ErrorMessage.noModelsAvailable + case .networkError(let message): + return message + case .httpError(let message): + return message + } + } +} \ No newline at end of file diff --git a/meetingnotes/Services/ErrorHandler.swift b/meetingnotes/Services/ErrorHandler.swift new file mode 100644 index 0000000..f1e5895 --- /dev/null +++ b/meetingnotes/Services/ErrorHandler.swift @@ -0,0 +1,191 @@ +// ErrorHandler.swift +// Centralized error handling service for OpenAI API and network errors + +import Foundation + +/// Centralized error handling service +class ErrorHandler { + static let shared = ErrorHandler() + + private init() {} + + /// Handles errors from OpenAI API calls and network requests + /// - Parameter error: The error to handle + /// - Returns: User-friendly error message + func handleError(_ error: Error) -> String { + // Handle network errors + if let urlError = error as? URLError { + return handleNetworkError(urlError) + } + + // Handle HTTP response errors + if let httpError = error as? HTTPError { + return handleHTTPError(httpError) + } + + // Handle OpenAI API errors by checking error description + let errorDescription = error.localizedDescription.lowercased() + if let openAIError = categorizeOpenAIError(errorDescription) { + return openAIError + } + + // Generic error fallback + return "An unexpected error occurred: \(error.localizedDescription)" + } + + /// Handles WebSocket close codes + /// - Parameter closeCode: WebSocket close code + /// - Returns: User-friendly error message + func handleWebSocketCloseCode(_ closeCode: Int) -> String { + switch closeCode { + case 1000: return "Connection closed normally" + case 1001: return ErrorMessage.connectionLost + case 1002: return "Connection protocol error. Please try again." + case 1003: return ErrorMessage.unsupportedData + case 1008: return "API policy violation. Please check your API key and account status." + case 1011: return ErrorMessage.apiServerError + case 4000: return ErrorMessage.badRequest + case 4001: return ErrorMessage.invalidAPIKey + case 4002: return ErrorMessage.accessForbidden + case 4003: return ErrorMessage.apiEndpointNotFound + case 4004: return "Invalid API method. Please update the app." + case 4005: return ErrorMessage.requestTimeout + case 4006: return ErrorMessage.requestTooLarge + case 4007: return ErrorMessage.rateLimited + case 4008: return ErrorMessage.insufficientFunds + default: return "Connection error (code \(closeCode)). Please try again." + } + } + + /// Handles HTTP status codes + /// - Parameter statusCode: HTTP status code + /// - Parameter message: Optional error message + /// - Returns: User-friendly error message + func handleHTTPStatusCode(_ statusCode: Int, message: String? = nil) -> String { + switch statusCode { + case 200...299: + return ErrorMessage.success + case 400: + return ErrorMessage.badRequest + case 401: + return ErrorMessage.invalidAPIKey + case 402: + return ErrorMessage.insufficientFunds + case 403: + return ErrorMessage.accessForbidden + case 404: + return ErrorMessage.apiEndpointNotFound + case 429: + return ErrorMessage.rateLimited + case 500...599: + return ErrorMessage.apiServerError + default: + return "HTTP error \(statusCode): \(message ?? "Unknown error")" + } + } + + /// Determines if an error should trigger a retry + /// - Parameter error: The error to check + /// - Returns: True if the error is retryable + func shouldRetry(_ error: Error) -> Bool { + // Network errors are generally retryable + if let urlError = error as? URLError { + switch urlError.code { + case .timedOut, .cannotFindHost, .networkConnectionLost, .cannotConnectToHost: + return true + default: + return false + } + } + + // WebSocket close codes + if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int { + return closeCode < 4000 // Only retry for non-API errors + } + + return false + } + + // MARK: - Private Methods + + private func handleNetworkError(_ urlError: URLError) -> String { + switch urlError.code { + case .notConnectedToInternet: + return "No internet connection. Please check your network and try again." + case .timedOut: + return "Request timed out. Please try again." + case .cannotFindHost: + return "Cannot reach OpenAI servers. Please check your internet connection." + case .cannotConnectToHost: + return "Cannot connect to OpenAI servers. Please check your internet connection." + case .networkConnectionLost: + return "Network connection lost. Please try again." + case .httpTooManyRedirects: + return "Too many redirects. Please try again later." + case .secureConnectionFailed: + return "Secure connection failed. Please check your internet connection." + case .serverCertificateUntrusted: + return "Server certificate untrusted. Please try again." + default: + return "Network error: \(urlError.localizedDescription)" + } + } + + private func handleHTTPError(_ httpError: HTTPError) -> String { + return handleHTTPStatusCode(httpError.statusCode, message: httpError.message) + } + + private func categorizeOpenAIError(_ errorDescription: String) -> String? { + if errorDescription.contains("unauthorized") || errorDescription.contains("401") { + return ErrorMessage.invalidAPIKey + } else if errorDescription.contains("insufficient") || errorDescription.contains("402") { + return ErrorMessage.insufficientFunds + } else if errorDescription.contains("rate limit") || errorDescription.contains("429") { + return ErrorMessage.rateLimited + } else if errorDescription.contains("server error") || errorDescription.contains("500") { + return ErrorMessage.apiServerError + } else if errorDescription.contains("forbidden") || errorDescription.contains("403") { + return ErrorMessage.accessForbidden + } else if errorDescription.contains("not found") || errorDescription.contains("404") { + return ErrorMessage.apiEndpointNotFound + } + + return nil + } +} + +/// HTTP error type +struct HTTPError: Error { + let statusCode: Int + let message: String? + + init(statusCode: Int, message: String? = nil) { + self.statusCode = statusCode + self.message = message + } +} + +/// Common error messages +enum ErrorMessage { + static let noAPIKey = "OpenAI API key not found. Please configure your API key in Settings." + static let noTemplate = "No template content found. Please select a valid template." + static let noTranscript = "No transcript available. Please record some audio first." + static let connectionTimeout = "Failed to connect to OpenAI transcription service. Please check your internet connection and API key." + static let configurationFailed = "Failed to configure transcription session." + static let invalidURL = "Invalid API URL configuration." + static let noModelsAvailable = "No models available with your API key. Please check your account status." + + // Centralized messages used across handlers + static let success = "Success" + static let badRequest = "Bad request. Please check your input." + static let invalidAPIKey = "Invalid OpenAI API key. Please check your API key in Settings." + static let insufficientFunds = "Insufficient funds in your OpenAI account. Please add credits to your account." + static let accessForbidden = "Access forbidden. Please check your API key permissions." + static let apiEndpointNotFound = "API endpoint not found. Please update the app." + static let rateLimited = "OpenAI API rate limit exceeded. Please try again later." + static let apiServerError = "OpenAI server error. Please try again later." + static let requestTimeout = "Request timeout. Please try again." + static let requestTooLarge = "Request too large. Please try again." + static let unsupportedData = "Unsupported data format. Please update the app." + static let connectionLost = "Connection lost. Please try again." +} \ No newline at end of file diff --git a/meetingnotes/Services/NotesGenerator.swift b/meetingnotes/Services/NotesGenerator.swift index 9b40a2a..baddb0a 100644 --- a/meetingnotes/Services/NotesGenerator.swift +++ b/meetingnotes/Services/NotesGenerator.swift @@ -4,6 +4,12 @@ import Foundation import OpenAI +/// Result type for note generation streaming +enum GenerationResult { + case content(String) + case error(String) +} + /// Generates meeting notes using OpenAI API class NotesGenerator { static let shared = NotesGenerator() @@ -20,16 +26,28 @@ class NotesGenerator { func generateNotesStream(meeting: Meeting, userBlurb: String, systemPrompt: String, - templateId: UUID? = nil) -> AsyncStream { + templateId: UUID? = nil) -> AsyncStream { - return AsyncStream(String.self) { continuation in + return AsyncStream(GenerationResult.self) { continuation in Task { do { guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else { + continuation.yield(.error(ErrorMessage.noAPIKey)) continuation.finish() return } + // Validate API key before proceeding + let validationResult = await APIKeyValidator.shared.validateAPIKey(apiKey) + switch validationResult { + case .failure(let error): + continuation.yield(.error(error.localizedDescription)) + continuation.finish() + return + case .success(): + break + } + let openAI = OpenAI(apiToken: apiKey) // Create date formatter for meeting date @@ -48,7 +66,14 @@ class NotesGenerator { // If no template content, use default if templateContent.isEmpty { - // No template content found, finish the stream + continuation.yield(.error(ErrorMessage.noTemplate)) + continuation.finish() + return + } + + // Check if transcript is empty + if meeting.formattedTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + continuation.yield(.error(ErrorMessage.noTranscript)) continuation.finish() return } @@ -75,13 +100,15 @@ class NotesGenerator { for try await result in stream { if let content = result.choices.first?.delta.content { - continuation.yield(content) + continuation.yield(.content(content)) } } continuation.finish() } catch { + let errorMessage = ErrorHandler.shared.handleError(error) print("Error in streaming generation: \(error)") + continuation.yield(.error(errorMessage)) continuation.finish() } } diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index cb7258a..ffbe2a8 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -98,6 +98,15 @@ class MeetingViewModel: ObservableObject { } .store(in: &cancellables) + // Update error message when audio manager encounters errors + audioManager.$errorMessage + .compactMap { $0 } + .sink { [weak self] errorMessage in + self?.errorMessage = errorMessage + print("🚨 Audio Manager Error: \(errorMessage)") + } + .store(in: &cancellables) + // Auto-save when meeting properties change $meeting .debounce(for: .milliseconds(500), scheduler: RunLoop.main) @@ -143,7 +152,20 @@ class MeetingViewModel: ObservableObject { } func startRecording() { - audioManager.startRecording() + // Validate API key before starting recording + Task { + let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey() + + switch validationResult { + case .success(): + // Key is valid, proceed with recording + audioManager.startRecording() + case .failure(let error): + // Show error message + errorMessage = error.localizedDescription + print("❌ API key validation failed: \(error.localizedDescription)") + } + } } func stopRecording() { @@ -176,6 +198,9 @@ class MeetingViewModel: ObservableObject { isGeneratingNotes = true errorMessage = nil + // Clear any audio manager errors as well + audioManager.errorMessage = nil + // Clear existing notes for streaming meeting.generatedNotes = "" @@ -191,11 +216,23 @@ class MeetingViewModel: ObservableObject { templateId: selectedTemplateId ) - for await chunk in stream { - meeting.generatedNotes += chunk + var hasError = false + for await result in stream { + switch result { + case .content(let chunk): + meeting.generatedNotes += chunk + case .error(let error): + errorMessage = error + hasError = true + print("🚨 Note Generation Error: \(error)") + break + } } - saveMeeting() + // Only save if there was no error + if !hasError { + saveMeeting() + } isGeneratingNotes = false }