mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 20:48:44 +00:00
feat(android): add v2 skills settings
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
Reference in New Issue
Block a user