mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
000db97c82
* 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>
203 lines
8.5 KiB
Swift
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
|
|
}
|
|
}
|