diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 4c9c7ff40c5..c3d75f3188b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -94,6 +94,9 @@ class MainViewModel( val usageSummary: StateFlow = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary } val usageRefreshing: StateFlow = runtimeState(initial = false) { it.usageRefreshing } val usageErrorText: StateFlow = runtimeState(initial = null) { it.usageErrorText } + val skillsSummary: StateFlow = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary } + val skillsRefreshing: StateFlow = runtimeState(initial = false) { it.skillsRefreshing } + val skillsErrorText: StateFlow = runtimeState(initial = null) { it.skillsErrorText } val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } @@ -384,6 +387,10 @@ class MainViewModel( ensureRuntime().refreshUsage() } + fun refreshSkills() { + ensureRuntime().refreshSkills() + } + fun loadChat(sessionKey: String) { ensureRuntime().loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index fce5124ba30..583b6d496e2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -323,6 +323,12 @@ class NodeRuntime( val usageRefreshing: StateFlow = _usageRefreshing.asStateFlow() private val _usageErrorText = MutableStateFlow(null) val usageErrorText: StateFlow = _usageErrorText.asStateFlow() + private val _skillsSummary = MutableStateFlow(GatewaySkillsSummary(skills = emptyList())) + val skillsSummary: StateFlow = _skillsSummary.asStateFlow() + private val _skillsRefreshing = MutableStateFlow(false) + val skillsRefreshing: StateFlow = _skillsRefreshing.asStateFlow() + private val _skillsErrorText = MutableStateFlow(null) + val skillsErrorText: StateFlow = _skillsErrorText.asStateFlow() private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _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 = models ?.mapNotNull { item -> @@ -1744,6 +1786,29 @@ class NodeRuntime( ) }.orEmpty() + private fun parseSkillSummaries(skills: JsonArray?): List = + 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, +) + +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() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt index 4f2ee2b3c1d..8b5a94676b3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt @@ -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) { +internal fun V2SettingsMetricPanel(rows: List) { ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) { Column { rows.forEachIndexed { index, row -> diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt index 77ab6248855..de111324eb5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt @@ -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): 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): 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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SkillsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SkillsSettingsScreen.kt new file mode 100644 index 00000000000..3886d6148d0 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SkillsSettingsScreen.kt @@ -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) { + 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" } +}