diff --git a/Examples/SwiftUICallView.swift b/Examples/SwiftUICallView.swift index baf353b..54712a1 100644 --- a/Examples/SwiftUICallView.swift +++ b/Examples/SwiftUICallView.swift @@ -27,28 +27,10 @@ class CallManager: ObservableObject { self?.callState = .started case .callDidEnd: self?.callState = .ended - case .speechUpdate: - print(event) - case .conversationUpdate: - print(event) - case .functionCall: - print(event) - case .hang: - print(event) - case .metadata: - print(event) - case .transcript: - print(event) - case .statusUpdate: - print(event) - case .modelOutput: - print(event) - case .userInterrupted: - print(event) - case .voiceInput: - print(event) case .error(let error): print("Error: \(error)") + default: + print(event) } } .store(in: &cancellables) diff --git a/Sources/Models/AnyCodable.swift b/Sources/Models/AnyCodable.swift new file mode 100644 index 0000000..9be33f7 --- /dev/null +++ b/Sources/Models/AnyCodable.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, debugDescription: "Unsupported value")) + } + } +} diff --git a/Sources/Models/AppMessage.swift b/Sources/Models/AppMessage.swift index 0f9c545..a57eb9a 100644 --- a/Sources/Models/AppMessage.swift +++ b/Sources/Models/AppMessage.swift @@ -8,17 +8,76 @@ import Foundation struct AppMessage: Codable { - enum MessageType: String, Codable { + enum MessageType: Codable, Equatable { case hang - case functionCall = "function-call" + case functionCall case transcript - case speechUpdate = "speech-update" + case speechUpdate case metadata - case conversationUpdate = "conversation-update" - case modelOutput = "model-output" - case statusUpdate = "status-update" - case voiceInput = "voice-input" - case userInterrupted = "user-interrupted" + case conversationUpdate + case modelOutput + case statusUpdate + case voiceInput + case userInterrupted + case assistantStarted + case workflowNodeStarted + case toolCalls + case toolCallsResult + case transferUpdate + case languageChangeDetected + case chatCreated + case chatDeleted + case sessionCreated + case sessionUpdated + case sessionDeleted + case callDeleted + case callDeleteFailed + case unknown(String) + + private static let rawValueMapping: [(String, MessageType)] = [ + ("hang", .hang), + ("function-call", .functionCall), + ("transcript", .transcript), + ("transcript[transcriptType=\"final\"]", .transcript), + ("speech-update", .speechUpdate), + ("metadata", .metadata), + ("conversation-update", .conversationUpdate), + ("model-output", .modelOutput), + ("status-update", .statusUpdate), + ("voice-input", .voiceInput), + ("user-interrupted", .userInterrupted), + ("assistant.started", .assistantStarted), + ("workflow.node.started", .workflowNodeStarted), + ("tool-calls", .toolCalls), + ("tool-calls-result", .toolCallsResult), + ("transfer-update", .transferUpdate), + ("language-change-detected", .languageChangeDetected), + ("chat.created", .chatCreated), + ("chat.deleted", .chatDeleted), + ("session.created", .sessionCreated), + ("session.updated", .sessionUpdated), + ("session.deleted", .sessionDeleted), + ("call.deleted", .callDeleted), + ("call.delete.failed", .callDeleteFailed), + ] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self.rawValueMapping.first(where: { $0.0 == rawValue })?.1 ?? .unknown(rawValue) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .unknown(let rawValue): + try container.encode(rawValue) + default: + if let pair = Self.rawValueMapping.first(where: { $0.1 == self }) { + try container.encode(pair.0) + } + } + } } let type: MessageType diff --git a/Sources/Models/LanguageChangeDetected.swift b/Sources/Models/LanguageChangeDetected.swift new file mode 100644 index 0000000..bbf2c9d --- /dev/null +++ b/Sources/Models/LanguageChangeDetected.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct LanguageChangeDetected: Codable { + public let language: String +} diff --git a/Sources/Models/ToolCall.swift b/Sources/Models/ToolCall.swift new file mode 100644 index 0000000..04ecb0c --- /dev/null +++ b/Sources/Models/ToolCall.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct ToolCallFunction: Codable { + public let name: String + public let arguments: String +} + +public struct ToolCall: Codable { + public let id: String + public let type: String + public let function: ToolCallFunction +} + +public struct ToolCallList: Codable { + public let toolCallList: [ToolCall] +} diff --git a/Sources/Models/ToolCallResult.swift b/Sources/Models/ToolCallResult.swift new file mode 100644 index 0000000..7bacd22 --- /dev/null +++ b/Sources/Models/ToolCallResult.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct ToolCallResult: Codable { + public let toolCallResult: [String: AnyCodable] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + toolCallResult = (try? container.decode([String: AnyCodable].self, forKey: .toolCallResult)) ?? [:] + } + + private enum CodingKeys: String, CodingKey { + case toolCallResult + } +} diff --git a/Sources/Models/TransferUpdate.swift b/Sources/Models/TransferUpdate.swift new file mode 100644 index 0000000..a95fc23 --- /dev/null +++ b/Sources/Models/TransferUpdate.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct TransferDestination: Codable { + public let type: String? + public let message: String? + public let description: String? +} + +public struct TransferUpdate: Codable { + public let destination: TransferDestination? + public let toAssistant: [String: AnyCodable]? + public let fromAssistant: [String: AnyCodable]? + public let toStepRecord: [String: AnyCodable]? + public let fromStepRecord: [String: AnyCodable]? +} diff --git a/Sources/Models/WorkflowNodeStarted.swift b/Sources/Models/WorkflowNodeStarted.swift new file mode 100644 index 0000000..e05d7dc --- /dev/null +++ b/Sources/Models/WorkflowNodeStarted.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct WorkflowNodeStarted: Codable { + public let node: [String: AnyCodable] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + node = (try? container.decode([String: AnyCodable].self, forKey: .node)) ?? [:] + } + + private enum CodingKeys: String, CodingKey { + case node + } +} diff --git a/Sources/Vapi.swift b/Sources/Vapi.swift index 69bdaa3..0da0085 100644 --- a/Sources/Vapi.swift +++ b/Sources/Vapi.swift @@ -51,6 +51,19 @@ public final class Vapi: CallClientDelegate { case voiceInput(VoiceInput) case hang case error(Swift.Error) + case assistantStarted([String: Any]) + case workflowNodeStarted(WorkflowNodeStarted) + case toolCalls(ToolCallList) + case toolCallsResult(ToolCallResult) + case transferUpdate(TransferUpdate) + case languageChangeDetected(LanguageChangeDetected) + case chatCreated([String: Any]) + case chatDeleted([String: Any]) + case sessionCreated([String: Any]) + case sessionUpdated([String: Any]) + case sessionDeleted([String: Any]) + case callDeleted + case callDeleteFailed } // MARK: - Properties @@ -439,10 +452,9 @@ public final class Vapi: CallClientDelegate { return } - // Parse the JSON data generically to determine the type of event let decoder = JSONDecoder() let appMessage = try decoder.decode(AppMessage.self, from: unescapedData) - // Parse the JSON data again, this time using the specific type + let event: Event switch appMessage.type { case .functionCall: @@ -462,7 +474,6 @@ public final class Vapi: CallClientDelegate { throw VapiError.decodingError(message: "App message missing parameters") } - let functionCall = FunctionCall(name: name, parameters: parameters) event = Event.functionCall(functionCall) case .hang: @@ -491,6 +502,52 @@ public final class Vapi: CallClientDelegate { case .voiceInput: let voiceInput = try decoder.decode(VoiceInput.self, from: unescapedData) event = Event.voiceInput(voiceInput) + case .assistantStarted: + guard let messageDictionary = try JSONSerialization.jsonObject(with: unescapedData, options: []) as? [String: Any] else { + throw VapiError.decodingError(message: "App message isn't a valid JSON object") + } + event = Event.assistantStarted(messageDictionary) + case .workflowNodeStarted: + let workflowNode = try decoder.decode(WorkflowNodeStarted.self, from: unescapedData) + event = Event.workflowNodeStarted(workflowNode) + case .toolCalls: + let toolCallList = try decoder.decode(ToolCallList.self, from: unescapedData) + event = Event.toolCalls(toolCallList) + case .toolCallsResult: + let toolCallResult = try decoder.decode(ToolCallResult.self, from: unescapedData) + event = Event.toolCallsResult(toolCallResult) + case .transferUpdate: + let transferUpdate = try decoder.decode(TransferUpdate.self, from: unescapedData) + event = Event.transferUpdate(transferUpdate) + case .languageChangeDetected: + let langChange = try decoder.decode(LanguageChangeDetected.self, from: unescapedData) + event = Event.languageChangeDetected(langChange) + case .chatCreated, .chatDeleted: + guard let messageDictionary = try JSONSerialization.jsonObject(with: unescapedData, options: []) as? [String: Any] else { + throw VapiError.decodingError(message: "App message isn't a valid JSON object") + } + event = appMessage.type == .chatCreated + ? Event.chatCreated(messageDictionary) + : Event.chatDeleted(messageDictionary) + case .sessionCreated, .sessionUpdated, .sessionDeleted: + guard let messageDictionary = try JSONSerialization.jsonObject(with: unescapedData, options: []) as? [String: Any] else { + throw VapiError.decodingError(message: "App message isn't a valid JSON object") + } + switch appMessage.type { + case .sessionCreated: + event = Event.sessionCreated(messageDictionary) + case .sessionUpdated: + event = Event.sessionUpdated(messageDictionary) + default: + event = Event.sessionDeleted(messageDictionary) + } + case .callDeleted: + event = Event.callDeleted + case .callDeleteFailed: + event = Event.callDeleteFailed + case .unknown(let rawType): + print("Received unhandled message type: \(rawType)") + return } eventSubject.send(event) } catch {