mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-08-14 07:02:02 +00:00
Enhance shared agent configuration and bridge diagnostics
This commit is contained in:
@@ -34,15 +34,14 @@
|
||||
> <key>`; the default model name is `hermes-agent`. Connection test
|
||||
> calls `GET /v1/models` and falls back to `/health`.
|
||||
>
|
||||
> **Hermes-Relay feature parity.** WakeHermesClaw mirrors the patterns in
|
||||
> [hermes-relay](https://codename-11.github.io/hermes-relay/):
|
||||
> 6-character pairing codes + deep-link QR payload (`agentvoice://pair?...`),
|
||||
> multi-endpoint candidates (LAN + Tailscale + public) raced in parallel on
|
||||
> every connect, per-capability TTL grants ("approve · 10 min / 1 hour /
|
||||
> until revoked") with a destructive-verb override, `/revoke` and `/grants`
|
||||
> endpoints, an Accessibility Bridge (tap / swipe / Home / Back / window
|
||||
> describe — sideload-only), notifications.active.list backed by the
|
||||
> existing NotificationListenerService, and a build-flag (`IS_SIDELOAD`)
|
||||
> **WakeHermesClaw shared agent controls.** The app includes 6-character
|
||||
> bridge pairing codes, deep-link setup payloads, multi-endpoint candidates
|
||||
> (LAN + VPN + public) raced in parallel on connect, per-capability TTL grants
|
||||
> ("approve · 10 min / 1 hour / until revoked") with a destructive-verb
|
||||
> override, `/revoke` and `/grants` endpoints, an Accessibility Bridge
|
||||
> (tap / swipe / Home / Back / window describe — sideload-only),
|
||||
> notifications.active.list backed by the existing NotificationListenerService,
|
||||
> and a build-flag (`IS_SIDELOAD`)
|
||||
> that gates Accessibility + SMS for a Play-track build.
|
||||
>
|
||||
> **Wear OS** uses its own per-watch backend URL + auth token. Point the
|
||||
|
||||
@@ -80,6 +80,8 @@ import com.openclaw.assistant.ui.chat.ChatMessage
|
||||
import com.openclaw.assistant.gateway.AgentInfo
|
||||
import com.openclaw.assistant.backend.BackendRepository
|
||||
import com.openclaw.assistant.backend.BackendType
|
||||
import com.openclaw.assistant.backend.HermesConfigApi
|
||||
import com.openclaw.assistant.backend.HermesModelOption
|
||||
import com.openclaw.assistant.ui.theme.OpenClawAssistantTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -874,7 +876,7 @@ fun PendingToolsIndicator(toolCalls: List<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ChatSettingsDialog(
|
||||
uiState: ChatUiState,
|
||||
@@ -890,8 +892,11 @@ fun ChatSettingsDialog(
|
||||
var pendingSelectedId by remember(selectedId) { mutableStateOf(selectedId) }
|
||||
val selectedBackend = enabledBackends.firstOrNull { it.id == pendingSelectedId } ?: primary
|
||||
var modelName by remember(selectedBackend?.id, selectedBackend?.modelName) {
|
||||
mutableStateOf(selectedBackend?.modelName?.ifBlank { null } ?: "default")
|
||||
mutableStateOf(selectedBackend?.modelName?.ifBlank { null } ?: defaultModelFor(selectedBackend?.type))
|
||||
}
|
||||
var modelStatus by remember(selectedBackend?.id) { mutableStateOf<String?>(null) }
|
||||
var hermesModels by remember(selectedBackend?.id) { mutableStateOf<List<HermesModelOption>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -940,16 +945,83 @@ fun ChatSettingsDialog(
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedBackend?.type == BackendType.HERMES_API_SERVER) {
|
||||
if (selectedBackend != null) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = when (selectedBackend.type) {
|
||||
BackendType.HERMES_API_SERVER -> "Hermes Model"
|
||||
BackendType.OPENCLAW_GATEWAY -> "OpenClaw Model"
|
||||
BackendType.OPENCLAW_HTTP -> "OpenClaw API Model"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = modelName,
|
||||
onValueChange = { modelName = it },
|
||||
label = { Text(stringResource(R.string.chat_settings_hermes_model)) },
|
||||
supportingText = { Text(stringResource(R.string.chat_settings_hermes_model_help)) },
|
||||
label = { Text("Model") },
|
||||
supportingText = {
|
||||
Text(
|
||||
when (selectedBackend.type) {
|
||||
BackendType.HERMES_API_SERVER -> "Saved for chat and can also be applied to Hermes."
|
||||
BackendType.OPENCLAW_GATEWAY -> "Sent with gateway chat requests when supported."
|
||||
BackendType.OPENCLAW_HTTP -> "Sent as the OpenAI-compatible model field."
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (selectedBackend.type == BackendType.HERMES_API_SERVER) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val target = selectedBackend.copy(modelName = modelName.trim().ifBlank { "default" })
|
||||
scope.launch {
|
||||
modelStatus = "Loading models..."
|
||||
runCatching { HermesConfigApi().fetchCatalog(target) }
|
||||
.onSuccess { catalog ->
|
||||
hermesModels = catalog.models
|
||||
catalog.config?.model?.takeIf { it.isNotBlank() }?.let { modelName = it }
|
||||
modelStatus = "Loaded ${catalog.models.size} model${if (catalog.models.size == 1) "" else "s"}"
|
||||
}
|
||||
.onFailure { modelStatus = "Could not load models: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
},
|
||||
) { Text("Load Models") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val target = selectedBackend.copy(modelName = modelName.trim().ifBlank { "default" })
|
||||
scope.launch {
|
||||
modelStatus = "Applying to Hermes..."
|
||||
runCatching { HermesConfigApi().updateModel(target, modelName) }
|
||||
.onSuccess { state ->
|
||||
val saved = target.copy(modelName = state.model ?: modelName.trim().ifBlank { "default" })
|
||||
repo.upsert(saved)
|
||||
modelName = saved.modelName ?: "default"
|
||||
modelStatus = "Hermes model updated: ${saved.modelName}"
|
||||
}
|
||||
.onFailure { modelStatus = "Could not update Hermes: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
},
|
||||
enabled = modelName.isNotBlank(),
|
||||
) { Text("Apply to Hermes") }
|
||||
}
|
||||
if (hermesModels.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
hermesModels.take(8).forEach { option ->
|
||||
AssistChip(
|
||||
onClick = { modelName = option.id },
|
||||
label = { Text(option.id, style = MaterialTheme.typography.labelSmall) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
modelStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -958,8 +1030,8 @@ fun ChatSettingsDialog(
|
||||
onClick = {
|
||||
ChatBackendTarget.set(pendingSelectedId)
|
||||
val target = selectedBackend
|
||||
if (target?.type == BackendType.HERMES_API_SERVER) {
|
||||
repo.upsert(target.copy(modelName = modelName.trim().ifBlank { "default" }))
|
||||
if (target != null) {
|
||||
repo.upsert(target.copy(modelName = modelName.trim().ifBlank { defaultModelFor(target.type) }))
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
@@ -975,6 +1047,13 @@ fun ChatSettingsDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultModelFor(type: BackendType?): String = when (type) {
|
||||
BackendType.OPENCLAW_GATEWAY,
|
||||
BackendType.OPENCLAW_HTTP -> "openclaw"
|
||||
BackendType.HERMES_API_SERVER,
|
||||
null -> "default"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatTargetRadioRow(
|
||||
selected: Boolean,
|
||||
|
||||
@@ -183,7 +183,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>, modelName: String? = null) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments, modelName = modelName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import androidx.core.os.LocaleListCompat
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import com.openclaw.assistant.backend.BackendMigration
|
||||
import com.openclaw.assistant.backend.AgentDiagnostics
|
||||
import com.openclaw.assistant.bridge.BridgeActivityLog
|
||||
import com.openclaw.assistant.bridge.WakeLockManager
|
||||
import com.openclaw.assistant.data.SettingsRepository
|
||||
import com.openclaw.assistant.node.NodeRuntime
|
||||
import java.security.Security
|
||||
@@ -68,6 +71,9 @@ class OpenClawApplication : Application() {
|
||||
} catch (e: Throwable) {
|
||||
Log.w("OpenClawApp", "Backend migration skipped: ${e.message}")
|
||||
}
|
||||
AgentDiagnostics.initialize(this)
|
||||
BridgeActivityLog.initialize(this)
|
||||
WakeLockManager.initialize(this)
|
||||
}
|
||||
|
||||
private fun applySavedAppLocale() {
|
||||
|
||||
@@ -43,6 +43,7 @@ class OpenClawClient() {
|
||||
sessionId: String,
|
||||
authToken: String? = null,
|
||||
agentId: String? = null,
|
||||
modelName: String? = null,
|
||||
attachments: List<Pair<String, String>> = emptyList()
|
||||
): Result<OpenClawResponse> = withContext(Dispatchers.IO) {
|
||||
if (httpUrl.isBlank()) {
|
||||
@@ -62,7 +63,7 @@ class OpenClawClient() {
|
||||
try {
|
||||
// OpenAI Chat Completions format for /v1/chat/completions
|
||||
val requestBody = JsonObject().apply {
|
||||
addProperty("model", "openclaw")
|
||||
addProperty("model", modelName?.trim()?.takeIf { it.isNotBlank() } ?: "openclaw")
|
||||
addProperty("user", sessionId)
|
||||
val messagesArray = JsonArray()
|
||||
val userMessage = JsonObject().apply {
|
||||
|
||||
@@ -24,9 +24,8 @@ data class AgentBackendConfig(
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* Additional endpoints to race alongside [baseUrl] at connect time.
|
||||
* Inspired by Hermes-Relay's "LAN + Tailscale + public URLs" model: the
|
||||
* client tries all candidates in parallel on every connect and on every
|
||||
* network change, using whichever responds first.
|
||||
* Additional routes such as LAN, VPN, and public URLs. The client tries all
|
||||
* candidates in parallel on connect and uses the first reachable route.
|
||||
*/
|
||||
val secondaryUrls: List<String> = emptyList(),
|
||||
/**
|
||||
@@ -36,6 +35,12 @@ data class AgentBackendConfig(
|
||||
*/
|
||||
val terminalUrl: String? = null,
|
||||
val terminalSessionToken: String? = null,
|
||||
/** Optional cross-backend agent/profile label selected by the user. */
|
||||
val agentContextName: String? = null,
|
||||
/** Optional model/personality/profile hint shown in shared Agent Context UI. */
|
||||
val agentContextDetail: String? = null,
|
||||
/** Optional preferred endpoint role, such as lan, vpn, or public. */
|
||||
val preferredEndpointRole: String? = null,
|
||||
) {
|
||||
val hermesMode: HermesMode
|
||||
get() = if (useRunsApi) HermesMode.RUNS_API else HermesMode.CHAT_COMPLETIONS
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.openclaw.assistant.backend
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class AgentContextInspection(
|
||||
val contextName: String?,
|
||||
val contextDetail: String?,
|
||||
val summary: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Best-effort read-only inspector for agent metadata exposed by Hermes-like
|
||||
* API servers. Every endpoint is optional; unsupported servers simply return a
|
||||
* short "not available" summary.
|
||||
*/
|
||||
class AgentContextInspector(
|
||||
private val httpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true; isLenient = true },
|
||||
) {
|
||||
suspend fun inspect(config: AgentBackendConfig): AgentContextInspection = withContext(Dispatchers.IO) {
|
||||
val baseUrl = config.baseUrl?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return@withContext AgentContextInspection(null, null, "No base URL configured")
|
||||
val apiBase = HermesUrl.apiBase(baseUrl)
|
||||
val headers = config.apiKeyOrToken?.takeIf { it.isNotBlank() }?.let { mapOf("Authorization" to "Bearer $it") }.orEmpty()
|
||||
val profiles = getJson("$apiBase/api/profiles", headers)
|
||||
val configJson = getJson("$apiBase/api/config", headers)
|
||||
val skillsJson = getJson("$apiBase/api/skills", headers)
|
||||
val soulJson = getJson("$apiBase/api/profiles/default/soul", headers)
|
||||
val memoryJson = getJson("$apiBase/api/profiles/default/memory", headers)
|
||||
|
||||
val profileName = firstProfileName(profiles)
|
||||
?: config.agentContextName
|
||||
?: config.modelName
|
||||
val model = firstProfileModel(profiles) ?: config.modelName
|
||||
val personality = configJson?.let(::defaultPersonality)
|
||||
val skillsCount = countArray(skillsJson, "skills")
|
||||
val memoryCount = countArray(memoryJson, "entries")
|
||||
val soulState = soulJson?.jsonObject?.get("exists")?.jsonPrimitive?.contentOrNull
|
||||
val detailParts = listOfNotNull(
|
||||
model?.takeIf { it.isNotBlank() }?.let { "model: $it" },
|
||||
personality?.takeIf { it.isNotBlank() }?.let { "personality: $it" },
|
||||
skillsCount?.let { "skills: $it" },
|
||||
memoryCount?.let { "memory: $it" },
|
||||
soulState?.let { "SOUL: $it" },
|
||||
)
|
||||
AgentContextInspection(
|
||||
contextName = profileName?.takeIf { it.isNotBlank() },
|
||||
contextDetail = detailParts.joinToString(" · ").ifBlank { null },
|
||||
summary = if (profiles == null && configJson == null && skillsJson == null && soulJson == null && memoryJson == null) {
|
||||
"No optional agent metadata endpoints responded. Manual context fields can still be used."
|
||||
} else {
|
||||
detailParts.joinToString("\n").ifBlank { "Agent metadata endpoint responded, but no displayable fields were found." }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getJson(url: String, headers: Map<String, String>): JsonObject? = runCatching {
|
||||
val builder = Request.Builder().url(url).get()
|
||||
headers.forEach { (k, v) -> builder.header(k, v) }
|
||||
httpClient.newCall(builder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return@runCatching null
|
||||
val body = response.body?.string()?.takeIf { it.isNotBlank() } ?: return@runCatching null
|
||||
json.parseToJsonElement(body).jsonObject
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun firstProfileName(obj: JsonObject?): String? {
|
||||
val array = obj?.get("profiles") as? JsonArray ?: obj?.get("items") as? JsonArray ?: return null
|
||||
return array.firstOrNull()?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
|
||||
private fun firstProfileModel(obj: JsonObject?): String? {
|
||||
val array = obj?.get("profiles") as? JsonArray ?: obj?.get("items") as? JsonArray ?: return null
|
||||
return array.firstOrNull()?.jsonObject?.get("model")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
|
||||
private fun defaultPersonality(obj: JsonObject): String? {
|
||||
val display = obj["display"]?.jsonObject
|
||||
return display?.get("personality")?.jsonPrimitive?.contentOrNull
|
||||
?: obj["personality"]?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
|
||||
private fun countArray(obj: JsonObject?, key: String): Int? =
|
||||
obj?.get(key)?.jsonArray?.size
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.openclaw.assistant.backend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
@Serializable
|
||||
data class AgentDiagnosticSnapshot(
|
||||
val backendId: String,
|
||||
val backendName: String,
|
||||
val backendType: String,
|
||||
val totalMessages: Int = 0,
|
||||
val streamsCompleted: Int = 0,
|
||||
val streamsErrored: Int = 0,
|
||||
val streamsCancelled: Int = 0,
|
||||
val totalInputChars: Long = 0,
|
||||
val totalOutputChars: Long = 0,
|
||||
val averageTimeToFirstTokenMs: Long = 0,
|
||||
val averageCompletionMs: Long = 0,
|
||||
val lastHealthOk: Boolean? = null,
|
||||
val lastHealthLatencyMs: Long? = null,
|
||||
val lastError: String? = null,
|
||||
val updatedAtMs: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Small local-only diagnostics store shared by Hermes and OpenClaw backends.
|
||||
* It stores counts/status only; never message text, screen text, tokens, or
|
||||
* profile document contents.
|
||||
*/
|
||||
object AgentDiagnostics {
|
||||
private const val PREFS_NAME = "openclaw.agent.diagnostics"
|
||||
private const val KEY_SNAPSHOTS = "snapshots.v1"
|
||||
|
||||
private val _snapshots = MutableStateFlow<List<AgentDiagnosticSnapshot>>(emptyList())
|
||||
val snapshots: StateFlow<List<AgentDiagnosticSnapshot>> = _snapshots.asStateFlow()
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Volatile private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
_snapshots.value = decode(prefs?.getString(KEY_SNAPSHOTS, null).orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun recordHealth(context: Context, backend: AgentBackendConfig, ok: Boolean, latencyMs: Long?, error: String? = null) {
|
||||
initialize(context)
|
||||
update(backend) { current ->
|
||||
current.copy(
|
||||
lastHealthOk = ok,
|
||||
lastHealthLatencyMs = latencyMs,
|
||||
lastError = error?.take(160),
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun beginMessage(context: Context, backend: AgentBackendConfig, inputChars: Int): ActiveRun {
|
||||
initialize(context)
|
||||
return ActiveRun(context.applicationContext, backend, inputChars.coerceAtLeast(0), System.currentTimeMillis())
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
initialize(context)
|
||||
_snapshots.value = emptyList()
|
||||
prefs?.edit { remove(KEY_SNAPSHOTS) }
|
||||
}
|
||||
|
||||
class ActiveRun internal constructor(
|
||||
private val context: Context,
|
||||
private val backend: AgentBackendConfig,
|
||||
private val inputChars: Int,
|
||||
private val startedAtMs: Long,
|
||||
) {
|
||||
private var firstTokenAtMs: Long? = null
|
||||
private var outputChars: Int = 0
|
||||
|
||||
fun onToken(chars: Int) {
|
||||
if (firstTokenAtMs == null) firstTokenAtMs = System.currentTimeMillis()
|
||||
outputChars += chars.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
fun complete(finalOutputChars: Int? = null) {
|
||||
val now = System.currentTimeMillis()
|
||||
val ttft = (firstTokenAtMs ?: now) - startedAtMs
|
||||
val completion = now - startedAtMs
|
||||
val out = (finalOutputChars ?: outputChars).coerceAtLeast(0)
|
||||
initialize(context)
|
||||
update(backend) { current ->
|
||||
val newMessages = current.totalMessages + 1
|
||||
val newCompleted = current.streamsCompleted + 1
|
||||
current.copy(
|
||||
totalMessages = newMessages,
|
||||
streamsCompleted = newCompleted,
|
||||
totalInputChars = current.totalInputChars + inputChars,
|
||||
totalOutputChars = current.totalOutputChars + out,
|
||||
averageTimeToFirstTokenMs = rollingAverage(current.averageTimeToFirstTokenMs, current.streamsCompleted, ttft),
|
||||
averageCompletionMs = rollingAverage(current.averageCompletionMs, current.streamsCompleted, completion),
|
||||
lastError = null,
|
||||
updatedAtMs = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun error(message: String?) {
|
||||
initialize(context)
|
||||
update(backend) { current ->
|
||||
current.copy(
|
||||
streamsErrored = current.streamsErrored + 1,
|
||||
lastError = message?.take(160),
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelled() {
|
||||
initialize(context)
|
||||
update(backend) { current ->
|
||||
current.copy(
|
||||
streamsCancelled = current.streamsCancelled + 1,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollingAverage(previousAverage: Long, previousCount: Int, next: Long): Long {
|
||||
val count = previousCount.coerceAtLeast(0)
|
||||
return ((previousAverage * count) + next) / (count + 1)
|
||||
}
|
||||
|
||||
private fun update(backend: AgentBackendConfig, block: (AgentDiagnosticSnapshot) -> AgentDiagnosticSnapshot) {
|
||||
val current = _snapshots.value
|
||||
val index = current.indexOfFirst { it.backendId == backend.id }
|
||||
val base = if (index >= 0) {
|
||||
current[index]
|
||||
} else {
|
||||
AgentDiagnosticSnapshot(
|
||||
backendId = backend.id,
|
||||
backendName = backend.displayName,
|
||||
backendType = backend.type.name,
|
||||
)
|
||||
}
|
||||
val updated = block(base.copy(backendName = backend.displayName, backendType = backend.type.name))
|
||||
val next = if (index >= 0) current.toMutableList().also { it[index] = updated } else current + updated
|
||||
_snapshots.value = next.sortedByDescending { it.updatedAtMs }
|
||||
prefs?.edit { putString(KEY_SNAPSHOTS, encode(_snapshots.value)) }
|
||||
}
|
||||
|
||||
private fun encode(items: List<AgentDiagnosticSnapshot>): String =
|
||||
json.encodeToString(ListSerializer(AgentDiagnosticSnapshot.serializer()), items)
|
||||
|
||||
private fun decode(raw: String): List<AgentDiagnosticSnapshot> = runCatching {
|
||||
json.decodeFromString(ListSerializer(AgentDiagnosticSnapshot.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.openclaw.assistant.backend
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesModelOption(
|
||||
val id: String,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
data class HermesConfigState(
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val apiMode: String?,
|
||||
val baseUrl: String?,
|
||||
)
|
||||
|
||||
data class HermesModelCatalog(
|
||||
val config: HermesConfigState?,
|
||||
val models: List<HermesModelOption>,
|
||||
val providers: List<String>,
|
||||
)
|
||||
|
||||
class HermesConfigApi(
|
||||
private val httpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true; isLenient = true },
|
||||
) {
|
||||
suspend fun fetchCatalog(config: AgentBackendConfig): HermesModelCatalog = withContext(Dispatchers.IO) {
|
||||
val current = fetchConfig(config)
|
||||
val catalog = getJson(config, HermesUrl.availableModelsUrl(requireBaseUrl(config)))
|
||||
HermesModelCatalog(
|
||||
config = current,
|
||||
models = parseModels(catalog),
|
||||
providers = parseProviders(catalog),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchConfig(config: AgentBackendConfig): HermesConfigState? = withContext(Dispatchers.IO) {
|
||||
getJson(config, HermesUrl.configUrl(requireBaseUrl(config)))?.let(::parseConfig)
|
||||
}
|
||||
|
||||
suspend fun updateModel(config: AgentBackendConfig, model: String): HermesConfigState = withContext(Dispatchers.IO) {
|
||||
val body = buildJsonObject { put("model", model.trim()) }.toString().toRequestBody(JSON_MEDIA)
|
||||
val request = authed(config, Request.Builder().url(HermesUrl.configUrl(requireBaseUrl(config))).patch(body)).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("HTTP ${response.code}: ${extractError(text).ifBlank { response.message }}")
|
||||
}
|
||||
parseConfig(json.parseToJsonElement(text).jsonObject)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJson(config: AgentBackendConfig, url: String): JsonObject? {
|
||||
val request = authed(config, Request.Builder().url(url).get()).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val text = response.body?.string()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return json.parseToJsonElement(text).jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
private fun authed(config: AgentBackendConfig, builder: Request.Builder): Request.Builder {
|
||||
config.apiKeyOrToken?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") }
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun requireBaseUrl(config: AgentBackendConfig): String =
|
||||
config.baseUrl?.takeIf { it.isNotBlank() } ?: throw IllegalStateException("Hermes backend has no base URL")
|
||||
|
||||
private fun parseConfig(obj: JsonObject): HermesConfigState = HermesConfigState(
|
||||
model = obj["model"]?.jsonPrimitive?.contentOrNull,
|
||||
provider = obj["provider"]?.jsonPrimitive?.contentOrNull,
|
||||
apiMode = obj["api_mode"]?.jsonPrimitive?.contentOrNull,
|
||||
baseUrl = obj["base_url"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
|
||||
private fun parseModels(obj: JsonObject?): List<HermesModelOption> {
|
||||
val array = obj?.get("models") as? JsonArray ?: return emptyList()
|
||||
return array.mapNotNull { item ->
|
||||
when (val value = item) {
|
||||
is JsonPrimitive -> value.contentOrNull?.let { HermesModelOption(it) }
|
||||
is JsonObject -> {
|
||||
val id = value["id"]?.jsonPrimitive?.contentOrNull ?: value["model"]?.jsonPrimitive?.contentOrNull
|
||||
id?.takeIf { it.isNotBlank() }?.let {
|
||||
HermesModelOption(
|
||||
id = it,
|
||||
description = value["description"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProviders(obj: JsonObject?): List<String> {
|
||||
val array = obj?.get("providers")?.jsonArray ?: return emptyList()
|
||||
return array.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
|
||||
}
|
||||
|
||||
private fun extractError(text: String): String = runCatching {
|
||||
val obj = json.parseToJsonElement(text).jsonObject
|
||||
obj["error"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
}.getOrDefault(text).take(300)
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,8 @@ import java.util.concurrent.TimeUnit
|
||||
* in parallel and returns the first one to respond successfully to
|
||||
* `GET /v1/models` (with `/health` fallback).
|
||||
*
|
||||
* Modelled after Hermes-Relay's "LAN + Tailscale + public URLs" pattern: every
|
||||
* connect picks the lowest-latency reachable endpoint, so the same paired
|
||||
* backend works at home, on a train, or behind a VPN without reconfiguration.
|
||||
* Races LAN, VPN, and public URLs so the same configured backend works at
|
||||
* home, on a train, or behind a VPN without manual reconfiguration.
|
||||
*
|
||||
* The racer never falls back to a non-2xx endpoint — auth failure on the
|
||||
* fastest endpoint is still preferred over silently using a slow stale one.
|
||||
|
||||
@@ -24,6 +24,9 @@ internal object HermesUrl {
|
||||
|
||||
fun chatCompletionsUrl(base: String) = "${normalizeBase(base)}/chat/completions"
|
||||
fun modelsUrl(base: String) = "${normalizeBase(base)}/models"
|
||||
fun apiBase(base: String) = normalizeBase(base).removeSuffix("/v1")
|
||||
fun configUrl(base: String) = "${apiBase(base)}/api/config"
|
||||
fun availableModelsUrl(base: String) = "${apiBase(base)}/api/available-models"
|
||||
fun healthUrl(base: String): String {
|
||||
val n = normalizeBase(base)
|
||||
return "$n/health"
|
||||
|
||||
@@ -66,6 +66,7 @@ class OpenClawHttpAdapter(override val config: AgentBackendConfig) : AgentClient
|
||||
sessionId = options.sessionId ?: "agent-voice",
|
||||
authToken = config.apiKeyOrToken?.takeIf { it.isNotBlank() },
|
||||
agentId = options.extra["agentId"]?.takeIf { it.isNotBlank() },
|
||||
modelName = config.modelName?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
|
||||
@@ -40,7 +40,7 @@ object PrimaryBackendDispatcher {
|
||||
} ?: return null
|
||||
return when (target.type) {
|
||||
BackendType.HERMES_API_SERVER,
|
||||
BackendType.OPENCLAW_HTTP -> sendViaAgentClient(target, userText, sessionId, agentId)
|
||||
BackendType.OPENCLAW_HTTP -> sendViaAgentClient(context, target, userText, sessionId, agentId)
|
||||
BackendType.OPENCLAW_GATEWAY -> sendViaGateway(context, target, userText)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ object PrimaryBackendDispatcher {
|
||||
): Reply? = sendPrimary(context, userText)
|
||||
|
||||
private suspend fun sendViaAgentClient(
|
||||
context: Context,
|
||||
target: AgentBackendConfig,
|
||||
userText: String,
|
||||
sessionId: String?,
|
||||
@@ -69,24 +70,40 @@ object PrimaryBackendDispatcher {
|
||||
): Reply {
|
||||
val client = AgentClientFactory.create(target)
|
||||
val collected = StringBuilder()
|
||||
client.sendMessage(
|
||||
messages = listOf(AgentMessage.user(userText)),
|
||||
options = AgentSendOptions(
|
||||
sessionId = sessionId,
|
||||
stream = target.useStreaming,
|
||||
extra = mapOf("agentId" to agentId.orEmpty()),
|
||||
),
|
||||
).collect { event ->
|
||||
when (event) {
|
||||
is AgentEvent.TokenDelta -> collected.append(event.text)
|
||||
is AgentEvent.MessageDelta -> collected.append(event.text)
|
||||
is AgentEvent.Completed -> {
|
||||
if (collected.isEmpty()) collected.append(event.finalText)
|
||||
val run = AgentDiagnostics.beginMessage(context, target, userText.length)
|
||||
try {
|
||||
client.sendMessage(
|
||||
messages = listOf(AgentMessage.user(userText)),
|
||||
options = AgentSendOptions(
|
||||
sessionId = sessionId,
|
||||
stream = target.useStreaming,
|
||||
extra = mapOf("agentId" to agentId.orEmpty()),
|
||||
),
|
||||
).collect { event ->
|
||||
when (event) {
|
||||
is AgentEvent.TokenDelta -> {
|
||||
collected.append(event.text)
|
||||
run.onToken(event.text.length)
|
||||
}
|
||||
is AgentEvent.MessageDelta -> {
|
||||
collected.append(event.text)
|
||||
run.onToken(event.text.length)
|
||||
}
|
||||
is AgentEvent.Completed -> {
|
||||
if (collected.isEmpty()) collected.append(event.finalText)
|
||||
run.complete(collected.length)
|
||||
}
|
||||
is AgentEvent.ToolProgress -> com.openclaw.assistant.ui.backend.ToolProgressFeed.push(event)
|
||||
is AgentEvent.Error -> {
|
||||
run.error(event.message)
|
||||
throw RuntimeException("${target.displayName} error: ${event.message}", event.cause)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
is AgentEvent.ToolProgress -> com.openclaw.assistant.ui.backend.ToolProgressFeed.push(event)
|
||||
is AgentEvent.Error -> throw RuntimeException("Hermes error: ${event.message}", event.cause)
|
||||
else -> Unit
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
run.error(e.message ?: e.javaClass.simpleName)
|
||||
throw e
|
||||
}
|
||||
if (collected.isBlank()) {
|
||||
throw IllegalStateException("${target.displayName} returned an empty response. Check the backend model/provider configuration.")
|
||||
@@ -104,8 +121,14 @@ object PrimaryBackendDispatcher {
|
||||
throw IllegalStateException("OpenClaw Gateway is not connected")
|
||||
}
|
||||
|
||||
val run = AgentDiagnostics.beginMessage(context, target, userText.length)
|
||||
val assistantCountBefore = runtime.chatMessages.value.count { it.role == "assistant" }
|
||||
runtime.sendChat(message = userText, thinking = "low", attachments = emptyList())
|
||||
runtime.sendChat(
|
||||
message = userText,
|
||||
thinking = "low",
|
||||
attachments = emptyList(),
|
||||
modelName = target.modelName?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
val responseText: String? = try {
|
||||
withTimeout<String>(60_000L) {
|
||||
@@ -131,10 +154,13 @@ object PrimaryBackendDispatcher {
|
||||
}
|
||||
|
||||
if (responseText.isNullOrBlank()) {
|
||||
run.error("No reply before timeout")
|
||||
throw IllegalStateException(
|
||||
"OpenClaw Gateway accepted the message, but the agent did not return a reply. Check the host OpenClaw agent/model authentication."
|
||||
)
|
||||
}
|
||||
run.onToken(responseText.length)
|
||||
run.complete(responseText.length)
|
||||
return Reply(text = responseText, sourceDisplayName = target.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.security.MessageDigest
|
||||
* that host MUST match.
|
||||
*
|
||||
* This is a defence against MITM on a paired backend's public URL — the
|
||||
* exact threat hermes-relay calls out with "TOFU cert pinning".
|
||||
* exact threat handled with TOFU cert pinning.
|
||||
*
|
||||
* Pin format follows [CertificatePinner.pin] — `sha256/<base64-encoded-hash>`.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,11 @@ import android.content.Context
|
||||
import com.openclaw.assistant.BuildConfig
|
||||
import com.openclaw.assistant.bridge.accessibility.AgentVoiceAccessibilityService
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@@ -54,6 +58,133 @@ object ScreenSwipeCapability : BridgeCapability {
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenLongPressCapability : BridgeCapability {
|
||||
override val name = "screen.long_press"
|
||||
override val description = "Long-press a point on the screen"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.MEDIUM
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val x = arguments["x"]?.jsonPrimitive?.content?.toFloatOrNull()
|
||||
val y = arguments["y"]?.jsonPrimitive?.content?.toFloatOrNull()
|
||||
val ms = arguments["durationMs"]?.jsonPrimitive?.content?.toLongOrNull() ?: 600L
|
||||
if (x == null || y == null) return buildJsonObject { put("ok", false); put("reason", "x,y required") }
|
||||
val ok = AgentVoiceAccessibilityService.get()?.performLongPress(x, y, ms) ?: false
|
||||
return buildJsonObject { put("ok", ok) }
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenDragCapability : BridgeCapability {
|
||||
override val name = "screen.drag"
|
||||
override val description = "Drag from (x1,y1) to (x2,y2)"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.MEDIUM
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject =
|
||||
ScreenSwipeCapability.execute(context, arguments)
|
||||
}
|
||||
|
||||
object ScreenFindNodesCapability : BridgeCapability {
|
||||
override val name = "screen.find_nodes"
|
||||
override val description = "Find visible accessibility nodes by text, class, or clickable state"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.LOW
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val nodes = AgentVoiceAccessibilityService.get()?.findNodes(
|
||||
text = arguments["text"]?.jsonPrimitive?.content,
|
||||
className = arguments["className"]?.jsonPrimitive?.content,
|
||||
clickable = arguments["clickable"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(),
|
||||
limit = arguments["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 20,
|
||||
).orEmpty()
|
||||
return buildJsonObject {
|
||||
put("nodes", buildJsonArray {
|
||||
nodes.forEach { node ->
|
||||
add(buildJsonObject {
|
||||
put("nodeId", node.nodeId)
|
||||
put("text", node.text ?: "")
|
||||
put("contentDescription", node.contentDescription ?: "")
|
||||
put("className", node.className ?: "")
|
||||
put("viewId", node.viewId ?: "")
|
||||
put("bounds", node.bounds.toJson())
|
||||
put("clickable", node.clickable)
|
||||
put("longClickable", node.longClickable)
|
||||
put("scrollable", node.scrollable)
|
||||
put("editable", node.editable)
|
||||
put("enabled", node.enabled)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenDescribeNodeCapability : BridgeCapability {
|
||||
override val name = "screen.describe_node"
|
||||
override val description = "Describe one visible accessibility node by nodeId"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.LOW
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val nodeId = arguments["nodeId"]?.jsonPrimitive?.content?.trim().orEmpty()
|
||||
val node = AgentVoiceAccessibilityService.get()?.describeNode(nodeId)
|
||||
return if (node == null) {
|
||||
buildJsonObject { put("found", false); put("nodeId", nodeId) }
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("found", true)
|
||||
put("nodeId", node.nodeId)
|
||||
put("text", node.text ?: "")
|
||||
put("contentDescription", node.contentDescription ?: "")
|
||||
put("className", node.className ?: "")
|
||||
put("viewId", node.viewId ?: "")
|
||||
put("bounds", node.bounds.toJson())
|
||||
put("clickable", node.clickable)
|
||||
put("longClickable", node.longClickable)
|
||||
put("scrollable", node.scrollable)
|
||||
put("editable", node.editable)
|
||||
put("enabled", node.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenHashCapability : BridgeCapability {
|
||||
override val name = "screen.hash"
|
||||
override val description = "Return a stable hash of visible accessibility content"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.LOW
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val hash = AgentVoiceAccessibilityService.get()?.screenHash()
|
||||
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
|
||||
return buildJsonObject {
|
||||
put("hash", hash.hash)
|
||||
put("nodeCount", hash.nodeCount)
|
||||
put("truncated", hash.truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenDiffCapability : BridgeCapability {
|
||||
override val name = "screen.diff"
|
||||
override val description = "Compare the current screen hash with a previous hash"
|
||||
override val group = "accessibility"
|
||||
override val riskLevel = RiskLevel.LOW
|
||||
override fun isAvailable(context: Context) = a11yAvailable()
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val previous = arguments["previousHash"]?.jsonPrimitive?.content.orEmpty()
|
||||
val hash = AgentVoiceAccessibilityService.get()?.screenHash()
|
||||
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
|
||||
return buildJsonObject {
|
||||
put("changed", hash.hash != previous)
|
||||
put("hash", hash.hash)
|
||||
put("nodeCount", hash.nodeCount)
|
||||
put("truncated", hash.truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ScreenHomeCapability : BridgeCapability {
|
||||
override val name = "screen.home"
|
||||
override val description = "Press the Home key via Accessibility Bridge"
|
||||
@@ -87,20 +218,34 @@ object ScreenWindowDescribeCapability : BridgeCapability {
|
||||
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
|
||||
val svc = AgentVoiceAccessibilityService.get()
|
||||
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
|
||||
val root = svc.rootInActiveWindow
|
||||
val snapshot = svc.readScreenSnapshot()
|
||||
val first = snapshot.nodes.firstOrNull()
|
||||
return buildJsonObject {
|
||||
put("ok", root != null)
|
||||
put("packageName", root?.packageName?.toString() ?: "")
|
||||
put("contentDescription", root?.contentDescription?.toString() ?: "")
|
||||
put("text", root?.text?.toString() ?: "")
|
||||
put("ok", true)
|
||||
put("packageName", snapshot.packageName ?: "")
|
||||
put("contentDescription", first?.contentDescription ?: "")
|
||||
put("text", first?.text ?: "")
|
||||
put("nodeCount", snapshot.nodes.size)
|
||||
put("truncated", snapshot.truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object A11yCapabilities {
|
||||
val all: List<BridgeCapability> = listOf(
|
||||
ScreenTapCapability, ScreenSwipeCapability,
|
||||
ScreenTapCapability, ScreenSwipeCapability, ScreenLongPressCapability, ScreenDragCapability,
|
||||
ScreenHomeCapability, ScreenBackCapability,
|
||||
ScreenWindowDescribeCapability,
|
||||
ScreenWindowDescribeCapability, ScreenFindNodesCapability, ScreenDescribeNodeCapability,
|
||||
ScreenHashCapability, ScreenDiffCapability,
|
||||
)
|
||||
}
|
||||
|
||||
private fun com.openclaw.assistant.bridge.accessibility.BoundsSnapshot.toJson(): JsonObject =
|
||||
buildJsonObject {
|
||||
put("left", left)
|
||||
put("top", top)
|
||||
put("right", right)
|
||||
put("bottom", bottom)
|
||||
put("centerX", centerX)
|
||||
put("centerY", centerY)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.openclaw.assistant.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
@Serializable
|
||||
data class BridgeActivityEntry(
|
||||
val id: String,
|
||||
val capability: String,
|
||||
val riskLevel: String,
|
||||
val status: String,
|
||||
val message: String?,
|
||||
val timestampMs: Long,
|
||||
)
|
||||
|
||||
object BridgeActivityLog {
|
||||
private const val PREFS_NAME = "openclaw.bridge.activity"
|
||||
private const val KEY_ENTRIES = "entries.v1"
|
||||
private const val MAX_ENTRIES = 100
|
||||
|
||||
private val _entries = MutableStateFlow<List<BridgeActivityEntry>>(emptyList())
|
||||
val entries: StateFlow<List<BridgeActivityEntry>> = _entries.asStateFlow()
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
@Volatile private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
_entries.value = decode(prefs?.getString(KEY_ENTRIES, null).orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun record(context: Context, capability: String, riskLevel: RiskLevel?, status: String, message: String? = null) {
|
||||
initialize(context)
|
||||
val entry = BridgeActivityEntry(
|
||||
id = "${System.currentTimeMillis()}-$capability",
|
||||
capability = capability,
|
||||
riskLevel = riskLevel?.name?.lowercase().orEmpty(),
|
||||
status = status,
|
||||
message = message?.take(180),
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
val next = (listOf(entry) + _entries.value).take(MAX_ENTRIES)
|
||||
_entries.value = next
|
||||
prefs?.edit { putString(KEY_ENTRIES, encode(next)) }
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
initialize(context)
|
||||
_entries.value = emptyList()
|
||||
prefs?.edit { remove(KEY_ENTRIES) }
|
||||
}
|
||||
|
||||
private fun encode(entries: List<BridgeActivityEntry>): String =
|
||||
json.encodeToString(ListSerializer(BridgeActivityEntry.serializer()), entries)
|
||||
|
||||
private fun decode(raw: String): List<BridgeActivityEntry> = runCatching {
|
||||
json.decodeFromString(ListSerializer(BridgeActivityEntry.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Media / system capabilities that round out the Hermes-Relay tool surface
|
||||
* Media / system capabilities that round out the WakeHermesClaw tool surface
|
||||
* count to ~18. Each one is risk-tiered and reports `isAvailable` honestly
|
||||
* based on the runtime permissions it actually holds.
|
||||
*/
|
||||
|
||||
@@ -39,9 +39,8 @@ class MobileBridgeConfig internal constructor(private val prefs: SharedPreferenc
|
||||
|
||||
/**
|
||||
* Package names that `apps.launch` will refuse to start. Mirrors the
|
||||
* Hermes-Relay "per-app blocklist" concept: a user can shield banking,
|
||||
* password, or work-profile apps even when `apps.launch` is otherwise
|
||||
* granted.
|
||||
* Per-app blocklist: a user can shield banking, password, or work-profile
|
||||
* apps even when `apps.launch` is otherwise granted.
|
||||
*/
|
||||
private val _packageBlocklist = MutableStateFlow(loadBlocklist())
|
||||
val packageBlocklist: StateFlow<Set<String>> = _packageBlocklist.asStateFlow()
|
||||
|
||||
@@ -228,18 +228,21 @@ open class MobileBridgeServer(
|
||||
val allowed = config.allowedCapabilityGroups.value
|
||||
val cap = registry.byName(capabilityName)
|
||||
if (cap == null || !cap.isAvailable(context) || cap.group !in allowed) {
|
||||
BridgeActivityLog.record(context, capabilityName, cap?.riskLevel, "blocked", "unsupported or disabled")
|
||||
return HttpResponse(200, errorEnvelope(requestId, "unsupported_capability", "Capability is not supported"))
|
||||
}
|
||||
|
||||
if (requiresApproval(cap)) {
|
||||
val approved = approvalGate(requestId, capabilityName, arguments)
|
||||
if (!approved) {
|
||||
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "denied", "User denied or approval timed out")
|
||||
return HttpResponse(200, errorEnvelope(requestId, "approval_denied", "User denied or approval timed out"))
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = cap.execute(context, arguments)
|
||||
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "completed")
|
||||
HttpResponse(200, buildJsonObject {
|
||||
put("requestId", requestId)
|
||||
put("status", "completed")
|
||||
@@ -247,6 +250,7 @@ open class MobileBridgeServer(
|
||||
put("error", kotlinx.serialization.json.JsonNull)
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "failed", e.message ?: e.javaClass.simpleName)
|
||||
HttpResponse(200, errorEnvelope(requestId, "execution_failed", e.message ?: "Unknown error"))
|
||||
}
|
||||
}
|
||||
@@ -262,9 +266,8 @@ open class MobileBridgeServer(
|
||||
* [BridgeApprovalRegistry]; tests substitute a deterministic gate.
|
||||
*/
|
||||
internal open suspend fun approvalGate(requestId: String, capability: String, arguments: kotlinx.serialization.json.JsonObject): Boolean {
|
||||
// Honour outstanding grants — these are Hermes-Relay style "per-channel
|
||||
// grants with user-chosen TTLs". A destructive verb forces a fresh
|
||||
// prompt regardless of the grant.
|
||||
// Honour outstanding grants with user-chosen TTLs. A destructive verb
|
||||
// forces a fresh prompt regardless of the grant.
|
||||
val destructive = com.openclaw.assistant.bridge.grants.DestructiveVerbs.isDestructive(capability)
|
||||
if (!destructive && com.openclaw.assistant.bridge.grants.BridgeGrants.isGranted(capability)) return true
|
||||
runCatching { BridgeApprovalNotifier.notify(context, requestId, capability) }
|
||||
|
||||
@@ -15,7 +15,7 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Mirrors Hermes-Relay's "notifications" surface. Reads the current active
|
||||
* Reads the current active
|
||||
* notifications via [OpenClawNotificationListenerService] (which Android
|
||||
* grants only after the user has explicitly enabled
|
||||
* "Notification access" for WakeHermesClaw).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.openclaw.assistant.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
object WakeLockManager {
|
||||
private const val TAG = "BridgeWakeLock"
|
||||
private const val LOCK_TAG = "WakeHermesClaw::BridgeAction"
|
||||
private const val TIMEOUT_MS = 10_000L
|
||||
|
||||
private val lock = Any()
|
||||
private var count = 0
|
||||
@Volatile private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (wakeLock != null) return
|
||||
val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG).apply {
|
||||
setReferenceCounted(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> withWakeLock(block: () -> T): T {
|
||||
val acquired = acquire()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
if (acquired) release()
|
||||
}
|
||||
}
|
||||
|
||||
fun acquire(): Boolean {
|
||||
val wl = wakeLock ?: return false
|
||||
synchronized(lock) {
|
||||
if (count == 0) {
|
||||
try {
|
||||
wl.acquire(TIMEOUT_MS)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "acquire failed: ${t.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
count += 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
val wl = wakeLock ?: return
|
||||
synchronized(lock) {
|
||||
if (count <= 0) {
|
||||
count = 0
|
||||
return
|
||||
}
|
||||
count -= 1
|
||||
if (count == 0) {
|
||||
runCatching { if (wl.isHeld) wl.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-8
@@ -2,12 +2,16 @@ package com.openclaw.assistant.bridge.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Path
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import com.openclaw.assistant.bridge.WakeLockManager
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Accessibility Bridge — Hermes-Relay's "the agent reads your screen and acts
|
||||
* on it: tap, type, swipe, screenshots, clipboard, media, notifications".
|
||||
* Accessibility Bridge — the agent reads the visible screen and acts on it:
|
||||
* tap, type, swipe, screenshots, clipboard, media, notifications.
|
||||
*
|
||||
* The user must explicitly enable WakeHermesClaw in Android Settings →
|
||||
* Accessibility before any of the screen.* capabilities can run. The service
|
||||
@@ -42,15 +46,64 @@ class AgentVoiceAccessibilityService : AccessibilityService() {
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* no-op: pull-driven service */ }
|
||||
|
||||
fun performTap(x: Float, y: Float): Boolean {
|
||||
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, 50L)
|
||||
return dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
return WakeLockManager.withWakeLock {
|
||||
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, 50L)
|
||||
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun performSwipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long): Boolean {
|
||||
val path = Path().apply { moveTo(x1, y1); lineTo(x2, y2) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs)
|
||||
return dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
return WakeLockManager.withWakeLock {
|
||||
val path = Path().apply { moveTo(x1, y1); lineTo(x2, y2) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs.coerceAtLeast(50L))
|
||||
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun performLongPress(x: Float, y: Float, durationMs: Long): Boolean {
|
||||
return WakeLockManager.withWakeLock {
|
||||
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs.coerceAtLeast(350L))
|
||||
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun readScreenSnapshot(): ScreenSnapshot {
|
||||
val roots = snapshotRoots()
|
||||
return try {
|
||||
ScreenSnapshot.fromRoots(roots)
|
||||
} finally {
|
||||
roots.forEach { runCatching { it.recycle() } }
|
||||
}
|
||||
}
|
||||
|
||||
fun findNodes(text: String?, className: String?, clickable: Boolean?, limit: Int): List<ScreenNodeSnapshot> {
|
||||
val needle = text?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
val klass = className?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return readScreenSnapshot().nodes.filter { node ->
|
||||
val hay = listOfNotNull(node.text, node.contentDescription).joinToString(" ").lowercase()
|
||||
(needle == null || needle in hay) &&
|
||||
(klass == null || node.className == klass) &&
|
||||
(clickable == null || node.clickable == clickable)
|
||||
}.take(limit.coerceIn(1, 50))
|
||||
}
|
||||
|
||||
fun describeNode(nodeId: String): ScreenNodeSnapshot? =
|
||||
readScreenSnapshot().nodes.firstOrNull { it.nodeId == nodeId }
|
||||
|
||||
fun screenHash(): ScreenHash {
|
||||
val snapshot = readScreenSnapshot()
|
||||
return ScreenHash(
|
||||
hash = snapshot.computeHash(),
|
||||
nodeCount = snapshot.nodes.size,
|
||||
truncated = snapshot.truncated,
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshotRoots(): List<AccessibilityNodeInfo> {
|
||||
val multi = runCatching { windows.mapNotNull { it.root } }.getOrDefault(emptyList())
|
||||
return if (multi.isNotEmpty()) multi else listOfNotNull(rootInActiveWindow)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -59,3 +112,116 @@ class AgentVoiceAccessibilityService : AccessibilityService() {
|
||||
fun isRunning(): Boolean = instance != null
|
||||
}
|
||||
}
|
||||
|
||||
data class ScreenSnapshot(
|
||||
val packageName: String?,
|
||||
val rootBounds: BoundsSnapshot,
|
||||
val nodes: List<ScreenNodeSnapshot>,
|
||||
val truncated: Boolean = false,
|
||||
) {
|
||||
fun computeHash(): String {
|
||||
val joined = nodes.joinToString("\u001e") { node ->
|
||||
listOf(
|
||||
node.className.orEmpty(),
|
||||
node.text.orEmpty(),
|
||||
node.contentDescription.orEmpty(),
|
||||
node.viewId.orEmpty(),
|
||||
"${node.bounds.left},${node.bounds.top},${node.bounds.right},${node.bounds.bottom}",
|
||||
).joinToString("|")
|
||||
}
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(joined.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_NODES = 512
|
||||
fun fromRoots(roots: List<AccessibilityNodeInfo>): ScreenSnapshot {
|
||||
val nodes = mutableListOf<ScreenNodeSnapshot>()
|
||||
var truncated = false
|
||||
var packageName: String? = null
|
||||
val union = Rect()
|
||||
var unionSet = false
|
||||
roots.forEachIndexed { windowIndex, root ->
|
||||
if (nodes.size >= MAX_NODES) {
|
||||
truncated = true
|
||||
return@forEachIndexed
|
||||
}
|
||||
if (packageName == null) packageName = root.packageName?.toString()
|
||||
val rect = Rect()
|
||||
root.getBoundsInScreen(rect)
|
||||
if (!unionSet) {
|
||||
union.set(rect)
|
||||
unionSet = true
|
||||
} else {
|
||||
union.union(rect)
|
||||
}
|
||||
walk(root, windowIndex, nodes)
|
||||
if (nodes.size >= MAX_NODES) truncated = true
|
||||
}
|
||||
return ScreenSnapshot(
|
||||
packageName = packageName,
|
||||
rootBounds = union.toBoundsSnapshot(),
|
||||
nodes = nodes.take(MAX_NODES),
|
||||
truncated = truncated,
|
||||
)
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo?, windowIndex: Int, out: MutableList<ScreenNodeSnapshot>) {
|
||||
if (node == null || out.size >= MAX_NODES) return
|
||||
val rect = Rect()
|
||||
node.getBoundsInScreen(rect)
|
||||
val text = node.text?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.take(500)
|
||||
val desc = node.contentDescription?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.take(500)
|
||||
val interesting = (text != null || desc != null || node.isClickable || node.isLongClickable || node.isScrollable || node.isEditable) &&
|
||||
rect.width() > 0 && rect.height() > 0
|
||||
if (interesting) {
|
||||
out += ScreenNodeSnapshot(
|
||||
nodeId = "w$windowIndex:${out.size}",
|
||||
text = text,
|
||||
contentDescription = desc,
|
||||
className = node.className?.toString(),
|
||||
viewId = node.viewIdResourceName,
|
||||
bounds = rect.toBoundsSnapshot(),
|
||||
clickable = node.isClickable,
|
||||
longClickable = node.isLongClickable,
|
||||
scrollable = node.isScrollable,
|
||||
editable = node.isEditable,
|
||||
enabled = node.isEnabled,
|
||||
)
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
if (out.size >= MAX_NODES) return
|
||||
val child = node.getChild(i) ?: continue
|
||||
try {
|
||||
walk(child, windowIndex, out)
|
||||
} finally {
|
||||
runCatching { child.recycle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ScreenNodeSnapshot(
|
||||
val nodeId: String,
|
||||
val text: String?,
|
||||
val contentDescription: String?,
|
||||
val className: String?,
|
||||
val viewId: String?,
|
||||
val bounds: BoundsSnapshot,
|
||||
val clickable: Boolean,
|
||||
val longClickable: Boolean,
|
||||
val scrollable: Boolean,
|
||||
val editable: Boolean,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
|
||||
data class BoundsSnapshot(val left: Int, val top: Int, val right: Int, val bottom: Int) {
|
||||
val centerX: Int get() = (left + right) / 2
|
||||
val centerY: Int get() = (top + bottom) / 2
|
||||
}
|
||||
|
||||
data class ScreenHash(val hash: String, val nodeCount: Int, val truncated: Boolean)
|
||||
|
||||
private fun Rect.toBoundsSnapshot(): BoundsSnapshot = BoundsSnapshot(left, top, right, bottom)
|
||||
|
||||
@@ -3,8 +3,7 @@ package com.openclaw.assistant.bridge.grants
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Per-capability grant store with TTL — Hermes-Relay calls these
|
||||
* "per-channel grants, user-chosen TTLs, revocable from any client".
|
||||
* Per-capability grant store with user-chosen TTLs, revocable from any client.
|
||||
*
|
||||
* A grant lets a capability run without re-prompting for [Grant.expiresAtMs]
|
||||
* milliseconds. Default TTL choices are 10 minutes / 1 hour / "until I revoke"
|
||||
@@ -37,8 +36,8 @@ object BridgeGrants {
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive-verb list. Hermes-Relay enforces an extra "are you sure?"
|
||||
* prompt when a capability's name (or arguments) match a destructive verb.
|
||||
* Destructive-verb list. The bridge enforces an extra confirmation prompt when
|
||||
* a capability's name (or arguments) match a destructive verb.
|
||||
*
|
||||
* Matching is case-insensitive substring on the capability name; tools that
|
||||
* want to bypass the check would have to pick a non-destructive name, which
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Pairing protocol modelled after Hermes-Relay's `hermes-pair` flow:
|
||||
* Pairing protocol for trusted local Mobile Bridge clients:
|
||||
*
|
||||
* 1. Bridge UI generates a short-lived [Offer] containing a random 6-character
|
||||
* human-readable code and a one-time pairing nonce. The offer is shown as
|
||||
|
||||
@@ -39,7 +39,7 @@ import com.openclaw.assistant.R
|
||||
import com.openclaw.assistant.bridge.MobileBridgeConfig
|
||||
|
||||
/**
|
||||
* Pairing screen — Hermes-Relay style. Generates a short-lived offer and
|
||||
* Pairing screen. Generates a short-lived offer and
|
||||
* displays both the 6-character human-typeable code (for a `hermes-pair`
|
||||
* style CLI) and the full `agentvoice://pair?...` URL (for QR scanners that
|
||||
* can read it off the screen, or to paste into a desktop pairing tool).
|
||||
|
||||
@@ -127,6 +127,7 @@ class ChatController(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
modelName: String? = null,
|
||||
) {
|
||||
val trimmed = message.trim()
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||
@@ -182,6 +183,7 @@ class ChatController(
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
modelName?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
|
||||
@@ -1104,8 +1104,8 @@ class NodeRuntime(context: Context) {
|
||||
chat.abort()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>, modelName: String? = null) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments, modelName = modelName)
|
||||
}
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
|
||||
@@ -655,6 +655,28 @@ class OpenClawSession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveOpenClawGatewayModel(): String? {
|
||||
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(context).backends.first()
|
||||
.filter { it.enabled }
|
||||
return backends.firstOrNull {
|
||||
it.isPrimary && it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY
|
||||
}?.modelName?.takeIf { it.isNotBlank() }
|
||||
?: backends.firstOrNull {
|
||||
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY
|
||||
}?.modelName?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private suspend fun resolveLegacyOpenClawModel(): String? {
|
||||
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(context).backends.first()
|
||||
.filter { it.enabled }
|
||||
return backends.firstOrNull {
|
||||
it.isPrimary && it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
|
||||
}?.modelName?.takeIf { it.isNotBlank() }
|
||||
?: backends.firstOrNull {
|
||||
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
|
||||
}?.modelName?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private var waitPhraseJob: Job? = null
|
||||
|
||||
private fun scheduleInitialFillerPhrase() {
|
||||
@@ -754,7 +776,8 @@ class OpenClawSession(
|
||||
nodeRuntime.sendChat(
|
||||
message = message,
|
||||
thinking = "low",
|
||||
attachments = emptyList()
|
||||
attachments = emptyList(),
|
||||
modelName = resolveOpenClawGatewayModel(),
|
||||
)
|
||||
|
||||
// Wait for a new complete assistant response (timeout 60s).
|
||||
@@ -828,7 +851,8 @@ class OpenClawSession(
|
||||
message = message,
|
||||
sessionId = settings.sessionId,
|
||||
authToken = settings.authToken.takeIf { it.isNotBlank() },
|
||||
agentId = agentId
|
||||
agentId = agentId,
|
||||
modelName = resolveLegacyOpenClawModel(),
|
||||
)
|
||||
|
||||
cancelWaitPhraseTimer()
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,8 +38,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.openclaw.assistant.backend.AgentBackendConfig
|
||||
import com.openclaw.assistant.backend.AgentClientFactory
|
||||
import com.openclaw.assistant.backend.AgentContextInspector
|
||||
import com.openclaw.assistant.backend.AgentDiagnostics
|
||||
import com.openclaw.assistant.backend.BackendRepository
|
||||
import com.openclaw.assistant.backend.BackendType
|
||||
import com.openclaw.assistant.backend.HermesConfigApi
|
||||
import com.openclaw.assistant.backend.HermesModelOption
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -77,6 +82,9 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
|
||||
var port by remember { mutableStateOf(existing?.port?.toString().orEmpty()) }
|
||||
var useTls by remember { mutableStateOf(existing?.useTls ?: true) }
|
||||
var modelName by remember { mutableStateOf(existing?.modelName ?: "default") }
|
||||
var agentContextName by remember { mutableStateOf(existing?.agentContextName.orEmpty()) }
|
||||
var agentContextDetail by remember { mutableStateOf(existing?.agentContextDetail.orEmpty()) }
|
||||
var preferredEndpointRole by remember { mutableStateOf(existing?.preferredEndpointRole.orEmpty()) }
|
||||
var useRunsApi by remember { mutableStateOf(existing?.useRunsApi ?: true) }
|
||||
var useStreaming by remember { mutableStateOf(existing?.useStreaming ?: true) }
|
||||
var setPrimary by remember { mutableStateOf(existing?.isPrimary ?: backends.isEmpty()) }
|
||||
@@ -84,6 +92,8 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
|
||||
var tailscaleUrl by remember { mutableStateOf(existing?.secondaryUrls?.getOrNull(1).orEmpty()) }
|
||||
var publicUrl by remember { mutableStateOf(existing?.secondaryUrls?.getOrNull(2).orEmpty()) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
var hermesModels by remember { mutableStateOf<List<HermesModelOption>>(emptyList()) }
|
||||
var hermesProviders by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text(if (existing == null) androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.add_backend) else androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_backends_edit)) }) }) { padding ->
|
||||
@@ -102,12 +112,67 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
|
||||
|
||||
OutlinedTextField(value = displayName, onValueChange = { displayName = it }, label = { Text("Display name") }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Agent Context", style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = agentContextName,
|
||||
onValueChange = { agentContextName = it },
|
||||
label = { Text("Profile / agent name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = agentContextDetail,
|
||||
onValueChange = { agentContextDetail = it },
|
||||
label = { Text("Model / personality note (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = preferredEndpointRole,
|
||||
onValueChange = { preferredEndpointRole = it },
|
||||
label = { Text("Preferred route label (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (type == BackendType.HERMES_API_SERVER) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = {
|
||||
val config = buildConfig(
|
||||
existing = existing,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
host = host,
|
||||
port = port,
|
||||
useTls = useTls,
|
||||
modelName = modelName,
|
||||
useRunsApi = useRunsApi,
|
||||
useStreaming = useStreaming,
|
||||
isPrimary = setPrimary,
|
||||
secondaryUrls = listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() },
|
||||
agentContextName = agentContextName,
|
||||
agentContextDetail = agentContextDetail,
|
||||
preferredEndpointRole = preferredEndpointRole,
|
||||
)
|
||||
scope.launch {
|
||||
status = "Inspecting agent context..."
|
||||
val inspection = AgentContextInspector().inspect(config)
|
||||
inspection.contextName?.let { agentContextName = it }
|
||||
inspection.contextDetail?.let { agentContextDetail = it }
|
||||
status = inspection.summary
|
||||
}
|
||||
}, enabled = baseUrl.isNotBlank()) {
|
||||
Text("Inspect Agent Context")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (type) {
|
||||
BackendType.HERMES_API_SERVER -> {
|
||||
OutlinedTextField(value = baseUrl, onValueChange = { baseUrl = it }, label = { Text("Primary URL (e.g. http://host:8642)") }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Additional endpoints — raced in parallel on every connect, fastest wins (Hermes-Relay style):", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Additional endpoints — raced in parallel on every connect, fastest reachable route wins:", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedTextField(value = lanUrl, onValueChange = { lanUrl = it }, label = { Text("LAN URL (optional)") }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -119,6 +184,68 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_import_model)) }, modifier = Modifier.fillMaxWidth())
|
||||
Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_import_model_help), style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }, agentContextName, agentContextDetail, preferredEndpointRole)
|
||||
scope.launch {
|
||||
status = "Loading Hermes models..."
|
||||
runCatching { HermesConfigApi().fetchCatalog(config) }
|
||||
.onSuccess { catalog ->
|
||||
hermesModels = catalog.models
|
||||
hermesProviders = catalog.providers
|
||||
catalog.config?.model?.takeIf { it.isNotBlank() }?.let { modelName = it }
|
||||
status = buildString {
|
||||
append("Loaded ${catalog.models.size} model")
|
||||
if (catalog.models.size != 1) append("s")
|
||||
catalog.config?.provider?.takeIf { it.isNotBlank() }?.let { append(" · provider: ").append(it) }
|
||||
}
|
||||
}
|
||||
.onFailure { status = "Could not load Hermes models: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
}, enabled = baseUrl.isNotBlank()) {
|
||||
Text("Load Models")
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }, agentContextName, agentContextDetail, preferredEndpointRole)
|
||||
scope.launch {
|
||||
status = "Applying model to Hermes..."
|
||||
runCatching { HermesConfigApi().updateModel(config, modelName) }
|
||||
.onSuccess { state ->
|
||||
val saved = config.copy(modelName = state.model ?: modelName.trim().ifBlank { "default" })
|
||||
repo.upsert(saved)
|
||||
if (setPrimary) repo.setPrimary(saved.id)
|
||||
status = "Hermes model updated: ${state.model ?: modelName}"
|
||||
}
|
||||
.onFailure { status = "Could not update Hermes model: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
}, enabled = baseUrl.isNotBlank() && modelName.isNotBlank()) {
|
||||
Text("Apply to Hermes")
|
||||
}
|
||||
}
|
||||
if (hermesProviders.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Providers: ${hermesProviders.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (hermesModels.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
hermesModels.take(8).forEach { option ->
|
||||
AssistChip(
|
||||
onClick = { modelName = option.id },
|
||||
label = {
|
||||
Text(
|
||||
listOfNotNull(option.id, option.description?.takeIf { it.isNotBlank() }).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (hermesModels.size > 8) {
|
||||
Text("+${hermesModels.size - 8} more", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = useRunsApi, onCheckedChange = { useRunsApi = it }); Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_hermes_use_runs_api))
|
||||
@@ -158,17 +285,18 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
|
||||
val secondary = listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary)
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary, agentContextName, agentContextDetail, preferredEndpointRole)
|
||||
repo.upsert(config)
|
||||
if (setPrimary) repo.setPrimary(config.id)
|
||||
onDone()
|
||||
}) { Text("Save") }
|
||||
|
||||
Button(onClick = {
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary)
|
||||
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary, agentContextName, agentContextDetail, preferredEndpointRole)
|
||||
scope.launch {
|
||||
status = "Testing…"
|
||||
val r = withContext(Dispatchers.IO) { AgentClientFactory.create(config).testConnection() }
|
||||
AgentDiagnostics.recordHealth(context, config, r.ok, r.latencyMs, if (r.ok) null else r.message)
|
||||
status = if (r.ok) "✓ ${r.message}" else "✗ ${r.message}"
|
||||
}
|
||||
}) { Text("Test") }
|
||||
@@ -192,6 +320,9 @@ private fun buildConfig(
|
||||
useStreaming: Boolean,
|
||||
isPrimary: Boolean,
|
||||
secondaryUrls: List<String> = emptyList(),
|
||||
agentContextName: String = "",
|
||||
agentContextDetail: String = "",
|
||||
preferredEndpointRole: String = "",
|
||||
): AgentBackendConfig {
|
||||
val base = existing ?: AgentBackendConfig(displayName = displayName, type = type)
|
||||
return base.copy(
|
||||
@@ -207,6 +338,9 @@ private fun buildConfig(
|
||||
useStreaming = useStreaming,
|
||||
isPrimary = isPrimary,
|
||||
secondaryUrls = secondaryUrls,
|
||||
agentContextName = agentContextName.ifBlank { null },
|
||||
agentContextDetail = agentContextDetail.ifBlank { null },
|
||||
preferredEndpointRole = preferredEndpointRole.ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.openclaw.assistant.backend.AgentBackendConfig
|
||||
import com.openclaw.assistant.backend.AgentClientFactory
|
||||
import com.openclaw.assistant.backend.AgentDiagnostics
|
||||
import com.openclaw.assistant.backend.BackendRepository
|
||||
import com.openclaw.assistant.backend.BackendType
|
||||
import com.openclaw.assistant.backend.ConnectionTestResult
|
||||
@@ -77,7 +78,9 @@ class BackendListViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun delete(id: String) = repo.delete(id)
|
||||
|
||||
suspend fun testConnection(config: AgentBackendConfig): ConnectionTestResult = withContext(Dispatchers.IO) {
|
||||
AgentClientFactory.create(config).testConnection()
|
||||
val result = AgentClientFactory.create(config).testConnection()
|
||||
AgentDiagnostics.recordHealth(getApplication<Application>().applicationContext, config, result.ok, result.latencyMs, if (result.ok) null else result.message)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Hermes-Relay style "tool-progress cards" feed. Producers (Hermes client
|
||||
* code) push [AgentEvent.ToolProgress] events here; Chat renders the most
|
||||
* Shared tool-progress feed. Producers (Hermes/OpenClaw client code) push
|
||||
* [AgentEvent.ToolProgress] events here; Chat renders the most
|
||||
* recent few as inline cards above the input so the user can see tool
|
||||
* activity in real time.
|
||||
*
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.openclaw.assistant.R
|
||||
import com.openclaw.assistant.bridge.BridgeActivityLog
|
||||
import com.openclaw.assistant.bridge.BridgeApprovalMode
|
||||
import com.openclaw.assistant.bridge.BridgeBindMode
|
||||
import com.openclaw.assistant.bridge.MobileBridgeConfig
|
||||
@@ -84,6 +86,8 @@ fun MobileBridgeSettingsScreen() {
|
||||
val bindMode by cfg.bindMode.collectAsState()
|
||||
val approvalMode by cfg.approvalMode.collectAsState()
|
||||
val allowedGroups by cfg.allowedCapabilityGroups.collectAsState()
|
||||
LaunchedEffect(Unit) { BridgeActivityLog.initialize(context) }
|
||||
val activityEntries by BridgeActivityLog.entries.collectAsState()
|
||||
var portText by remember(port) { mutableStateOf(port.toString()) }
|
||||
var showToken by remember { mutableStateOf(false) }
|
||||
var rotated by remember { mutableStateOf(0) }
|
||||
@@ -206,7 +210,7 @@ fun MobileBridgeSettingsScreen() {
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FlowChipRow {
|
||||
listOf("device", "apps", "clipboard.read", "medium").forEach { group ->
|
||||
listOf("device", "apps", "accessibility", "clipboard.read", "clipboard.write", "media", "notifications", "sms", "contacts", "calendar", "camera").forEach { group ->
|
||||
FilterChip(
|
||||
selected = group in allowedGroups,
|
||||
onClick = {
|
||||
@@ -269,6 +273,34 @@ fun MobileBridgeSettingsScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard(title = "Bridge Activity", icon = Icons.Default.CheckCircle) {
|
||||
Text(
|
||||
"Recent local capability calls. Arguments, screen text, screenshots, and tokens are not stored.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (activityEntries.isEmpty()) {
|
||||
Text("No bridge activity yet.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
activityEntries.take(8).forEach { entry ->
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Text(entry.capability, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
AssistChip(onClick = {}, label = { Text(entry.status) })
|
||||
}
|
||||
val detail = listOf(entry.riskLevel.takeIf { it.isNotBlank() }, entry.message).filterNotNull().joinToString(" · ")
|
||||
if (detail.isNotBlank()) {
|
||||
Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { BridgeActivityLog.clear(context) }) {
|
||||
Text("Clear activity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!com.openclaw.assistant.BuildConfig.IS_SIDELOAD) {
|
||||
Text(
|
||||
stringResource(R.string.av_bridge_play_warning),
|
||||
|
||||
@@ -536,7 +536,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
nodeRuntime.sendChat(
|
||||
message = text,
|
||||
thinking = "low",
|
||||
attachments = outgoing
|
||||
attachments = outgoing,
|
||||
modelName = resolveSelectedOpenClawModel(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingNodeChatTts = false
|
||||
@@ -632,6 +633,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sessionId = sessionId,
|
||||
authToken = authToken,
|
||||
agentId = effectiveAgentId,
|
||||
modelName = resolveSelectedOpenClawModel(),
|
||||
attachments = attachments.map { Pair(it.mimeType, it.base64) }
|
||||
)
|
||||
|
||||
@@ -1215,4 +1217,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
agentId = agentId,
|
||||
)?.text
|
||||
}
|
||||
|
||||
private suspend fun resolveSelectedOpenClawModel(): String? {
|
||||
val ctx = getApplication<Application>().applicationContext
|
||||
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(ctx).backends.first()
|
||||
.filter { it.enabled }
|
||||
val overrideId = com.openclaw.assistant.ui.backend.ChatBackendTarget.selectedId.value
|
||||
val target = if (overrideId != null) {
|
||||
backends.firstOrNull { it.id == overrideId }
|
||||
} else {
|
||||
backends.firstOrNull { it.isPrimary }
|
||||
}
|
||||
return target?.takeIf {
|
||||
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY ||
|
||||
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
|
||||
}?.modelName?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Self-check / diagnostic screen — the AgentVoice analogue of Hermes-Relay's
|
||||
* `hermes-status` command and `/hermes-relay-self-setup` skill. One scrollable
|
||||
* Self-check / diagnostic screen. One scrollable
|
||||
* view that probes every load-bearing surface so a user (or Hermes itself
|
||||
* via the bridge) can answer "is everything wired correctly?" in a glance.
|
||||
*
|
||||
|
||||
@@ -605,7 +605,7 @@ private fun parseHermesRelayJson(obj: JsonObject): PairingPayload? {
|
||||
modelName = obj["model"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { "default" } ?: "default",
|
||||
useRunsApi = obj["runs"]?.jsonPrimitive?.booleanOrNull ?: true,
|
||||
streaming = obj["streaming"]?.jsonPrimitive?.booleanOrNull ?: true,
|
||||
displayName = obj["name"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { null } ?: "Hermes Relay",
|
||||
displayName = obj["name"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { null } ?: "Hermes Agent",
|
||||
),
|
||||
openClawSetupCode = null,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* "Morphing sphere" voice indicator — Hermes-Relay-style organic blob that
|
||||
* "Morphing sphere" voice indicator — organic blob that
|
||||
* breathes while idle, ripples in response to live microphone amplitude while
|
||||
* listening, swirls slowly while thinking, and pulses while the assistant
|
||||
* speaks. Drives off [AssistantState] + an `audioLevel` already normalised
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeNotificationStateChanged|typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode"
|
||||
android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode|flagRetrieveInteractiveWindows"
|
||||
android:canPerformGestures="true"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:notificationTimeout="100"
|
||||
|
||||
@@ -143,7 +143,7 @@ class PairingUriParserTest {
|
||||
assertTrue(h.secondaryUrls.isEmpty())
|
||||
assertEquals("api-key", h.apiKey)
|
||||
assertEquals(true, h.useRunsApi)
|
||||
assertEquals("Hermes Relay", h.displayName)
|
||||
assertEquals("Hermes Agent", h.displayName)
|
||||
}
|
||||
|
||||
@Test fun `Hermes Relay v3 endpoints are imported in priority order`() {
|
||||
|
||||
Reference in New Issue
Block a user