import Foundation import LiveKit /// Outbound helpers for agent control topics and inbound parsing of /// voice-control / conversation-control data messages. @MainActor final class ControlChannel: NSObject, RoomDelegate { struct ModelInfo: Equatable { let id: String let name: String? } enum InboundEvent { case agentReady case models([ModelInfo], selected: String?) case modelChanged(String) case taskEvent(TaskEventPayload) } struct TaskEventPayload: Equatable { let type: String let taskId: String? let description: String? let role: String? let content: String? let result: String? let error: String? let createdAt: Double? } var onEvent: ((InboundEvent) -> Void)? private let decoder = JSONDecoder() /// Inbound voice-control handling. Returns true when the message was consumed. func handleVoiceControl(_ data: Data, session: VoiceSession) -> Bool { guard let json = try? decoder.decode([String: JSONValue].self, from: data), let type = json["type"]?.stringValue else { return false } switch type { case "agent_ready": Task { @MainActor [weak session] in session?.agentReadyReceived() } return true case "models": var models: [ModelInfo] = [] for item in json["models"]?.arrayValue ?? [] { if let obj = item.objectValue, let id = obj["id"]?.stringValue { models.append(ModelInfo(id: id, name: obj["name"]?.stringValue)) } else if let id = item.stringValue { models.append(ModelInfo(id: id, name: nil)) } } onEvent?(.models(models, selected: json["selected"]?.stringValue)) return true case "model_changed": if let model = json["model"]?.stringValue { onEvent?(.modelChanged(model)) } return true default: return false } } /// Inbound task_event handling (topic-agnostic per web UI). The agent publishes /// {type:"task_event", task_id, event, ...} — the discriminator is `event`, not `type`. func handleTaskEvent(_ data: Data) -> Bool { guard let json = try? decoder.decode([String: JSONValue].self, from: data), json["type"]?.stringValue == "task_event", let type = json["event"]?.stringValue, ["created", "step", "completed", "failed"].contains(type) else { return false } let taskObj = json["task"]?.objectValue ?? [:] let payload = TaskEventPayload( type: type, taskId: json["task_id"]?.stringValue, description: json["description"]?.stringValue ?? taskObj["description"]?.stringValue, role: json["role"]?.stringValue, content: json["content"]?.stringValue, result: json["result"]?.stringValue, error: json["error"]?.stringValue, createdAt: json["created_at"]?.doubleValue ?? taskObj["created_at"]?.doubleValue ) onEvent?(.taskEvent(payload)) return true } // MARK: Outbound (topic'd JSON over reliable data streams) private func publish(_ object: [String: Any], topic: String, room: Room) async throws { let data = try JSONSerialization.data(withJSONObject: object) try await room.localParticipant.publish(data: data, options: DataPublishOptions(topic: topic, reliable: true)) } func setVoice(_ voiceID: String, room: Room) async throws { try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room) } func setModel(_ modelID: String, room: Room) async throws { try await publish(["type": "set_model", "model": modelID], topic: "voice-control", room: room) } func getModels(room: Room) async throws { try await publish(["type": "get_models"], topic: "voice-control", room: room) } func setMode(_ mode: String, room: Room) async throws { try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room) } } // MARK: - Minimal JSON value decoder for flexible agent payloads indirect enum JSONValue: Decodable { case string(String) case number(Double) case bool(Bool) case object([String: JSONValue]) case array([JSONValue]) case null init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null } else if let value = try? container.decode(Bool.self) { self = .bool(value) } else if let value = try? container.decode(Double.self) { self = .number(value) } else if let value = try? container.decode(String.self) { self = .string(value) } else if let value = try? container.decode([JSONValue].self) { self = .array(value) } else if let value = try? container.decode([String: JSONValue].self) { self = .object(value) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") } } var stringValue: String? { if case .string(let value) = self { return value } return nil } var doubleValue: Double? { if case .number(let value) = self { return value } return nil } var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value } return nil } var arrayValue: [JSONValue]? { if case .array(let value) = self { return value } return nil } }