mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:01:33 +00:00
refactor(mobile): consolidate native gateway and voice lifecycles (#114671)
* refactor(mobile): consolidate native lifecycle ownership * fix(ci): cover Codex prewarm and chat lifecycle proof
This commit is contained in:
committed by
GitHub
parent
e2fbd3b1ed
commit
f85a458a45
File diff suppressed because it is too large
Load Diff
@@ -1671,7 +1671,7 @@ class NodeRuntime private constructor(
|
||||
nodeStatusText = "Connected"
|
||||
}
|
||||
notificationOutbox.onConnected()
|
||||
showLocalCanvasOnConnect()
|
||||
resetLocalCanvas()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
|
||||
val endpoint = connectedEndpoint
|
||||
val auth = activeGatewayAuth
|
||||
@@ -1692,7 +1692,7 @@ class NodeRuntime private constructor(
|
||||
nodeStatusText = message
|
||||
nodeConnectionProblem = gatewayProblemAfterDisconnect(nodeConnectionProblem, message)
|
||||
}
|
||||
showLocalCanvasOnDisconnect()
|
||||
resetLocalCanvas()
|
||||
},
|
||||
onConnectFailure = { error, pauseReconnect ->
|
||||
updateStatus {
|
||||
@@ -2201,18 +2201,15 @@ class NodeRuntime private constructor(
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnConnect() {
|
||||
private fun resetLocalCanvas() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
private fun launchGatewayRefresh(refresh: suspend () -> Unit) {
|
||||
if (mode != NodeRuntimeMode.ScreenshotFixture) scope.launch { refresh() }
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
@@ -2237,38 +2234,15 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshModelCatalog() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshModelCatalogFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshModelCatalog() = launchGatewayRefresh { refreshModelCatalogFromGateway() }
|
||||
|
||||
fun refreshProviderModels() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshProviderModelsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshProviderModels() = launchGatewayRefresh { refreshProviderModelsFromGateway() }
|
||||
|
||||
fun refreshTalkSetupReadiness() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch { refreshTalkSetupReadinessFromGateway() }
|
||||
}
|
||||
fun refreshTalkSetupReadiness() = launchGatewayRefresh { refreshTalkSetupReadinessFromGateway() }
|
||||
|
||||
fun refreshAgents() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshAgentsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshAgents() = launchGatewayRefresh { refreshAgentsFromGateway() }
|
||||
|
||||
fun refreshCronJobs() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshCronFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshCronJobs() = launchGatewayRefresh { refreshCronFromGateway() }
|
||||
|
||||
fun loadCronJobDetail(id: String) {
|
||||
val detailRequest = cronJobDetailRequestGuard.begin(id) ?: return
|
||||
@@ -2441,19 +2415,9 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshUsageFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshUsage() = launchGatewayRefresh { refreshUsageFromGateway() }
|
||||
|
||||
fun refreshSkills() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshSkillsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshSkills() = launchGatewayRefresh { refreshSkillsFromGateway() }
|
||||
|
||||
fun setSkillEnabled(
|
||||
skillKey: String,
|
||||
@@ -2576,12 +2540,7 @@ class NodeRuntime private constructor(
|
||||
_skillWorkshopNoticeText.value = null
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshNodesDevicesFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshNodesDevices() = launchGatewayRefresh { refreshNodesDevicesFromGateway() }
|
||||
|
||||
fun approveDevicePairing(
|
||||
requestId: String,
|
||||
@@ -2628,12 +2587,7 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshExecApprovals() = launchGatewayRefresh { refreshExecApprovalsFromGateway() }
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
@@ -2654,26 +2608,11 @@ class NodeRuntime private constructor(
|
||||
_execApprovalsNotice.compareAndSet(expected, null)
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshChannelsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshChannels() = launchGatewayRefresh { refreshChannelsFromGateway() }
|
||||
|
||||
fun refreshDreaming() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshDreamingFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshDreaming() = launchGatewayRefresh { refreshDreamingFromGateway() }
|
||||
|
||||
fun refreshHealthLogs() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
refreshHealthLogsFromGateway()
|
||||
}
|
||||
}
|
||||
fun refreshHealthLogs() = launchGatewayRefresh { refreshHealthLogsFromGateway() }
|
||||
|
||||
fun requestCanvasRehydrate(
|
||||
source: String = "manual",
|
||||
@@ -5475,6 +5414,36 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> refreshGatewaySummary(
|
||||
summary: MutableStateFlow<T>,
|
||||
refreshing: MutableStateFlow<Boolean>,
|
||||
errorText: MutableStateFlow<NativeText?>,
|
||||
disconnectedSummary: T,
|
||||
failureText: NativeText,
|
||||
fetch: suspend (GatewayDataScope) -> T,
|
||||
): Boolean {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return false
|
||||
publishGatewayData(gatewayScope) {
|
||||
refreshing.value = true
|
||||
errorText.value = null
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
summary.value = disconnectedSummary
|
||||
refreshing.value = false
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val nextSummary = fetch(gatewayScope)
|
||||
publishGatewayData(gatewayScope) { summary.value = nextSummary }
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { errorText.value = failureText }
|
||||
false
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { refreshing.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes approval state only while the response's operator socket still owns the method catalog. */
|
||||
private inline fun publishGatewayApprovalData(
|
||||
gatewayScope: GatewayDataScope,
|
||||
@@ -6076,68 +6045,40 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshUsageFromGateway() {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return
|
||||
publishGatewayData(gatewayScope) {
|
||||
_usageRefreshing.value = true
|
||||
_usageErrorText.value = null
|
||||
private suspend fun refreshUsageFromGateway() =
|
||||
refreshGatewaySummary(
|
||||
summary = _usageSummary,
|
||||
refreshing = _usageRefreshing,
|
||||
errorText = _usageErrorText,
|
||||
disconnectedSummary = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()),
|
||||
failureText = nativeText("Could not load usage."),
|
||||
) { gatewayScope ->
|
||||
val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "usage.status", "{}")).asObjectOrNull()
|
||||
GatewayUsageSummary(
|
||||
updatedAtMs = root.long("updatedAt"),
|
||||
providers = parseUsageProviders(root?.get("providers") as? JsonArray),
|
||||
)
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
|
||||
_usageRefreshing.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res = requestGatewayData(gatewayScope, "usage.status", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val summary =
|
||||
GatewayUsageSummary(
|
||||
updatedAtMs = root.long("updatedAt"),
|
||||
providers = parseUsageProviders(root?.get("providers") as? JsonArray),
|
||||
)
|
||||
publishGatewayData(gatewayScope) { _usageSummary.value = summary }
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { _usageErrorText.value = nativeText("Could not load usage.") }
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { _usageRefreshing.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSkillsFromGateway(): Boolean {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return false
|
||||
publishGatewayData(gatewayScope) {
|
||||
_skillsRefreshing.value = true
|
||||
_skillsErrorText.value = null
|
||||
private suspend fun refreshSkillsFromGateway(): Boolean =
|
||||
refreshGatewaySummary(
|
||||
summary = _skillsSummary,
|
||||
refreshing = _skillsRefreshing,
|
||||
errorText = _skillsErrorText,
|
||||
disconnectedSummary = GatewaySkillsSummary(skills = emptyList()),
|
||||
failureText = nativeText("Could not load skills."),
|
||||
) { gatewayScope ->
|
||||
val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "skills.status", "{}")).asObjectOrNull()
|
||||
GatewaySkillsSummary(
|
||||
managedSkillsDirAvailable =
|
||||
root
|
||||
?.get("managedSkillsDir")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.isNotEmpty() == true,
|
||||
skills = parseSkillSummaries(root?.get("skills") as? JsonArray),
|
||||
)
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
|
||||
_skillsRefreshing.value = false
|
||||
return false
|
||||
}
|
||||
try {
|
||||
val res = requestGatewayData(gatewayScope, "skills.status", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val summary =
|
||||
GatewaySkillsSummary(
|
||||
managedSkillsDirAvailable =
|
||||
root
|
||||
?.get("managedSkillsDir")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.isNotEmpty() == true,
|
||||
skills = parseSkillSummaries(root?.get("skills") as? JsonArray),
|
||||
)
|
||||
publishGatewayData(gatewayScope) {
|
||||
_skillsSummary.value = summary
|
||||
}
|
||||
return true
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { _skillsErrorText.value = nativeText("Could not load skills.") }
|
||||
return false
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { _skillsRefreshing.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setSkillEnabledOnGateway(
|
||||
skillKey: String,
|
||||
@@ -7542,101 +7483,64 @@ class NodeRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshChannelsFromGateway() {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return
|
||||
publishGatewayData(gatewayScope) {
|
||||
_channelsRefreshing.value = true
|
||||
_channelsErrorText.value = null
|
||||
private suspend fun refreshChannelsFromGateway() =
|
||||
refreshGatewaySummary(
|
||||
summary = _channelsSummary,
|
||||
refreshing = _channelsRefreshing,
|
||||
errorText = _channelsErrorText,
|
||||
disconnectedSummary = GatewayChannelsSummary(channels = emptyList()),
|
||||
failureText = nativeText("Could not load channels."),
|
||||
) { gatewayScope ->
|
||||
val response = requestGatewayData(gatewayScope, "channels.status", """{"probe":false,"timeoutMs":8000}""")
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull()
|
||||
GatewayChannelsSummary(
|
||||
updatedAtMs = root.long("ts"),
|
||||
partial = root.boolean("partial"),
|
||||
warnings = parseStringArray(root?.get("warnings") as? JsonArray),
|
||||
channels = parseChannelSummaries(root),
|
||||
)
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
|
||||
_channelsRefreshing.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res = requestGatewayData(gatewayScope, "channels.status", """{"probe":false,"timeoutMs":8000}""")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val summary =
|
||||
GatewayChannelsSummary(
|
||||
updatedAtMs = root.long("ts"),
|
||||
partial = root.boolean("partial"),
|
||||
warnings = parseStringArray(root?.get("warnings") as? JsonArray),
|
||||
channels = parseChannelSummaries(root),
|
||||
)
|
||||
publishGatewayData(gatewayScope) { _channelsSummary.value = summary }
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { _channelsErrorText.value = nativeText("Could not load channels.") }
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { _channelsRefreshing.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDreamingFromGateway() {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return
|
||||
publishGatewayData(gatewayScope) {
|
||||
_dreamingRefreshing.value = true
|
||||
_dreamingErrorText.value = null
|
||||
private suspend fun refreshDreamingFromGateway() =
|
||||
refreshGatewaySummary(
|
||||
summary = _dreamingSummary,
|
||||
refreshing = _dreamingRefreshing,
|
||||
errorText = _dreamingErrorText,
|
||||
disconnectedSummary = GatewayDreamingSummary(),
|
||||
failureText = nativeText("Could not load dreaming."),
|
||||
) { gatewayScope ->
|
||||
val statusResponse = requestGatewayData(gatewayScope, "doctor.memory.status", "{}")
|
||||
val statusRoot = json.parseToJsonElement(statusResponse).asObjectOrNull()
|
||||
val diaryResponse = requestGatewayData(gatewayScope, "doctor.memory.dreamDiary", "{}")
|
||||
val diaryRoot = json.parseToJsonElement(diaryResponse).asObjectOrNull()
|
||||
parseDreamingSummary(dreaming = statusRoot?.get("dreaming").asObjectOrNull(), diary = diaryRoot)
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
_dreamingSummary.value = GatewayDreamingSummary()
|
||||
_dreamingRefreshing.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val statusRes = requestGatewayData(gatewayScope, "doctor.memory.status", "{}")
|
||||
val statusRoot = json.parseToJsonElement(statusRes).asObjectOrNull()
|
||||
val diaryRes = requestGatewayData(gatewayScope, "doctor.memory.dreamDiary", "{}")
|
||||
val diaryRoot = json.parseToJsonElement(diaryRes).asObjectOrNull()
|
||||
val dreaming = statusRoot?.get("dreaming").asObjectOrNull()
|
||||
val summary =
|
||||
parseDreamingSummary(
|
||||
dreaming = dreaming,
|
||||
diary = diaryRoot,
|
||||
)
|
||||
publishGatewayData(gatewayScope) { _dreamingSummary.value = summary }
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { _dreamingErrorText.value = nativeText("Could not load dreaming.") }
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { _dreamingRefreshing.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshHealthLogsFromGateway() {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return
|
||||
publishGatewayData(gatewayScope) {
|
||||
_healthLogsRefreshing.value = true
|
||||
_healthLogsErrorText.value = null
|
||||
}
|
||||
if (!operatorConnected) {
|
||||
_healthLogsSummary.value = GatewayHealthLogsSummary()
|
||||
_healthLogsRefreshing.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res = requestGatewayData(gatewayScope, "logs.tail", """{"limit":40,"maxBytes":65536}""")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
private suspend fun refreshHealthLogsFromGateway() =
|
||||
refreshGatewaySummary(
|
||||
summary = _healthLogsSummary,
|
||||
refreshing = _healthLogsRefreshing,
|
||||
errorText = _healthLogsErrorText,
|
||||
disconnectedSummary = GatewayHealthLogsSummary(),
|
||||
failureText = nativeText("Could not load gateway logs."),
|
||||
) { gatewayScope ->
|
||||
val response = requestGatewayData(gatewayScope, "logs.tail", """{"limit":40,"maxBytes":65536}""")
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull()
|
||||
val lines = (root?.get("lines") as? JsonArray)?.mapNotNull { it.asStringOrNull() }.orEmpty()
|
||||
val summary =
|
||||
GatewayHealthLogsSummary(
|
||||
fileName =
|
||||
root
|
||||
?.get("file")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\'),
|
||||
cursor = root.long("cursor"),
|
||||
truncated = root.boolean("truncated"),
|
||||
entries = lines.map { parseGatewayLogEntry(it) },
|
||||
)
|
||||
publishGatewayData(gatewayScope) { _healthLogsSummary.value = summary }
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) { _healthLogsErrorText.value = nativeText("Could not load gateway logs.") }
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) { _healthLogsRefreshing.value = false }
|
||||
GatewayHealthLogsSummary(
|
||||
fileName =
|
||||
root
|
||||
?.get("file")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\'),
|
||||
cursor = root.long("cursor"),
|
||||
truncated = root.boolean("truncated"),
|
||||
entries = lines.map { parseGatewayLogEntry(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
|
||||
val sanitizedLine = sanitizeGatewayLogText(line)
|
||||
|
||||
@@ -508,9 +508,7 @@ class ChatController internal constructor(
|
||||
clearOptimisticMessages = false,
|
||||
preserveDisconnectedOwnership = true,
|
||||
)
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
// Older gateways cannot restate plan state, so reconnect retains it until
|
||||
// recovery proves another run, a terminal state, or an explicit empty snapshot.
|
||||
_historyLoading.value = false
|
||||
@@ -617,9 +615,7 @@ class ChatController internal constructor(
|
||||
if (retireRunState) {
|
||||
restoreRunStateOnReconnect = false
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
}
|
||||
clearPlanSteps()
|
||||
appliedMainSessionKey = "main"
|
||||
@@ -2183,9 +2179,7 @@ class ChatController internal constructor(
|
||||
_healthOk.value = false
|
||||
clearLiveHistoryMarker()
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanSteps()
|
||||
_sessionId.value = null
|
||||
_historyLoading.value = markLoading
|
||||
@@ -2505,9 +2499,7 @@ class ChatController internal constructor(
|
||||
settleProjectedRun(actualRunId)
|
||||
if (ack.isTerminalSuccess) {
|
||||
if (isCapturedOwnerCurrent()) {
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanSteps()
|
||||
refreshCurrentHistoryBestEffort(runIdsToReconcile = setOf(actualRunId))
|
||||
}
|
||||
@@ -2516,9 +2508,7 @@ class ChatController internal constructor(
|
||||
// Terminal timeout/error means the gateway did not accept a runnable turn.
|
||||
// Surface failed acceptance instead of letting a cleared composer look successful.
|
||||
if (isCapturedOwnerCurrent()) {
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanSteps()
|
||||
updateLocalizedErrorText(nativeText("Chat failed before the run started; try again."))
|
||||
}
|
||||
@@ -2871,9 +2861,7 @@ class ChatController internal constructor(
|
||||
// retain local ownership until the recovery snapshot can reconcile it.
|
||||
resetSwarmProgress()
|
||||
if (isSwarmEnabled()) refreshSwarmSessions()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
refreshQuestions()
|
||||
refreshHistoryForRecovery()
|
||||
}
|
||||
@@ -5154,9 +5142,7 @@ class ChatController internal constructor(
|
||||
val hasNewerRun =
|
||||
synchronized(pendingRuns) { pendingRuns.isNotEmpty() } || unresolvedRepliesByRunId.isNotEmpty()
|
||||
if (!hasNewerRun) {
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanStepsFor(runId)
|
||||
updateLocalizedErrorText(
|
||||
if (state == "error") {
|
||||
@@ -5188,9 +5174,7 @@ class ChatController internal constructor(
|
||||
} else {
|
||||
clearPendingRuns(clearOptimisticMessages = false)
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanStepsFor(runId)
|
||||
val terminalRunIds = runId?.let(::setOf) ?: unresolvedRepliesByRunId.keys.toSet()
|
||||
refreshCurrentHistoryBestEffort(
|
||||
@@ -5413,9 +5397,7 @@ class ChatController internal constructor(
|
||||
"error" -> {
|
||||
updateLocalizedErrorText(nativeText("Event stream interrupted; try refreshing."))
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
clearPlanSteps()
|
||||
}
|
||||
}
|
||||
@@ -5441,6 +5423,12 @@ class ChatController internal constructor(
|
||||
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
||||
}
|
||||
|
||||
private fun clearLiveRunUi() {
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
}
|
||||
|
||||
private fun clearPlanSteps() {
|
||||
planRunId = null
|
||||
_planSteps.value = emptyList()
|
||||
@@ -5586,9 +5574,7 @@ class ChatController internal constructor(
|
||||
|
||||
private fun clearTransientRunUiIfIdle(preservePlan: Boolean = false) {
|
||||
if (synchronized(pendingRuns) { pendingRuns.isNotEmpty() }) return
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
clearLiveRunUi()
|
||||
if (!preservePlan) clearPlanSteps()
|
||||
}
|
||||
|
||||
|
||||
@@ -3191,33 +3191,12 @@ class TalkModeManager internal constructor(
|
||||
if (stopRequested) return
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
if (activePttCaptureId != null) {
|
||||
val pushToTalkActive = activePttCaptureId != null
|
||||
if (pushToTalkActive) {
|
||||
pttReleaseCompletion?.let {
|
||||
it.complete(Unit)
|
||||
return
|
||||
}
|
||||
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
|
||||
setStatus(nativeText("Microphone permission required"))
|
||||
return
|
||||
}
|
||||
setStatus(
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_AUDIO -> nativeText("Audio error")
|
||||
SpeechRecognizer.ERROR_CLIENT -> nativeText("Client error")
|
||||
SpeechRecognizer.ERROR_NETWORK -> nativeText("Network error")
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeText("Network timeout")
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> nativeText("Listening (PTT)")
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeText("Recognizer busy")
|
||||
SpeechRecognizer.ERROR_SERVER -> nativeText("Server error")
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeText("Listening (PTT)")
|
||||
else -> nativeText("Speech error (\$error)", error)
|
||||
},
|
||||
)
|
||||
schedulePushToTalkRestart(
|
||||
delayMs = 600L,
|
||||
advanceRung = pttRecognitionRung !is PushToTalkRecognitionRung.RestartingSingleSession,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
|
||||
setStatus(nativeText("Microphone permission required"))
|
||||
@@ -3230,13 +3209,21 @@ class TalkModeManager internal constructor(
|
||||
SpeechRecognizer.ERROR_CLIENT -> nativeText("Client error")
|
||||
SpeechRecognizer.ERROR_NETWORK -> nativeText("Network error")
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeText("Network timeout")
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> nativeText("Listening")
|
||||
SpeechRecognizer.ERROR_NO_MATCH,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
|
||||
-> if (pushToTalkActive) nativeText("Listening (PTT)") else nativeText("Listening")
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeText("Recognizer busy")
|
||||
SpeechRecognizer.ERROR_SERVER -> nativeText("Server error")
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeText("Listening")
|
||||
else -> nativeText("Speech error (\$error)", error)
|
||||
},
|
||||
)
|
||||
if (pushToTalkActive) {
|
||||
schedulePushToTalkRestart(
|
||||
delayMs = 600L,
|
||||
advanceRung = pttRecognitionRung !is PushToTalkRecognitionRung.RestartingSingleSession,
|
||||
)
|
||||
return
|
||||
}
|
||||
scheduleRestart(delayMs = 600)
|
||||
}
|
||||
|
||||
|
||||
@@ -2301,6 +2301,23 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func successfulInvokeResponse(
|
||||
_ request: BridgeInvokeRequest,
|
||||
payload: some Encodable) throws -> BridgeInvokeResponse
|
||||
{
|
||||
try BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payloadJSON: self.encodePayload(payload))
|
||||
}
|
||||
|
||||
private static func unknownInvokeResponse(_ request: BridgeInvokeRequest) -> BridgeInvokeResponse {
|
||||
BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
|
||||
private static func scopedWatchNotificationRequest(
|
||||
_ req: BridgeInvokeRequest,
|
||||
gatewayStableID: String?) -> BridgeInvokeRequest
|
||||
@@ -2379,8 +2396,7 @@ final class NodeAppModel {
|
||||
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
||||
isPrecise: isPrecise,
|
||||
source: nil)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
}
|
||||
|
||||
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -2431,10 +2447,7 @@ final class NodeAppModel {
|
||||
])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2507,10 +2520,7 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2581,10 +2591,7 @@ final class NodeAppModel {
|
||||
updateCameraHUD(ownerID: req.id, text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2739,8 +2746,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
let payload = OpenClawChatPushPayload(messageId: messageId)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
@@ -2802,17 +2808,12 @@ final class NodeAppModel {
|
||||
switch req.command {
|
||||
case OpenClawDeviceCommand.status.rawValue:
|
||||
let payload = try await deviceStatusService.status()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawDeviceCommand.info.rawValue:
|
||||
let payload = self.deviceStatusService.info()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2820,8 +2821,7 @@ final class NodeAppModel {
|
||||
let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawPhotosLatestParams()
|
||||
let payload = try await photosService.latest(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
}
|
||||
|
||||
private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -2830,18 +2830,13 @@ final class NodeAppModel {
|
||||
let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawContactsSearchParams()
|
||||
let payload = try await contactsService.search(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawContactsCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await contactsService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2851,18 +2846,13 @@ final class NodeAppModel {
|
||||
let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawCalendarEventsParams()
|
||||
let payload = try await calendarService.events(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawCalendarCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await calendarService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2872,18 +2862,13 @@ final class NodeAppModel {
|
||||
let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawRemindersListParams()
|
||||
let payload = try await remindersService.list(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawRemindersCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await remindersService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2893,19 +2878,14 @@ final class NodeAppModel {
|
||||
let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawMotionActivityParams()
|
||||
let payload = try await motionService.activities(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawMotionCommand.pedometer.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawPedometerParams()
|
||||
let payload = try await motionService.pedometer(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2919,8 +2899,7 @@ final class NodeAppModel {
|
||||
message: "INVALID_REQUEST: period must be today"))
|
||||
}
|
||||
let payload = try await self.healthSummaryService.summary(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
}
|
||||
|
||||
private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -2988,25 +2967,19 @@ final class NodeAppModel {
|
||||
case .started:
|
||||
await self.talkMode.awaitPushToTalkOnce(start)
|
||||
}
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawTalkCommand.pttStop.rawValue:
|
||||
// Interrupt commands invalidate suspended preparation before touching
|
||||
// capture state, then bypass the preparation queue entirely.
|
||||
self.talkPttCommandEpoch &+= 1
|
||||
let payload = self.talkMode.endPushToTalk(expectedTranscriptionOnly: false)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawTalkCommand.pttCancel.rawValue:
|
||||
self.talkPttCommandEpoch &+= 1
|
||||
let payload = self.talkMode.cancelPushToTalk(expectedTranscriptionOnly: false)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3283,16 +3256,20 @@ extension NodeAppModel {
|
||||
private func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
||||
|
||||
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
||||
func register(
|
||||
_ commands: [String],
|
||||
handler: @escaping (NodeAppModel, BridgeInvokeRequest) async throws -> BridgeInvokeResponse)
|
||||
{
|
||||
let invoke: NodeCapabilityRouter.Handler = { [weak self] request in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await handler(self, request)
|
||||
}
|
||||
for command in commands {
|
||||
handlers[command] = handler
|
||||
handlers[command] = invoke
|
||||
}
|
||||
}
|
||||
|
||||
register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleLocationInvoke(req)
|
||||
}
|
||||
register([OpenClawLocationCommand.get.rawValue]) { try await $0.handleLocationInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
@@ -3300,111 +3277,66 @@ extension NodeAppModel {
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCanvasInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleCanvasInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCanvasA2UIInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleCanvasA2UIInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawCameraCommand.list.rawValue,
|
||||
OpenClawCameraCommand.snap.rawValue,
|
||||
OpenClawCameraCommand.clip.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCameraInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleCameraInvoke($1) }
|
||||
|
||||
register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
}
|
||||
register([OpenClawScreenCommand.record.rawValue]) { try await $0.handleScreenRecordInvoke($1) }
|
||||
|
||||
register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleSystemNotify(req)
|
||||
}
|
||||
register([OpenClawSystemCommand.notify.rawValue]) { try await $0.handleSystemNotify($1) }
|
||||
|
||||
register([OpenClawChatCommand.push.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleChatPushInvoke(req)
|
||||
}
|
||||
register([OpenClawChatCommand.push.rawValue]) { try await $0.handleChatPushInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawDeviceCommand.status.rawValue,
|
||||
OpenClawDeviceCommand.info.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleDeviceInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleDeviceInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawWatchCommand.status.rawValue,
|
||||
OpenClawWatchCommand.notify.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleWatchInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleWatchInvoke($1) }
|
||||
|
||||
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handlePhotosInvoke(req)
|
||||
}
|
||||
register([OpenClawPhotosCommand.latest.rawValue]) { try await $0.handlePhotosInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawContactsCommand.search.rawValue,
|
||||
OpenClawContactsCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleContactsInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleContactsInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawCalendarCommand.events.rawValue,
|
||||
OpenClawCalendarCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCalendarInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleCalendarInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawRemindersCommand.list.rawValue,
|
||||
OpenClawRemindersCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleRemindersInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleRemindersInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawMotionCommand.activity.rawValue,
|
||||
OpenClawMotionCommand.pedometer.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleMotionInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleMotionInvoke($1) }
|
||||
|
||||
register([OpenClawHealthCommand.summary.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleHealthInvoke(req)
|
||||
}
|
||||
register([OpenClawHealthCommand.summary.rawValue]) { try await $0.handleHealthInvoke($1) }
|
||||
|
||||
register([
|
||||
OpenClawTalkCommand.pttStart.rawValue,
|
||||
OpenClawTalkCommand.pttStop.rawValue,
|
||||
OpenClawTalkCommand.pttCancel.rawValue,
|
||||
OpenClawTalkCommand.pttOnce.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleTalkInvoke(req)
|
||||
}
|
||||
]) { try await $0.handleTalkInvoke($1) }
|
||||
|
||||
return NodeCapabilityRouter(handlers: handlers)
|
||||
}
|
||||
@@ -3419,8 +3351,7 @@ extension NodeAppModel {
|
||||
appInstalled: status.appInstalled,
|
||||
reachable: status.reachable,
|
||||
activationState: status.activationState)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
case OpenClawWatchCommand.notify.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
|
||||
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
||||
@@ -3457,8 +3388,7 @@ extension NodeAppModel {
|
||||
deliveredImmediately: result.deliveredImmediately,
|
||||
queuedForDelivery: result.queuedForDelivery,
|
||||
transport: result.transport)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
return try Self.successfulInvokeResponse(req, payload: payload)
|
||||
} catch {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
@@ -3468,10 +3398,7 @@ extension NodeAppModel {
|
||||
message: error.localizedDescription))
|
||||
}
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
return Self.unknownInvokeResponse(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6047,14 +5974,38 @@ extension NodeAppModel {
|
||||
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
||||
}
|
||||
|
||||
private static func appendPendingApproval<Item>(
|
||||
_ item: Item,
|
||||
to items: inout [Item],
|
||||
maximumCount: Int? = nil,
|
||||
key: (Item) -> (some Equatable)?,
|
||||
sortedBy sortsBefore: (Item, Item) -> Bool) -> Bool
|
||||
{
|
||||
guard let itemKey = key(item), !items.contains(where: { key($0) == itemKey }) else { return false }
|
||||
items.append(item)
|
||||
if let maximumCount, items.count > maximumCount { items.removeFirst() }
|
||||
items.sort(by: sortsBefore)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func removePendingApproval<Item>(
|
||||
_ item: Item,
|
||||
from items: inout [Item],
|
||||
key: (Item) -> (some Equatable)?) -> Bool
|
||||
{
|
||||
guard let itemKey = key(item) else { return false }
|
||||
let originalCount = items.count
|
||||
items.removeAll { key($0) == itemKey }
|
||||
return items.count != originalCount
|
||||
}
|
||||
|
||||
private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
|
||||
guard let pushKey = Self.execApprovalPushKey(push),
|
||||
!self.pendingWatchExecApprovalRecoveryPushes.contains(where: {
|
||||
Self.execApprovalPushKey($0) == pushKey
|
||||
})
|
||||
guard Self.appendPendingApproval(
|
||||
push,
|
||||
to: &self.pendingWatchExecApprovalRecoveryPushes,
|
||||
key: Self.execApprovalPushKey,
|
||||
sortedBy: Self.execApprovalPushSortsBefore)
|
||||
else { return }
|
||||
self.pendingWatchExecApprovalRecoveryPushes.append(push)
|
||||
self.pendingWatchExecApprovalRecoveryPushes.sort(by: Self.execApprovalPushSortsBefore)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: queued recovery "
|
||||
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
|
||||
@@ -6062,12 +6013,11 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
|
||||
guard let pushKey = Self.execApprovalPushKey(push) else { return }
|
||||
let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count
|
||||
self.pendingWatchExecApprovalRecoveryPushes.removeAll {
|
||||
Self.execApprovalPushKey($0) == pushKey
|
||||
}
|
||||
guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return }
|
||||
guard Self.removePendingApproval(
|
||||
push,
|
||||
from: &self.pendingWatchExecApprovalRecoveryPushes,
|
||||
key: Self.execApprovalPushKey)
|
||||
else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: cleared recovery "
|
||||
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
|
||||
@@ -6075,40 +6025,35 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
|
||||
guard let pushKey = Self.execApprovalPushKey(push),
|
||||
!self.pendingExecApprovalResolvedPushes.contains(where: {
|
||||
Self.execApprovalPushKey($0) == pushKey
|
||||
})
|
||||
else { return }
|
||||
// A silent resolution push is not replayed by the gateway. Keep it until the
|
||||
// authenticated owner route returns so its matching notification cannot linger.
|
||||
self.pendingExecApprovalResolvedPushes.append(push)
|
||||
if self.pendingExecApprovalResolvedPushes.count > 32 {
|
||||
self.pendingExecApprovalResolvedPushes.removeFirst()
|
||||
}
|
||||
self.pendingExecApprovalResolvedPushes.sort(by: Self.execApprovalPushSortsBefore)
|
||||
guard Self.appendPendingApproval(
|
||||
push,
|
||||
to: &self.pendingExecApprovalResolvedPushes,
|
||||
maximumCount: 32,
|
||||
key: Self.execApprovalPushKey,
|
||||
sortedBy: Self.execApprovalPushSortsBefore)
|
||||
else { return }
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
|
||||
guard let pushKey = Self.execApprovalPushKey(push) else { return }
|
||||
let originalCount = self.pendingExecApprovalResolvedPushes.count
|
||||
self.pendingExecApprovalResolvedPushes.removeAll {
|
||||
Self.execApprovalPushKey($0) == pushKey
|
||||
}
|
||||
guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return }
|
||||
guard Self.removePendingApproval(
|
||||
push,
|
||||
from: &self.pendingExecApprovalResolvedPushes,
|
||||
key: Self.execApprovalPushKey)
|
||||
else { return }
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private func removePendingPersistedExecApprovalReadback(
|
||||
_ readback: PersistedExecApprovalReadback)
|
||||
{
|
||||
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback) else { return }
|
||||
let originalCount = self.pendingPersistedExecApprovalReadbacks.count
|
||||
self.pendingPersistedExecApprovalReadbacks.removeAll {
|
||||
Self.persistedExecApprovalReadbackKey($0) == readbackKey
|
||||
}
|
||||
guard self.pendingPersistedExecApprovalReadbacks.count != originalCount else { return }
|
||||
guard Self.removePendingApproval(
|
||||
readback,
|
||||
from: &self.pendingPersistedExecApprovalReadbacks,
|
||||
key: Self.persistedExecApprovalReadbackKey)
|
||||
else { return }
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
@@ -6119,19 +6064,15 @@ extension NodeAppModel {
|
||||
let readback = PersistedExecApprovalReadback(
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback),
|
||||
!self.pendingPersistedExecApprovalReadbacks.contains(where: {
|
||||
Self.persistedExecApprovalReadbackKey($0) == readbackKey
|
||||
})
|
||||
else { return }
|
||||
// A requested event is an edge trigger, not replayed state. Retain its exact owner
|
||||
// until approval.get classifies it so a reconnect cannot lose a parked approval.
|
||||
self.pendingPersistedExecApprovalReadbacks.append(readback)
|
||||
if self.pendingPersistedExecApprovalReadbacks.count > 64 {
|
||||
self.pendingPersistedExecApprovalReadbacks.removeFirst()
|
||||
}
|
||||
self.pendingPersistedExecApprovalReadbacks.sort(
|
||||
by: Self.persistedExecApprovalReadbackSortsBefore)
|
||||
guard Self.appendPendingApproval(
|
||||
readback,
|
||||
to: &self.pendingPersistedExecApprovalReadbacks,
|
||||
maximumCount: 64,
|
||||
key: Self.persistedExecApprovalReadbackKey,
|
||||
sortedBy: Self.persistedExecApprovalReadbackSortsBefore)
|
||||
else { return }
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
|
||||
@@ -4577,34 +4577,31 @@ extension TalkModeManager {
|
||||
}
|
||||
|
||||
static func configureAudioSession(preferredInputDeviceID: String? = nil) throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let forceSpeaker = TalkDefaults.speakerphoneEnabled()
|
||||
let options = TalkAudioRoute.categoryOptions(speakerphoneEnabled: forceSpeaker)
|
||||
// Prefer `.spokenAudio` for STT; it tends to preserve speech energy better than `.voiceChat`.
|
||||
try session.setCategory(.playAndRecord, mode: .spokenAudio, options: options)
|
||||
try? session.setPreferredSampleRate(48000)
|
||||
try? session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setActive(true, options: [])
|
||||
if TalkAudioRoute.shouldForceSpeaker(
|
||||
preferenceEnabled: forceSpeaker,
|
||||
outputPortTypes: session.currentRoute.outputs.map(\.portType))
|
||||
{
|
||||
try? session.overrideOutputAudioPort(.speaker)
|
||||
} else {
|
||||
try? session.overrideOutputAudioPort(.none)
|
||||
}
|
||||
// A speaker override also forces the built-in microphone; apply an explicit input last.
|
||||
self.applyPreferredInput(preferredInputDeviceID, to: session)
|
||||
GatewayDiagnostics.log("talk audio: session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
|
||||
try self.configureAudioSession(
|
||||
mode: .spokenAudio,
|
||||
preferredInputDeviceID: preferredInputDeviceID,
|
||||
diagnosticsPrefix: "talk audio")
|
||||
}
|
||||
|
||||
static func configureRealtimeAudioSession(preferredInputDeviceID: String? = nil) throws {
|
||||
// Realtime Talk is full duplex. `.voiceChat` enables iOS voice processing so speaker
|
||||
// output is less likely to be captured as fresh microphone input.
|
||||
try self.configureAudioSession(
|
||||
mode: .voiceChat,
|
||||
preferredInputDeviceID: preferredInputDeviceID,
|
||||
diagnosticsPrefix: "talk realtime audio")
|
||||
}
|
||||
|
||||
private static func configureAudioSession(
|
||||
mode: AVAudioSession.Mode,
|
||||
preferredInputDeviceID: String?,
|
||||
diagnosticsPrefix: String) throws
|
||||
{
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let forceSpeaker = TalkDefaults.speakerphoneEnabled()
|
||||
let options = TalkAudioRoute.categoryOptions(speakerphoneEnabled: forceSpeaker)
|
||||
// Realtime Talk is full duplex. `.voiceChat` enables iOS voice processing so speaker
|
||||
// output is less likely to be captured as fresh microphone input.
|
||||
try session.setCategory(.playAndRecord, mode: .voiceChat, options: options)
|
||||
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||
try? session.setPreferredSampleRate(48000)
|
||||
try? session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setActive(true, options: [])
|
||||
@@ -4619,7 +4616,7 @@ extension TalkModeManager {
|
||||
// A speaker override also forces the built-in microphone; apply an explicit input last.
|
||||
self.applyPreferredInput(preferredInputDeviceID, to: session)
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime audio: session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
|
||||
"\(diagnosticsPrefix): session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
|
||||
}
|
||||
|
||||
private static func applyPreferredInput(_ deviceID: String?, to session: AVAudioSession) {
|
||||
|
||||
@@ -1943,7 +1943,9 @@ struct ChatViewModelTests {
|
||||
let discardedViewModel = try weakReference(to: viewModel)
|
||||
|
||||
viewModel = nil
|
||||
await Task.yield()
|
||||
for _ in 0..<100 where discardedViewModel.value != nil {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(discardedViewModel.value == nil)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ function createExtensionCodexAppServerAttemptExtraVitestConfig(
|
||||
) {
|
||||
return createScopedVitestConfig(
|
||||
[
|
||||
"extensions/codex/src/app-server/run-attempt-client-prewarm.test.ts",
|
||||
"extensions/codex/src/app-server/run-attempt-lifecycle-controller.test.ts",
|
||||
"extensions/codex/src/app-server/run-attempt-thread-cleanup.test.ts",
|
||||
"extensions/codex/src/app-server/run-attempt.context-engine.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user