mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
fix: isolate device chat defaults (#53752) (thanks @lixuankai)
* [feat]Multiple nodes session context isolated from each other * feat(android): Multiple nodes session context isolated from each other * feat(android): Multiple nodes session context isolated from each other * feat(android): Multiple nodes session context isolated from each other * fix(android): isolate device chat defaults --------- Co-authored-by: lixuankai <lixuankai@oppo.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -24,7 +24,6 @@ import ai.openclaw.app.voice.TalkModeManager
|
|||||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -34,7 +33,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -195,7 +193,12 @@ class NodeRuntime(
|
|||||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||||
|
|
||||||
private val _mainSessionKey = MutableStateFlow("main")
|
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
|
||||||
|
val deviceId = identityStore.loadOrCreate().deviceId
|
||||||
|
return buildNodeMainSessionKey(deviceId, agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _mainSessionKey = MutableStateFlow(resolveNodeMainSessionKey())
|
||||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||||
|
|
||||||
private val cameraHudSeq = AtomicLong(0)
|
private val cameraHudSeq = AtomicLong(0)
|
||||||
@@ -243,7 +246,7 @@ class NodeRuntime(
|
|||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
applyMainSessionKey(mainSessionKey)
|
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainSessionKey))
|
||||||
updateStatus()
|
updateStatus()
|
||||||
micCapture.onGatewayConnectionChanged(true)
|
micCapture.onGatewayConnectionChanged(true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -259,9 +262,6 @@ class NodeRuntime(
|
|||||||
_serverName.value = null
|
_serverName.value = null
|
||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
|
||||||
_mainSessionKey.value = "main"
|
|
||||||
}
|
|
||||||
chat.applyMainSessionKey(resolveMainSessionKey())
|
chat.applyMainSessionKey(resolveMainSessionKey())
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
updateStatus()
|
updateStatus()
|
||||||
@@ -320,7 +320,9 @@ class NodeRuntime(
|
|||||||
session = operatorSession,
|
session = operatorSession,
|
||||||
json = json,
|
json = json,
|
||||||
supportsChatSubscribe = false,
|
supportsChatSubscribe = false,
|
||||||
)
|
).also {
|
||||||
|
it.applyMainSessionKey(_mainSessionKey.value)
|
||||||
|
}
|
||||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
||||||
// Reuse the existing TalkMode speech engine for native Android TTS playback
|
// Reuse the existing TalkMode speech engine for native Android TTS playback
|
||||||
// without enabling the legacy talk capture loop.
|
// without enabling the legacy talk capture loop.
|
||||||
@@ -404,13 +406,12 @@ class NodeRuntime(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyMainSessionKey(candidate: String?) {
|
private fun syncMainSessionKey(agentId: String?) {
|
||||||
val trimmed = normalizeMainKey(candidate) ?: return
|
val resolvedKey = resolveNodeMainSessionKey(agentId)
|
||||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
if (_mainSessionKey.value == resolvedKey) return
|
||||||
if (_mainSessionKey.value == trimmed) return
|
_mainSessionKey.value = resolvedKey
|
||||||
_mainSessionKey.value = trimmed
|
talkMode.setMainSessionKey(resolvedKey)
|
||||||
talkMode.setMainSessionKey(trimmed)
|
chat.applyMainSessionKey(resolvedKey)
|
||||||
chat.applyMainSessionKey(trimmed)
|
|
||||||
updateHomeCanvasState()
|
updateHomeCanvasState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,9 +961,7 @@ class NodeRuntime(
|
|||||||
val config = root?.get("config").asObjectOrNull()
|
val config = root?.get("config").asObjectOrNull()
|
||||||
val ui = config?.get("ui").asObjectOrNull()
|
val ui = config?.get("ui").asObjectOrNull()
|
||||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
syncMainSessionKey(gatewayDefaultAgentId)
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
|
||||||
applyMainSessionKey(mainKey)
|
|
||||||
|
|
||||||
val parsed = parseHexColorArgb(raw)
|
val parsed = parseHexColorArgb(raw)
|
||||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||||
@@ -995,7 +994,7 @@ class NodeRuntime(
|
|||||||
|
|
||||||
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
|
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
|
||||||
gatewayAgents = agents
|
gatewayAgents = agents
|
||||||
applyMainSessionKey(mainKey)
|
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainKey) ?: gatewayDefaultAgentId)
|
||||||
updateHomeCanvasState()
|
updateHomeCanvasState()
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -11,3 +11,14 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
|||||||
if (trimmed == "global") return true
|
if (trimmed == "global") return true
|
||||||
return trimmed.startsWith("agent:")
|
return trimmed.startsWith("agent:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||||
|
val trimmed = raw?.trim().orEmpty()
|
||||||
|
if (!trimmed.startsWith("agent:")) return null
|
||||||
|
return trimmed.removePrefix("agent:").substringBefore(':').trim().ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
|
||||||
|
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
|
||||||
|
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ChatController(
|
|||||||
private val json: Json,
|
private val json: Json,
|
||||||
private val supportsChatSubscribe: Boolean,
|
private val supportsChatSubscribe: Boolean,
|
||||||
) {
|
) {
|
||||||
|
private var appliedMainSessionKey = "main"
|
||||||
private val _sessionKey = MutableStateFlow("main")
|
private val _sessionKey = MutableStateFlow("main")
|
||||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(sessionKey: String) {
|
fun load(sessionKey: String) {
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
val key = normalizeRequestedSessionKey(sessionKey)
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||||
}
|
}
|
||||||
@@ -81,9 +82,15 @@ class ChatController(
|
|||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
fun applyMainSessionKey(mainSessionKey: String) {
|
||||||
val trimmed = mainSessionKey.trim()
|
val trimmed = mainSessionKey.trim()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
if (_sessionKey.value == trimmed) return
|
val nextState =
|
||||||
if (_sessionKey.value != "main") return
|
applyMainSessionKey(
|
||||||
_sessionKey.value = trimmed
|
currentSessionKey = normalizeRequestedSessionKey(_sessionKey.value),
|
||||||
|
appliedMainSessionKey = appliedMainSessionKey,
|
||||||
|
nextMainSessionKey = trimmed,
|
||||||
|
)
|
||||||
|
appliedMainSessionKey = nextState.appliedMainSessionKey
|
||||||
|
if (_sessionKey.value == nextState.currentSessionKey) return
|
||||||
|
_sessionKey.value = nextState.currentSessionKey
|
||||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun switchSession(sessionKey: String) {
|
fun switchSession(sessionKey: String) {
|
||||||
val key = sessionKey.trim()
|
val key = normalizeRequestedSessionKey(sessionKey)
|
||||||
if (key.isEmpty()) return
|
if (key.isEmpty()) return
|
||||||
if (key == _sessionKey.value) return
|
if (key == _sessionKey.value) return
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
@@ -111,6 +118,13 @@ class ChatController(
|
|||||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeRequestedSessionKey(sessionKey: String): String {
|
||||||
|
val key = sessionKey.trim()
|
||||||
|
if (key.isEmpty()) return appliedMainSessionKey
|
||||||
|
if (key == "main" && appliedMainSessionKey != "main") return appliedMainSessionKey
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
message: String,
|
message: String,
|
||||||
thinkingLevel: String,
|
thinkingLevel: String,
|
||||||
@@ -532,6 +546,28 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class MainSessionState(
|
||||||
|
val currentSessionKey: String,
|
||||||
|
val appliedMainSessionKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun applyMainSessionKey(
|
||||||
|
currentSessionKey: String,
|
||||||
|
appliedMainSessionKey: String,
|
||||||
|
nextMainSessionKey: String,
|
||||||
|
): MainSessionState {
|
||||||
|
if (currentSessionKey == appliedMainSessionKey) {
|
||||||
|
return MainSessionState(
|
||||||
|
currentSessionKey = nextMainSessionKey,
|
||||||
|
appliedMainSessionKey = nextMainSessionKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return MainSessionState(
|
||||||
|
currentSessionKey = currentSessionKey,
|
||||||
|
appliedMainSessionKey = nextMainSessionKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||||
if (previous.isEmpty() || incoming.isEmpty()) return incoming
|
if (previous.isEmpty() || incoming.isEmpty()) return incoming
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
val sessions by viewModel.chatSessions.collectAsState()
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadChat(mainSessionKey)
|
viewModel.loadChat(mainSessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import android.speech.tts.TextToSpeech
|
|||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import ai.openclaw.app.gateway.GatewaySession
|
import ai.openclaw.app.gateway.GatewaySession
|
||||||
import ai.openclaw.app.isCanonicalMainSessionKey
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
@@ -131,7 +130,6 @@ class TalkModeManager(
|
|||||||
fun setMainSessionKey(sessionKey: String?) {
|
fun setMainSessionKey(sessionKey: String?) {
|
||||||
val trimmed = sessionKey?.trim().orEmpty()
|
val trimmed = sessionKey?.trim().orEmpty()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
|
||||||
mainSessionKey = trimmed
|
mainSessionKey = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,9 +909,6 @@ class TalkModeManager(
|
|||||||
val res = session.request("talk.config", "{}")
|
val res = session.request("talk.config", "{}")
|
||||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||||
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
|
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
|
||||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
|
||||||
mainSessionKey = parsed.mainSessionKey
|
|
||||||
}
|
|
||||||
silenceWindowMs = parsed.silenceTimeoutMs
|
silenceWindowMs = parsed.silenceTimeoutMs
|
||||||
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
|
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
|
||||||
configLoaded = true
|
configLoaded = true
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ai.openclaw.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SessionKeyTest {
|
||||||
|
@Test
|
||||||
|
fun buildNodeMainSessionKeyUsesStableDeviceScopedSuffix() {
|
||||||
|
val key = buildNodeMainSessionKey(deviceId = "1234567890abcdef", agentId = "ops")
|
||||||
|
|
||||||
|
assertEquals("agent:ops:node-1234567890ab", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveAgentIdFromMainSessionKeyParsesCanonicalAgentKey() {
|
||||||
|
assertEquals("ops", resolveAgentIdFromMainSessionKey("agent:ops:main"))
|
||||||
|
assertNull(resolveAgentIdFromMainSessionKey("global"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ai.openclaw.app.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatControllerSessionPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun applyMainSessionKeyMovesCurrentSessionWhenStillOnDefault() {
|
||||||
|
val state =
|
||||||
|
applyMainSessionKey(
|
||||||
|
currentSessionKey = "main",
|
||||||
|
appliedMainSessionKey = "main",
|
||||||
|
nextMainSessionKey = "agent:ops:node-device",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("agent:ops:node-device", state.currentSessionKey)
|
||||||
|
assertEquals("agent:ops:node-device", state.appliedMainSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyMainSessionKeyKeepsUserSelectedSession() {
|
||||||
|
val state =
|
||||||
|
applyMainSessionKey(
|
||||||
|
currentSessionKey = "custom",
|
||||||
|
appliedMainSessionKey = "agent:ops:node-old",
|
||||||
|
nextMainSessionKey = "agent:ops:node-new",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("custom", state.currentSessionKey)
|
||||||
|
assertEquals("agent:ops:node-new", state.appliedMainSessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user