Skip to content

Commit 16a5f0b

Browse files
committed
refactor: split talk gateway config loaders
1 parent dc5645d commit 16a5f0b

6 files changed

Lines changed: 359 additions & 154 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package ai.openclaw.app.voice
2+
3+
import ai.openclaw.app.normalizeMainKey
4+
import kotlinx.serialization.json.JsonElement
5+
import kotlinx.serialization.json.JsonObject
6+
import kotlinx.serialization.json.JsonPrimitive
7+
import kotlinx.serialization.json.booleanOrNull
8+
import kotlinx.serialization.json.contentOrNull
9+
10+
internal data class TalkModeGatewayConfigState(
11+
val activeProvider: String,
12+
val normalizedPayload: Boolean,
13+
val missingResolvedPayload: Boolean,
14+
val mainSessionKey: String,
15+
val defaultVoiceId: String?,
16+
val voiceAliases: Map<String, String>,
17+
val defaultModelId: String,
18+
val defaultOutputFormat: String,
19+
val apiKey: String?,
20+
val interruptOnSpeech: Boolean?,
21+
val silenceTimeoutMs: Long,
22+
)
23+
24+
internal object TalkModeGatewayConfigParser {
25+
fun parse(
26+
config: JsonObject?,
27+
defaultProvider: String,
28+
defaultModelIdFallback: String,
29+
defaultOutputFormatFallback: String,
30+
envVoice: String?,
31+
sagVoice: String?,
32+
envKey: String?,
33+
): TalkModeGatewayConfigState {
34+
val talk = config?.get("talk").asObjectOrNull()
35+
val selection = TalkModeManager.selectTalkProviderConfig(talk)
36+
val activeProvider = selection?.provider ?: defaultProvider
37+
val activeConfig = selection?.config
38+
val sessionCfg = config?.get("session").asObjectOrNull()
39+
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
40+
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
41+
val aliases =
42+
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
43+
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
44+
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
45+
}?.toMap().orEmpty()
46+
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
47+
val outputFormat =
48+
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
49+
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
50+
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
51+
val silenceTimeoutMs = TalkModeManager.resolvedSilenceTimeoutMs(talk)
52+
53+
return TalkModeGatewayConfigState(
54+
activeProvider = activeProvider,
55+
normalizedPayload = selection?.normalizedPayload == true,
56+
missingResolvedPayload = talk != null && selection == null,
57+
mainSessionKey = mainKey,
58+
defaultVoiceId =
59+
if (activeProvider == defaultProvider) {
60+
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
61+
} else {
62+
voice
63+
},
64+
voiceAliases = aliases,
65+
defaultModelId = model ?: defaultModelIdFallback,
66+
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
67+
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
68+
interruptOnSpeech = interrupt,
69+
silenceTimeoutMs = silenceTimeoutMs,
70+
)
71+
}
72+
73+
fun fallback(
74+
defaultProvider: String,
75+
defaultModelIdFallback: String,
76+
defaultOutputFormatFallback: String,
77+
envVoice: String?,
78+
sagVoice: String?,
79+
envKey: String?,
80+
): TalkModeGatewayConfigState =
81+
TalkModeGatewayConfigState(
82+
activeProvider = defaultProvider,
83+
normalizedPayload = false,
84+
missingResolvedPayload = false,
85+
mainSessionKey = "main",
86+
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
87+
voiceAliases = emptyMap(),
88+
defaultModelId = defaultModelIdFallback,
89+
defaultOutputFormat = defaultOutputFormatFallback,
90+
apiKey = envKey?.takeIf { it.isNotEmpty() },
91+
interruptOnSpeech = null,
92+
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
93+
)
94+
}
95+
96+
private fun normalizeTalkAliasKey(value: String): String =
97+
value.trim().lowercase()
98+
99+
private fun JsonElement?.asStringOrNull(): String? =
100+
this?.let { element ->
101+
element as? JsonPrimitive
102+
}?.contentOrNull
103+
104+
private fun JsonElement?.asBooleanOrNull(): Boolean? {
105+
val primitive = this as? JsonPrimitive ?: return null
106+
return primitive.booleanOrNull
107+
}
108+
109+
private fun JsonElement?.asObjectOrNull(): JsonObject? =
110+
this as? JsonObject

apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,69 +1400,64 @@ class TalkModeManager(
14001400
try {
14011401
val res = session.request("talk.config", """{"includeSecrets":true}""")
14021402
val root = json.parseToJsonElement(res).asObjectOrNull()
1403-
val config = root?.get("config").asObjectOrNull()
1404-
val talk = config?.get("talk").asObjectOrNull()
1405-
val selection = selectTalkProviderConfig(talk)
1406-
if (talk != null && selection == null) {
1403+
val parsed =
1404+
TalkModeGatewayConfigParser.parse(
1405+
config = root?.get("config").asObjectOrNull(),
1406+
defaultProvider = defaultTalkProvider,
1407+
defaultModelIdFallback = defaultModelIdFallback,
1408+
defaultOutputFormatFallback = defaultOutputFormatFallback,
1409+
envVoice = envVoice,
1410+
sagVoice = sagVoice,
1411+
envKey = envKey,
1412+
)
1413+
if (parsed.missingResolvedPayload) {
14071414
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
14081415
}
1409-
val activeProvider = selection?.provider ?: defaultTalkProvider
1410-
val activeConfig = selection?.config
1411-
val sessionCfg = config?.get("session").asObjectOrNull()
1412-
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
1413-
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
1414-
val aliases =
1415-
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
1416-
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
1417-
normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
1418-
}?.toMap().orEmpty()
1419-
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
1420-
val outputFormat =
1421-
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
1422-
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
1423-
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
1424-
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
14251416

14261417
if (!isCanonicalMainSessionKey(mainSessionKey)) {
1427-
mainSessionKey = mainKey
1418+
mainSessionKey = parsed.mainSessionKey
14281419
}
1429-
defaultVoiceId =
1430-
if (activeProvider == defaultTalkProvider) {
1431-
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
1432-
} else {
1433-
voice
1434-
}
1435-
voiceAliases = aliases
1420+
defaultVoiceId = parsed.defaultVoiceId
1421+
voiceAliases = parsed.voiceAliases
14361422
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
1437-
defaultModelId = model ?: defaultModelIdFallback
1423+
defaultModelId = parsed.defaultModelId
14381424
if (!modelOverrideActive) currentModelId = defaultModelId
1439-
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
1440-
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
1441-
silenceWindowMs = silenceTimeoutMs
1425+
defaultOutputFormat = parsed.defaultOutputFormat
1426+
apiKey = parsed.apiKey
1427+
silenceWindowMs = parsed.silenceTimeoutMs
14421428
Log.d(
14431429
tag,
1444-
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=$silenceTimeoutMs",
1430+
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}",
14451431
)
1446-
if (interrupt != null) interruptOnSpeech = interrupt
1447-
activeProviderIsElevenLabs = activeProvider == defaultTalkProvider
1432+
if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech
1433+
activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider
14481434
if (!activeProviderIsElevenLabs) {
14491435
// Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls
14501436
apiKey = null
14511437
defaultVoiceId = null
14521438
if (!voiceOverrideActive) currentVoiceId = null
1453-
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
1454-
} else if (selection?.normalizedPayload == true) {
1439+
Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback")
1440+
} else if (parsed.normalizedPayload) {
14551441
Log.d(tag, "talk config provider=elevenlabs")
14561442
}
14571443
configLoaded = true
14581444
} catch (_: Throwable) {
1459-
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
1460-
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
1461-
defaultModelId = defaultModelIdFallback
1445+
val fallback =
1446+
TalkModeGatewayConfigParser.fallback(
1447+
defaultProvider = defaultTalkProvider,
1448+
defaultModelIdFallback = defaultModelIdFallback,
1449+
defaultOutputFormatFallback = defaultOutputFormatFallback,
1450+
envVoice = envVoice,
1451+
sagVoice = sagVoice,
1452+
envKey = envKey,
1453+
)
1454+
silenceWindowMs = fallback.silenceTimeoutMs
1455+
defaultVoiceId = fallback.defaultVoiceId
1456+
defaultModelId = fallback.defaultModelId
14621457
if (!modelOverrideActive) currentModelId = defaultModelId
1463-
apiKey = envKey?.takeIf { it.isNotEmpty() }
1464-
voiceAliases = emptyMap()
1465-
defaultOutputFormat = defaultOutputFormatFallback
1458+
apiKey = fallback.apiKey
1459+
voiceAliases = fallback.voiceAliases
1460+
defaultOutputFormat = fallback.defaultOutputFormat
14661461
// Keep config load retryable after transient fetch failures.
14671462
configLoaded = false
14681463
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
import OpenClawKit
3+
4+
struct TalkModeGatewayConfigState {
5+
let activeProvider: String
6+
let normalizedPayload: Bool
7+
let missingResolvedPayload: Bool
8+
let defaultVoiceId: String?
9+
let voiceAliases: [String: String]
10+
let defaultModelId: String
11+
let defaultOutputFormat: String?
12+
let rawConfigApiKey: String?
13+
let interruptOnSpeech: Bool?
14+
let silenceTimeoutMs: Int
15+
}
16+
17+
enum TalkModeGatewayConfigParser {
18+
static func parse(
19+
config: [String: Any],
20+
defaultProvider: String,
21+
defaultModelIdFallback: String,
22+
defaultSilenceTimeoutMs: Int
23+
) -> TalkModeGatewayConfigState {
24+
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
25+
let selection = TalkConfigParsing.selectProviderConfig(
26+
talk,
27+
defaultProvider: defaultProvider,
28+
allowLegacyFallback: false)
29+
let activeProvider = selection?.provider ?? defaultProvider
30+
let activeConfig = selection?.config
31+
let defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
32+
.trimmingCharacters(in: .whitespacesAndNewlines)
33+
let voiceAliases: [String: String]
34+
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
35+
var resolved: [String: String] = [:]
36+
for (key, value) in aliases {
37+
guard let id = value.stringValue else { continue }
38+
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
39+
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
40+
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
41+
resolved[normalizedKey] = trimmedId
42+
}
43+
voiceAliases = resolved
44+
} else {
45+
voiceAliases = [:]
46+
}
47+
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
48+
let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback
49+
let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
50+
.trimmingCharacters(in: .whitespacesAndNewlines)
51+
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
52+
let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue
53+
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
54+
talk,
55+
fallback: defaultSilenceTimeoutMs)
56+
57+
return TalkModeGatewayConfigState(
58+
activeProvider: activeProvider,
59+
normalizedPayload: selection?.normalizedPayload == true,
60+
missingResolvedPayload: talk != nil && selection == nil,
61+
defaultVoiceId: defaultVoiceId,
62+
voiceAliases: voiceAliases,
63+
defaultModelId: defaultModelId,
64+
defaultOutputFormat: defaultOutputFormat,
65+
rawConfigApiKey: rawConfigApiKey,
66+
interruptOnSpeech: interruptOnSpeech,
67+
silenceTimeoutMs: silenceTimeoutMs)
68+
}
69+
}

apps/ios/Sources/Voice/TalkModeManager.swift

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,17 +1970,6 @@ extension TalkModeManager {
19701970
return trimmed
19711971
}
19721972

1973-
static func selectTalkProviderConfig(_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? {
1974-
TalkConfigParsing.selectProviderConfig(
1975-
talk,
1976-
defaultProvider: Self.defaultTalkProvider,
1977-
allowLegacyFallback: false)
1978-
}
1979-
1980-
static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int {
1981-
TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: Self.defaultSilenceTimeoutMs)
1982-
}
1983-
19841973
func reloadConfig() async {
19851974
guard let gateway else { return }
19861975
self.pcmFormatUnavailable = false
@@ -1992,41 +1981,27 @@ extension TalkModeManager {
19921981
)
19931982
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
19941983
guard let config = json["config"] as? [String: Any] else { return }
1995-
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
1996-
let selection = Self.selectTalkProviderConfig(talk)
1997-
if talk != nil, selection == nil {
1984+
let parsed = TalkModeGatewayConfigParser.parse(
1985+
config: config,
1986+
defaultProvider: Self.defaultTalkProvider,
1987+
defaultModelIdFallback: Self.defaultModelIdFallback,
1988+
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs)
1989+
if parsed.missingResolvedPayload {
19981990
GatewayDiagnostics.log(
19991991
"talk config ignored: normalized payload missing talk.resolved")
20001992
}
2001-
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
2002-
let activeConfig = selection?.config
2003-
let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk)
2004-
self.defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
2005-
.trimmingCharacters(in: .whitespacesAndNewlines)
2006-
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
2007-
var resolved: [String: String] = [:]
2008-
for (key, value) in aliases {
2009-
guard let id = value.stringValue else { continue }
2010-
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
2011-
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
2012-
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
2013-
resolved[normalizedKey] = trimmedId
2014-
}
2015-
self.voiceAliases = resolved
2016-
} else {
2017-
self.voiceAliases = [:]
2018-
}
1993+
let activeProvider = parsed.activeProvider
1994+
self.defaultVoiceId = parsed.defaultVoiceId
1995+
self.voiceAliases = parsed.voiceAliases
20191996
if !self.voiceOverrideActive {
20201997
self.currentVoiceId = self.defaultVoiceId
20211998
}
2022-
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
2023-
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
1999+
self.defaultModelId = parsed.defaultModelId
20242000
if !self.modelOverrideActive {
20252001
self.currentModelId = self.defaultModelId
20262002
}
2027-
self.defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
2028-
.trimmingCharacters(in: .whitespacesAndNewlines)
2029-
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
2003+
self.defaultOutputFormat = parsed.defaultOutputFormat
2004+
let rawConfigApiKey = parsed.rawConfigApiKey
20302005
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
20312006
let localApiKey = Self.normalizedTalkApiKey(
20322007
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
@@ -2045,12 +2020,13 @@ extension TalkModeManager {
20452020
self.gatewayTalkDefaultModelId = self.defaultModelId
20462021
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
20472022
self.gatewayTalkConfigLoaded = true
2048-
if let interrupt = talk?["interruptOnSpeech"]?.boolValue {
2023+
if let interrupt = parsed.interruptOnSpeech {
20492024
self.interruptOnSpeech = interrupt
20502025
}
2051-
self.silenceWindow = TimeInterval(silenceTimeoutMs) / 1000
2052-
if selection != nil {
2053-
GatewayDiagnostics.log("talk config provider=\(activeProvider) silenceTimeoutMs=\(silenceTimeoutMs)")
2026+
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
2027+
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
2028+
GatewayDiagnostics.log(
2029+
"talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)")
20542030
}
20552031
} catch {
20562032
self.defaultModelId = Self.defaultModelIdFallback

0 commit comments

Comments
 (0)