RPC: stream heartbeat events to menu
This commit is contained in:
@@ -4,6 +4,18 @@ import OSLog
|
||||
actor AgentRPC {
|
||||
static let shared = AgentRPC()
|
||||
|
||||
struct HeartbeatEvent: Codable {
|
||||
let ts: Double
|
||||
let status: String
|
||||
let to: String?
|
||||
let preview: String?
|
||||
let durationMs: Double?
|
||||
let hasMedia: Bool?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var stdoutHandle: FileHandle?
|
||||
@@ -175,6 +187,15 @@ actor AgentRPC {
|
||||
let lineData = self.buffer.subdata(in: self.buffer.startIndex..<range.lowerBound)
|
||||
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
|
||||
// Handle event envelopes (unsolicited)
|
||||
if let event = self.parseHeartbeatEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.heartbeatNotification, object: event)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let waiter = waiters.first {
|
||||
self.waiters.removeFirst()
|
||||
waiter.resume(returning: line)
|
||||
@@ -182,6 +203,24 @@ actor AgentRPC {
|
||||
}
|
||||
}
|
||||
|
||||
private func parseHeartbeatEvent(from line: String) -> HeartbeatEvent? {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String,
|
||||
type == "event",
|
||||
let evt = obj["event"] as? String,
|
||||
evt == "heartbeat",
|
||||
let payload = obj["payload"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
|
||||
}
|
||||
|
||||
private func nextLine() async throws -> String {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||
self.waiters.append(cont)
|
||||
|
||||
@@ -56,6 +56,7 @@ private struct MenuContent: View {
|
||||
let updater: UpdaterProviding?
|
||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@@ -68,6 +69,7 @@ private struct MenuContent: View {
|
||||
}
|
||||
self.statusRow
|
||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||
self.heartbeatStatusRow
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
@@ -169,6 +171,45 @@ private struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
label = "Last heartbeat sent · \(ageText)"
|
||||
color = .blue
|
||||
case "ok-empty", "ok-token":
|
||||
label = "Heartbeat ok · \(ageText)"
|
||||
color = .green
|
||||
case "skipped":
|
||||
label = "Heartbeat skipped · \(ageText)"
|
||||
color = .secondary
|
||||
case "failed":
|
||||
label = "Heartbeat failed · \(ageText)"
|
||||
color = .red
|
||||
default:
|
||||
label = "Heartbeat · \(ageText)"
|
||||
color = .secondary
|
||||
}
|
||||
} else {
|
||||
label = "No heartbeat yet"
|
||||
color = .secondary
|
||||
}
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user