feat: API key validation and websocket error handling (#27)
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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<String> {
|
||||
templateId: UUID? = nil) -> AsyncStream<GenerationResult> {
|
||||
|
||||
return AsyncStream<String>(String.self) { continuation in
|
||||
return AsyncStream<GenerationResult>(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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user