Files
openclaw/apps/ios/WatchApp/Sources/WatchVoiceTurnTracker.swift
2026-07-21 09:00:21 +02:00

163 lines
4.8 KiB
Swift

import Foundation
struct WatchVoiceTurnTracker: Codable, Equatable {
private(set) var commandId: String?
private(set) var isAwaitingReply = false
mutating func begin(commandId: String) {
self.commandId = commandId
self.isAwaitingReply = true
}
mutating func takeReply(completedCommandId: String?, text: String?) -> String? {
guard self.isAwaitingReply,
let completedCommandId,
completedCommandId == commandId,
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
self.commandId = nil
self.isAwaitingReply = false
return text
}
mutating func cancel() {
self.commandId = nil
self.isAwaitingReply = false
}
}
struct WatchVoiceTurnState: Codable, Equatable {
static let timeoutMs: Int64 = 90000
private(set) var tracker = WatchVoiceTurnTracker()
private(set) var completion: WatchChatCompletionMessage?
private(set) var startedAtMs: Int64?
var isAwaitingReply: Bool {
self.tracker.isAwaitingReply
}
mutating func begin(commandId: String, nowMs: Int64) {
self.tracker.begin(commandId: commandId)
self.completion = nil
self.startedAtMs = nowMs
}
mutating func receive(_ message: WatchChatCompletionMessage, nowMs: Int64) {
self.expireIfNeeded(nowMs: nowMs)
guard self.tracker.isAwaitingReply,
message.commandId == self.tracker.commandId
else {
return
}
if let current = completion {
switch (current.sentAtMs, message.sentAtMs) {
case let (currentSentAtMs?, incomingSentAtMs?) where incomingSentAtMs < currentSentAtMs:
return
case (_?, nil):
return
default:
break
}
}
self.completion = message
}
mutating func takeReply(nowMs: Int64) -> String? {
self.expireIfNeeded(nowMs: nowMs)
guard let completion,
let reply = tracker.takeReply(
completedCommandId: completion.commandId,
text: completion.replyText)
else {
return nil
}
self.completion = nil
self.startedAtMs = nil
return reply
}
mutating func cancel() {
self.tracker.cancel()
self.completion = nil
self.startedAtMs = nil
}
mutating func expireIfNeeded(nowMs: Int64) {
guard self.tracker.isAwaitingReply,
let startedAtMs,
nowMs >= startedAtMs,
nowMs - startedAtMs >= Self.timeoutMs
else {
return
}
self.cancel()
}
func remainingTimeoutMs(nowMs: Int64) -> Int64? {
guard self.tracker.isAwaitingReply, let startedAtMs else { return nil }
let elapsedMs = max(0, nowMs - startedAtMs)
return max(0, Self.timeoutMs - elapsedMs)
}
static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
}
#if os(watchOS)
extension WatchInboxStore {
var chatCompletion: WatchChatCompletionMessage? {
self.voiceTurnState.completion
}
var isAwaitingVoiceReply: Bool {
self.voiceTurnState.isAwaitingReply
}
func consume(chatCompletion message: WatchChatCompletionMessage) {
let previousState = self.voiceTurnState
self.voiceTurnState.receive(message, nowMs: WatchVoiceTurnState.nowMs())
guard self.voiceTurnState != previousState else { return }
self.persistVoiceTurnState()
}
func beginVoiceTurn(commandId: String) {
self.voiceTurnState.begin(commandId: commandId, nowMs: WatchVoiceTurnState.nowMs())
self.persistVoiceTurnState()
}
func takeVoiceReply() -> String? {
let previousState = self.voiceTurnState
let reply = self.voiceTurnState.takeReply(nowMs: WatchVoiceTurnState.nowMs())
if self.voiceTurnState != previousState {
self.persistVoiceTurnState()
}
return reply
}
func cancelVoiceTurn() {
guard self.voiceTurnState.isAwaitingReply || self.voiceTurnState.completion != nil else { return }
self.voiceTurnState.cancel()
self.persistVoiceTurnState()
}
func voiceReplyTimeoutNanoseconds() -> UInt64? {
let previousState = self.voiceTurnState
let nowMs = WatchVoiceTurnState.nowMs()
self.voiceTurnState.expireIfNeeded(nowMs: nowMs)
if self.voiceTurnState != previousState {
self.persistVoiceTurnState()
}
guard let remainingMs = self.voiceTurnState.remainingTimeoutMs(nowMs: nowMs) else {
return nil
}
return UInt64(remainingMs) * 1_000_000
}
}
#endif