Files
openclaw/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift
Vincent Koc 000db97c82 feat(talk): add macOS realtime Gateway relay (#128204)
* feat(talk): add macOS realtime relay transport

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>

* feat(talk): integrate realtime relay with macOS Talk

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>

* fix(talk): keep realtime bootstrap config redacted

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>

* fix(talk): surface failed native fallback

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>

* test(talk): inject deterministic relay capture

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>

---------

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>
2026-08-23 07:30:30 -07:00

203 lines
8.5 KiB
Swift

import Foundation
import OpenClawKit
struct TalkModeGatewayConfigState {
let activeProvider: String
let normalizedPayload: Bool
let missingResolvedPayload: Bool
let voiceId: String?
let voiceAliases: [String: String]
let modelId: String?
let outputFormat: String?
let interruptOnSpeech: Bool
let silenceTimeoutMs: Int
let speechLocaleID: String?
let apiKey: String?
let referenceAudioPath: String?
let referenceText: String?
let seamColorHex: String?
let realtimeProvider: String?
let realtimeModelId: String?
let realtimeSpeakerVoice: String?
let realtimeMode: String?
let realtimeTransport: String?
let realtimeBrain: String?
var hasGatewayRealtimeRelayTuple: Bool {
self.realtimeMode == "realtime" &&
self.realtimeTransport == "gateway-relay" &&
self.realtimeBrain == "agent-consult"
}
}
enum TalkModeGatewayConfigParser {
static func parse(
snapshot: ConfigSnapshot,
defaultProvider: String,
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int,
envVoice: String?,
sagVoice: String?,
envApiKey: String?) -> TalkModeGatewayConfigState
{
let talk = snapshot.config?["talk"]?.dictionaryValue
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
let activeProvider = selection?.provider ?? defaultProvider
let activeConfig = selection?.config
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
let ui = snapshot.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let voice = activeConfig?["voiceId"]?.stringValue
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
let resolvedAliases: [String: String] =
rawAliases?.reduce(into: [:]) { acc, entry in
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !key.isEmpty, !value.isEmpty else { return }
acc[key] = value
} ?? [:]
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedModel: String? = if model?.isEmpty == false {
model!
} else if activeProvider == defaultProvider {
defaultModelIdFallback
} else {
nil
}
let outputFormat = activeConfig?["outputFormat"]?.stringValue
let interrupt = talk?["interruptOnSpeech"]?.boolValue
let speechLocaleID = TalkConfigParsing.resolvedSpeechLocaleID(talk)
let apiKey = activeConfig?["apiKey"]?.stringValue
let referenceAudioPath = activeConfig?["referenceAudioPath"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let referenceText = activeConfig?["referenceText"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let realtime = talk?["realtime"]?.dictionaryValue
let realtimeProviders = realtime?["providers"]?.dictionaryValue
let realtimeProvider = Self.firstString(realtime, keys: ["provider"])
?? Self.singleRealtimeProviderId(realtimeProviders)
let realtimeProviderConfig = Self.realtimeProviderConfig(
providers: realtimeProviders,
provider: realtimeProvider)
let realtimeModelId = Self.firstString(realtime, keys: ["model"])
?? Self.firstString(realtimeProviderConfig, keys: ["model"])
let realtimeSpeakerVoice = Self.firstString(
realtime,
keys: ["speakerVoice", "voice"])
?? Self.firstString(realtimeProviderConfig, keys: ["speakerVoice", "voice"])
let realtimeMode = Self.firstString(realtime, keys: ["mode"])?.lowercased()
let realtimeTransport = Self.firstString(realtime, keys: ["transport"])?.lowercased()
let realtimeBrain = Self.firstString(realtime, keys: ["brain"])?.lowercased()
let resolvedVoice: String? = if activeProvider == defaultProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
} else {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
}
let resolvedApiKey: String? = if activeProvider == defaultProvider {
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
} else {
nil
}
return TalkModeGatewayConfigState(
activeProvider: activeProvider,
normalizedPayload: selection?.normalizedPayload == true,
missingResolvedPayload: talk != nil && selection == nil,
voiceId: resolvedVoice,
voiceAliases: resolvedAliases,
modelId: resolvedModel,
outputFormat: outputFormat,
interruptOnSpeech: interrupt ?? true,
silenceTimeoutMs: silenceTimeoutMs,
speechLocaleID: speechLocaleID,
apiKey: resolvedApiKey,
referenceAudioPath: referenceAudioPath?.isEmpty == false ? referenceAudioPath : nil,
referenceText: referenceText?.isEmpty == false ? referenceText : nil,
seamColorHex: rawSeam.isEmpty ? nil : rawSeam,
realtimeProvider: realtimeProvider,
realtimeModelId: realtimeModelId,
realtimeSpeakerVoice: realtimeSpeakerVoice,
realtimeMode: realtimeMode,
realtimeTransport: realtimeTransport,
realtimeBrain: realtimeBrain)
}
static func fallback(
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int,
envVoice: String?,
sagVoice: String?,
envApiKey: String?) -> TalkModeGatewayConfigState
{
let resolvedVoice =
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
return TalkModeGatewayConfigState(
activeProvider: "elevenlabs",
normalizedPayload: false,
missingResolvedPayload: false,
voiceId: resolvedVoice,
voiceAliases: [:],
modelId: defaultModelIdFallback,
outputFormat: nil,
interruptOnSpeech: true,
silenceTimeoutMs: defaultSilenceTimeoutMs,
speechLocaleID: nil,
apiKey: resolvedApiKey,
referenceAudioPath: nil,
referenceText: nil,
seamColorHex: nil,
realtimeProvider: nil,
realtimeModelId: nil,
realtimeSpeakerVoice: nil,
realtimeMode: nil,
realtimeTransport: nil,
realtimeBrain: nil)
}
private static func firstString(
_ config: [String: AnyCodable]?,
keys: [String]) -> String?
{
guard let config else { return nil }
for key in keys {
let value = config[key]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
}
return nil
}
private static func singleRealtimeProviderId(_ providers: [String: AnyCodable]?) -> String? {
guard let providers, providers.count == 1 else { return nil }
let provider = providers.keys.first?.trimmingCharacters(in: .whitespacesAndNewlines)
return provider?.isEmpty == false ? provider : nil
}
private static func realtimeProviderConfig(
providers: [String: AnyCodable]?,
provider: String?) -> [String: AnyCodable]?
{
guard let providers else { return nil }
if let provider {
if let exact = providers[provider]?.dictionaryValue {
return exact
}
return providers.first { key, _ in
key.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(provider) == .orderedSame
}?.value.dictionaryValue
}
if providers.count == 1 {
return providers.values.first?.dictionaryValue
}
return nil
}
}