feat: API key validation and websocket error handling (#27)
This commit is contained in:
@@ -10,11 +10,15 @@ import ScreenCaptureKit
|
|||||||
class AudioManager: NSObject, ObservableObject {
|
class AudioManager: NSObject, ObservableObject {
|
||||||
@Published var transcriptChunks: [TranscriptChunk] = []
|
@Published var transcriptChunks: [TranscriptChunk] = []
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
private var audioEngine = AVAudioEngine()
|
private var audioEngine = AVAudioEngine()
|
||||||
private var micSocketTask: URLSessionWebSocketTask?
|
private var micSocketTask: URLSessionWebSocketTask?
|
||||||
private var systemSocketTask: URLSessionWebSocketTask?
|
private var systemSocketTask: URLSessionWebSocketTask?
|
||||||
private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
|
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
|
// ScreenCaptureKit properties
|
||||||
private var stream: SCStream?
|
private var stream: SCStream?
|
||||||
@@ -42,6 +46,14 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
func startRecording() {
|
func startRecording() {
|
||||||
print("Starting recording...")
|
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
|
// First ensure everything is stopped and cleaned up
|
||||||
stopRecordingInternal()
|
stopRecordingInternal()
|
||||||
|
|
||||||
@@ -76,9 +88,7 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
systemSocketTask = nil
|
systemSocketTask = nil
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
DispatchQueue.main.async {
|
// (isRecording already cleared in stopRecording)
|
||||||
self.isRecording = false
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Internal cleanup completed")
|
print("Internal cleanup completed")
|
||||||
}
|
}
|
||||||
@@ -238,6 +248,8 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
|
// Immediately mark as not recording to prevent stale callbacks
|
||||||
|
self.isRecording = false
|
||||||
print("Stopping recording...")
|
print("Stopping recording...")
|
||||||
|
|
||||||
// Stop system audio capture
|
// Stop system audio capture
|
||||||
@@ -256,10 +268,6 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
systemSocketTask?.cancel(with: .normalClosure, reason: nil)
|
systemSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||||
systemSocketTask = nil
|
systemSocketTask = nil
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isRecording = false
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Recording stopped")
|
print("Recording stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +303,11 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private func connectToOpenAIRealtime(source: AudioSource) {
|
private func connectToOpenAIRealtime(source: AudioSource) {
|
||||||
guard let key = KeychainHelper.shared.getAPIKey(), !key.isEmpty else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +317,21 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
|
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
|
||||||
|
|
||||||
let task = session.webSocketTask(with: request)
|
let task = session.webSocketTask(with: request)
|
||||||
|
|
||||||
|
// Add connection monitoring
|
||||||
task.resume()
|
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
|
// Send initial configuration
|
||||||
let config: [String: Any] = [
|
let config: [String: Any] = [
|
||||||
@@ -328,14 +354,29 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let jsonData = try JSONSerialization.data(withJSONObject: config)
|
let jsonData = try JSONSerialization.data(withJSONObject: config)
|
||||||
if let jsonStr = String(data: jsonData, encoding: .utf8) {
|
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 {
|
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 {
|
} catch {
|
||||||
print("❌ Config JSON error: \(error)")
|
let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))"
|
||||||
|
print("❌ \(errorMsg)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.errorMessage = errorMsg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch source {
|
switch source {
|
||||||
@@ -345,11 +386,11 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
systemSocketTask = task
|
systemSocketTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
receiveMessage(for: source)
|
receiveMessage(for: source, sessionID: thisSession)
|
||||||
print("🌐 Connected to OpenAI Realtime (\(source))")
|
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
|
let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask
|
||||||
task?.receive { [weak self] result in
|
task?.receive { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
@@ -362,18 +403,44 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
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):
|
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)")
|
print("❌ Receive error (\(source)): \(error)")
|
||||||
// Attempt reconnect if still recording
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
DispatchQueue.main.async {
|
||||||
if self?.isRecording == true {
|
self.errorMessage = errorMsg
|
||||||
self?.connectToOpenAIRealtime(source: source)
|
}
|
||||||
|
|
||||||
|
// 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) {
|
private func parseRealtimeEvent(_ text: String, source: AudioSource) {
|
||||||
guard let data = text.data(using: .utf8),
|
guard let data = text.data(using: .utf8),
|
||||||
@@ -433,12 +500,19 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
let base64 = data.base64EncodedString()
|
let base64 = data.base64EncodedString()
|
||||||
let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
|
let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
|
||||||
|
|
||||||
|
let thisSession = self.sessionID
|
||||||
do {
|
do {
|
||||||
let jsonData = try JSONSerialization.data(withJSONObject: message)
|
let jsonData = try JSONSerialization.data(withJSONObject: message)
|
||||||
if let jsonStr = String(data: jsonData, encoding: .utf8) {
|
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 {
|
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)")
|
print("❌ Send error (\(source)): \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Void, APIKeyValidationError> {
|
||||||
|
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<Void, APIKeyValidationError> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OpenAI
|
import OpenAI
|
||||||
|
|
||||||
|
/// Result type for note generation streaming
|
||||||
|
enum GenerationResult {
|
||||||
|
case content(String)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates meeting notes using OpenAI API
|
/// Generates meeting notes using OpenAI API
|
||||||
class NotesGenerator {
|
class NotesGenerator {
|
||||||
static let shared = NotesGenerator()
|
static let shared = NotesGenerator()
|
||||||
@@ -20,16 +26,28 @@ class NotesGenerator {
|
|||||||
func generateNotesStream(meeting: Meeting,
|
func generateNotesStream(meeting: Meeting,
|
||||||
userBlurb: String,
|
userBlurb: String,
|
||||||
systemPrompt: String,
|
systemPrompt: String,
|
||||||
templateId: UUID? = nil) -> AsyncStream<String> {
|
templateId: UUID? = nil) -> AsyncStream<GenerationResult> {
|
||||||
|
|
||||||
return AsyncStream<String>(String.self) { continuation in
|
return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else {
|
guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else {
|
||||||
|
continuation.yield(.error(ErrorMessage.noAPIKey))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
return
|
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)
|
let openAI = OpenAI(apiToken: apiKey)
|
||||||
|
|
||||||
// Create date formatter for meeting date
|
// Create date formatter for meeting date
|
||||||
@@ -48,7 +66,14 @@ class NotesGenerator {
|
|||||||
|
|
||||||
// If no template content, use default
|
// If no template content, use default
|
||||||
if templateContent.isEmpty {
|
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()
|
continuation.finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,13 +100,15 @@ class NotesGenerator {
|
|||||||
|
|
||||||
for try await result in stream {
|
for try await result in stream {
|
||||||
if let content = result.choices.first?.delta.content {
|
if let content = result.choices.first?.delta.content {
|
||||||
continuation.yield(content)
|
continuation.yield(.content(content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
} catch {
|
} catch {
|
||||||
|
let errorMessage = ErrorHandler.shared.handleError(error)
|
||||||
print("Error in streaming generation: \(error)")
|
print("Error in streaming generation: \(error)")
|
||||||
|
continuation.yield(.error(errorMessage))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ class MeetingViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
// Auto-save when meeting properties change
|
||||||
$meeting
|
$meeting
|
||||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
@@ -143,7 +152,20 @@ class MeetingViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startRecording() {
|
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() {
|
func stopRecording() {
|
||||||
@@ -176,6 +198,9 @@ class MeetingViewModel: ObservableObject {
|
|||||||
isGeneratingNotes = true
|
isGeneratingNotes = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
|
// Clear any audio manager errors as well
|
||||||
|
audioManager.errorMessage = nil
|
||||||
|
|
||||||
// Clear existing notes for streaming
|
// Clear existing notes for streaming
|
||||||
meeting.generatedNotes = ""
|
meeting.generatedNotes = ""
|
||||||
|
|
||||||
@@ -191,11 +216,23 @@ class MeetingViewModel: ObservableObject {
|
|||||||
templateId: selectedTemplateId
|
templateId: selectedTemplateId
|
||||||
)
|
)
|
||||||
|
|
||||||
for await chunk in stream {
|
var hasError = false
|
||||||
meeting.generatedNotes += chunk
|
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
|
isGeneratingNotes = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user