feat(android): add v2 skills settings

This commit is contained in:
Ayaan Zaidi
2026-05-19 20:21:38 +05:30
parent 41175edd98
commit 817ca4bf65
5 changed files with 295 additions and 3 deletions

View File

@@ -94,6 +94,9 @@ class MainViewModel(
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
@@ -384,6 +387,10 @@ class MainViewModel(
ensureRuntime().refreshUsage()
}
fun refreshSkills() {
ensureRuntime().refreshSkills()
}
fun loadChat(sessionKey: String) {
ensureRuntime().loadChat(sessionKey)
}

View File

@@ -323,6 +323,12 @@ class NodeRuntime(
val usageRefreshing: StateFlow<Boolean> = _usageRefreshing.asStateFlow()
private val _usageErrorText = MutableStateFlow<String?>(null)
val usageErrorText: StateFlow<String?> = _usageErrorText.asStateFlow()
private val _skillsSummary = MutableStateFlow(GatewaySkillsSummary(skills = emptyList()))
val skillsSummary: StateFlow<GatewaySkillsSummary> = _skillsSummary.asStateFlow()
private val _skillsRefreshing = MutableStateFlow(false)
val skillsRefreshing: StateFlow<Boolean> = _skillsRefreshing.asStateFlow()
private val _skillsErrorText = MutableStateFlow<String?>(null)
val skillsErrorText: StateFlow<String?> = _skillsErrorText.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
@@ -367,6 +373,7 @@ class NodeRuntime(
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
_cronJobs.value = emptyList()
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
@@ -635,6 +642,7 @@ class NodeRuntime(
refreshModelCatalogFromGateway()
refreshCronFromGateway()
refreshUsageFromGateway()
refreshSkillsFromGateway()
}
}
@@ -662,6 +670,12 @@ class NodeRuntime(
}
}
fun refreshSkills() {
scope.launch {
refreshSkillsFromGateway()
}
}
fun requestCanvasRehydrate(
source: String = "manual",
force: Boolean = true,
@@ -1657,6 +1671,34 @@ class NodeRuntime(
}
}
private suspend fun refreshSkillsFromGateway() {
_skillsRefreshing.value = true
_skillsErrorText.value = null
if (!operatorConnected) {
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
_skillsRefreshing.value = false
return
}
try {
val res = operatorSession.request("skills.status", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
_skillsSummary.value =
GatewaySkillsSummary(
managedSkillsDirAvailable =
root
?.get("managedSkillsDir")
.asStringOrNull()
?.trim()
?.isNotEmpty() == true,
skills = parseSkillSummaries(root?.get("skills") as? JsonArray),
)
} catch (_: Throwable) {
_skillsErrorText.value = "Could not load skills."
} finally {
_skillsRefreshing.value = false
}
}
private fun parseGatewayModels(models: JsonArray?): List<GatewayModelSummary> =
models
?.mapNotNull { item ->
@@ -1744,6 +1786,29 @@ class NodeRuntime(
)
}.orEmpty()
private fun parseSkillSummaries(skills: JsonArray?): List<GatewaySkillSummary> =
skills
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()?.trim().orEmpty()
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
emoji = obj["emoji"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
disabled = obj.boolean("disabled"),
eligible = obj.boolean("eligible"),
blockedByAllowlist = obj.boolean("blockedByAllowlist"),
bundled = obj.boolean("bundled"),
missingCount = skillMissingCount(missing),
installCount = (obj["install"] as? JsonArray)?.size ?: 0,
)
}.orEmpty()
private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 }
private fun cronScheduleLabel(schedule: JsonObject?): String =
when (schedule?.get("kind").asStringOrNull()) {
"at" -> "One time"
@@ -2053,6 +2118,24 @@ data class GatewayUsageWindowSummary(
val resetAtMs: Long?,
)
data class GatewaySkillsSummary(
val managedSkillsDirAvailable: Boolean = false,
val skills: List<GatewaySkillSummary>,
)
data class GatewaySkillSummary(
val name: String,
val description: String?,
val source: String,
val emoji: String?,
val disabled: Boolean,
val eligible: Boolean,
val blockedByAllowlist: Boolean,
val bundled: Boolean,
val missingCount: Int,
val installCount: Int,
)
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -75,6 +75,7 @@ internal enum class V2SettingsRoute {
Approvals,
CronJobs,
Usage,
Skills,
Notifications,
PhoneCapabilities,
Gateway,
@@ -97,6 +98,7 @@ internal fun V2SettingsDetailScreen(
V2SettingsRoute.Approvals -> V2ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.CronJobs -> V2CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Usage -> V2UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Skills -> V2SkillsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Notifications -> V2NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.PhoneCapabilities -> V2PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Gateway -> V2GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -487,7 +489,7 @@ private fun V2AboutSettingsScreen(onBack: () -> Unit) {
}
@Composable
private fun V2SettingsDetailFrame(
internal fun V2SettingsDetailFrame(
title: String,
subtitle: String,
icon: ImageVector,
@@ -524,7 +526,7 @@ private data class V2SettingsToggleRow(
val onCheckedChange: (Boolean) -> Unit,
)
private data class V2SettingsMetric(
internal data class V2SettingsMetric(
val title: String,
val value: String,
)
@@ -808,7 +810,7 @@ private fun V2SettingsToggleListRow(row: V2SettingsToggleRow) {
}
@Composable
private fun V2SettingsMetricPanel(rows: List<V2SettingsMetric>) {
internal fun V2SettingsMetricPanel(rows: List<V2SettingsMetric>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
rows.forEachIndexed { index, row ->

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.chat.V2ChatScreen
@@ -523,6 +524,7 @@ private fun V2SettingsShellScreen(
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val cronStatus by viewModel.cronStatus.collectAsState()
val usageSummary by viewModel.usageSummary.collectAsState()
val skillsSummary by viewModel.skillsSummary.collectAsState()
var route by rememberSaveable { mutableStateOf(V2SettingsRoute.Home) }
LaunchedEffect(isConnected) {
@@ -530,6 +532,7 @@ private fun V2SettingsShellScreen(
viewModel.refreshAgents()
viewModel.refreshCronJobs()
viewModel.refreshUsage()
viewModel.refreshSkills()
}
}
@@ -569,6 +572,7 @@ private fun V2SettingsShellScreen(
V2SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = V2SettingsRoute.Approvals),
V2SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = V2SettingsRoute.CronJobs),
V2SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = V2SettingsRoute.Usage),
V2SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = V2SettingsRoute.Skills),
V2SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = V2SettingsRoute.Notifications),
V2SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = V2SettingsRoute.PhoneCapabilities),
V2SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = V2SettingsRoute.Gateway),
@@ -632,6 +636,18 @@ private fun usageSummaryText(count: Int): String =
else -> "$count providers"
}
private fun skillsSummaryText(skills: List<GatewaySkillSummary>): String {
val ready = skills.count { !it.disabled && it.eligible && it.missingCount == 0 }
return if (skills.isEmpty()) "No skills" else "$ready/${skills.size} ready"
}
private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
when {
skills.isEmpty() -> null
skills.any { it.blockedByAllowlist || (!it.disabled && (!it.eligible || it.missingCount > 0)) } -> false
else -> true
}
private data class V2SettingsRow(
val title: String,
val value: String,

View File

@@ -0,0 +1,184 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun V2SkillsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val skillsSummary by viewModel.skillsSummary.collectAsState()
val skillsRefreshing by viewModel.skillsRefreshing.collectAsState()
val skillsErrorText by viewModel.skillsErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshSkills()
}
}
V2SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
V2SettingsMetricPanel(
rows =
listOf(
V2SettingsMetric("Installed", skills.size.toString()),
V2SettingsMetric("Ready", readyCount.toString()),
V2SettingsMetric("Needs Setup", needsSetupCount.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (skillsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshSkills,
enabled = isConnected && !skillsRefreshing,
modifier = Modifier.weight(1f),
)
}
skillsErrorText?.let { errorText ->
ClawPanel {
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load skills.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
skills.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No skills installed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> V2SkillsPanel(skills = skills)
}
}
}
@Composable
private fun V2SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
skills.forEachIndexed { index, skill ->
V2SkillListRow(skill = skill)
if (index != skills.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun V2SkillListRow(skill: GatewaySkillSummary) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = skillBadge(skill), style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = skill.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = skillSubtitle(skill), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
}
}
private fun skillReady(skill: GatewaySkillSummary): Boolean = !skill.disabled && skill.eligible && skill.missingCount == 0
private fun skillNeedsSetup(skill: GatewaySkillSummary): Boolean = !skill.disabled && (skill.blockedByAllowlist || !skill.eligible || skill.missingCount > 0)
private fun skillStatusText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "Off"
skillNeedsSetup(skill) -> "Setup"
else -> "Ready"
}
private fun skillStatus(skill: GatewaySkillSummary): ClawStatus =
when {
skill.disabled -> ClawStatus.Neutral
skillNeedsSetup(skill) -> ClawStatus.Warning
else -> ClawStatus.Success
}
private fun skillSubtitle(skill: GatewaySkillSummary): String {
val issue =
when {
skill.disabled -> "Disabled"
skill.blockedByAllowlist -> "Blocked"
skill.missingCount > 0 -> "${skill.missingCount} missing"
!skill.eligible -> "Needs setup"
else -> null
}
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
"openclaw-managed" -> "Installed"
"openclaw-workspace" -> "Workspace"
"openclaw-extra" -> "Extra"
else -> "Skill"
}
private fun skillBadge(skill: GatewaySkillSummary): String {
skill.emoji?.let { return it }
return skill.name
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "S" }
}