Initialize Meatbag SDUI platform
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
local.properties
|
||||||
|
*.log
|
||||||
29
backend/build.gradle.kts
Normal file
29
backend/build.gradle.kts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
id("io.ktor.plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "meatbag"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("meatbag.backend.ApplicationKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":common"))
|
||||||
|
implementation("io.ktor:ktor-server-core-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-server-netty-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-server-websockets-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
|
||||||
|
implementation("ch.qos.logback:logback-classic:1.4.14")
|
||||||
|
|
||||||
|
// Test dependencies including Ktor client plugins for testApplication
|
||||||
|
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")
|
||||||
|
testImplementation("io.ktor:ktor-client-content-negotiation-jvm:2.3.11")
|
||||||
|
testImplementation("io.ktor:ktor-client-websockets-jvm:2.3.11")
|
||||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23")
|
||||||
|
}
|
||||||
145
backend/src/main/kotlin/meatbag/backend/Application.kt
Normal file
145
backend/src/main/kotlin/meatbag/backend/Application.kt
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package meatbag.backend
|
||||||
|
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.netty.*
|
||||||
|
import io.ktor.server.plugins.callloging.*
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.server.plugins.cors.routing.*
|
||||||
|
import io.ktor.server.request.*
|
||||||
|
import io.ktor.server.response.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.websocket.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import meatbag.common.*
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.module() {
|
||||||
|
install(CallLogging)
|
||||||
|
|
||||||
|
install(CORS) {
|
||||||
|
anyHost()
|
||||||
|
allowHeader(HttpHeaders.ContentType)
|
||||||
|
allowHeader(HttpHeaders.Authorization)
|
||||||
|
allowMethod(HttpMethod.Options)
|
||||||
|
allowMethod(HttpMethod.Post)
|
||||||
|
allowMethod(HttpMethod.Get)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriod = Duration.ofSeconds(15)
|
||||||
|
timeout = Duration.ofSeconds(15)
|
||||||
|
maxFrameSize = Long.MAX_VALUE
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(SDUIJson.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceRegistry = DeviceRegistry()
|
||||||
|
val routingEngine = RoutingEngine(deviceRegistry)
|
||||||
|
|
||||||
|
routing {
|
||||||
|
// Client WebSocket connection endpoint
|
||||||
|
webSocket("/ws/connect") {
|
||||||
|
val deviceId = call.parameters["deviceId"] ?: java.util.UUID.randomUUID().toString()
|
||||||
|
val deviceTypeStr = call.parameters["deviceType"] ?: "DESKTOP"
|
||||||
|
val deviceType = try {
|
||||||
|
DeviceType.valueOf(deviceTypeStr.uppercase())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
DeviceType.DESKTOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = ActiveDevice(deviceId, deviceType, this)
|
||||||
|
deviceRegistry.register(device)
|
||||||
|
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
println("[WebSocket] Message from device $deviceId: $text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[WebSocket] Connection error or close for device $deviceId: ${e.localizedMessage}")
|
||||||
|
} finally {
|
||||||
|
deviceRegistry.unregister(deviceId)
|
||||||
|
println("[WebSocket] Device unregistered: ID=$deviceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint for receiving responses from clients
|
||||||
|
post("/api/respond") {
|
||||||
|
val response = try {
|
||||||
|
call.receive<ApprovalResponse>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, "Invalid Response body structure: ${e.localizedMessage}")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchedPendingRequest = routingEngine.handleResponse(response)
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
mapOf(
|
||||||
|
"status" to "Response processed",
|
||||||
|
"matchedPendingRequest" to matchedPendingRequest
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint for receiving requests from agents
|
||||||
|
post("/api/request") {
|
||||||
|
val rawJson = call.receiveText()
|
||||||
|
val uiPrimitive = try {
|
||||||
|
SDUIJson.decodeFromString(rawJson)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, "Invalid UI Primitive format: ${e.localizedMessage}")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val approvalResult = routingEngine.routeRequest(
|
||||||
|
AgentRequestContext(
|
||||||
|
uiPrimitive = uiPrimitive,
|
||||||
|
urgent = call.request.queryParameters["urgent"]?.toBoolean() ?: false,
|
||||||
|
requestedAttention = parseAttentionTier(call.request.queryParameters["attentionTier"]),
|
||||||
|
preferredDeviceTypes = parseDeviceTypes(call.request.queryParameters["deviceTypes"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
call.respond(HttpStatusCode.OK, approvalResult)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic / status endpoint
|
||||||
|
get("/api/status") {
|
||||||
|
val devices = deviceRegistry.getAllDevices().map { mapOf("id" to it.id, "type" to it.type.name) }
|
||||||
|
call.respond(mapOf("status" to "OK", "connectedDevices" to devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttentionTier(value: String?): AttentionTier {
|
||||||
|
return value
|
||||||
|
?.let { runCatching { AttentionTier.valueOf(it.uppercase()) }.getOrNull() }
|
||||||
|
?: AttentionTier.BACKGROUND_NOTIFICATION
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDeviceTypes(value: String?): List<DeviceType> {
|
||||||
|
return value
|
||||||
|
?.split(",")
|
||||||
|
?.mapNotNull { rawType -> runCatching { DeviceType.valueOf(rawType.trim().uppercase()) }.getOrNull() }
|
||||||
|
?.distinct()
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
31
backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt
Normal file
31
backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package meatbag.backend
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class DeviceRegistry {
|
||||||
|
private val devices = ConcurrentHashMap<String, ActiveDevice>()
|
||||||
|
|
||||||
|
fun register(device: ActiveDevice) {
|
||||||
|
devices[device.id] = device
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(deviceId: String) {
|
||||||
|
devices.remove(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDevice(deviceId: String): ActiveDevice? {
|
||||||
|
return devices[deviceId]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllDevices(): List<ActiveDevice> {
|
||||||
|
return devices.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDevicesByType(deviceTypes: Set<DeviceType>): List<ActiveDevice> {
|
||||||
|
if (deviceTypes.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return devices.values
|
||||||
|
.filter { it.type in deviceTypes }
|
||||||
|
.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/src/main/kotlin/meatbag/backend/Models.kt
Normal file
53
backend/src/main/kotlin/meatbag/backend/Models.kt
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package meatbag.backend
|
||||||
|
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
import meatbag.common.UiPrimitive
|
||||||
|
|
||||||
|
enum class DeviceType {
|
||||||
|
WEAR_OS_WATCH,
|
||||||
|
PHONE,
|
||||||
|
DESKTOP
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ActiveDevice(
|
||||||
|
val id: String,
|
||||||
|
val type: DeviceType,
|
||||||
|
val session: WebSocketSession
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AgentRequestContext(
|
||||||
|
val uiPrimitive: UiPrimitive,
|
||||||
|
val requestedAttention: AttentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
|
||||||
|
val urgent: Boolean = false,
|
||||||
|
val preferredDeviceTypes: List<DeviceType> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserRuleSet(
|
||||||
|
val id: String = "mock-default",
|
||||||
|
val criticalKeywords: Set<String> = setOf(
|
||||||
|
"rm -rf",
|
||||||
|
"delete",
|
||||||
|
"destroy",
|
||||||
|
"kill",
|
||||||
|
"urgent",
|
||||||
|
"critical",
|
||||||
|
"sudo",
|
||||||
|
"permission",
|
||||||
|
"allow"
|
||||||
|
),
|
||||||
|
val watchFirstForSilentHaptic: Boolean = true,
|
||||||
|
val fanOutOverrideScreen: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoutingDecision(
|
||||||
|
val attentionTier: AttentionTier,
|
||||||
|
val deviceTypes: List<DeviceType>,
|
||||||
|
val fanOut: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeliveryResult(
|
||||||
|
val requestId: String,
|
||||||
|
val attentionTier: AttentionTier,
|
||||||
|
val deviceIds: List<String>
|
||||||
|
)
|
||||||
235
backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt
Normal file
235
backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package meatbag.backend
|
||||||
|
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import meatbag.common.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
interface AgentCallback {
|
||||||
|
suspend fun onHumanResponse(response: ApprovalResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingAgentCallback : AgentCallback {
|
||||||
|
private val responses = ConcurrentHashMap<String, ApprovalResponse>()
|
||||||
|
|
||||||
|
override suspend fun onHumanResponse(response: ApprovalResponse) {
|
||||||
|
responses[response.requestId] = response
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getResponse(requestId: String): ApprovalResponse? = responses[requestId]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoutingEngine(
|
||||||
|
private val deviceRegistry: DeviceRegistry,
|
||||||
|
private val userRuleSet: UserRuleSet = UserRuleSet(),
|
||||||
|
private val agentCallback: AgentCallback = RecordingAgentCallback()
|
||||||
|
) {
|
||||||
|
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
|
||||||
|
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
|
||||||
|
|
||||||
|
suspend fun routeRequest(
|
||||||
|
uiPrimitive: UiPrimitive,
|
||||||
|
isUrgent: Boolean,
|
||||||
|
requestedAttention: AttentionTier
|
||||||
|
): ApprovalResponse {
|
||||||
|
return routeRequest(
|
||||||
|
AgentRequestContext(
|
||||||
|
uiPrimitive = uiPrimitive,
|
||||||
|
requestedAttention = requestedAttention,
|
||||||
|
urgent = isUrgent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun routeRequest(context: AgentRequestContext): ApprovalResponse {
|
||||||
|
val activeDevices = deviceRegistry.getAllDevices()
|
||||||
|
if (activeDevices.isEmpty()) {
|
||||||
|
throw IllegalStateException("No active client devices connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
val decision = evaluateRules(context)
|
||||||
|
val targetDevices = selectDevices(decision, activeDevices)
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<ApprovalResponse>()
|
||||||
|
pendingRequests[context.uiPrimitive.id] = deferred
|
||||||
|
|
||||||
|
println(
|
||||||
|
"Routing request ${context.uiPrimitive.id} to devices " +
|
||||||
|
targetDevices.joinToString { "${it.id} (${it.type})" } +
|
||||||
|
" with tier ${decision.attentionTier}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
targetDevices.forEach { device ->
|
||||||
|
device.session.send(Frame.Text(payloadJson(context, decision, device)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return deferred.await()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingRequests.remove(context.uiPrimitive.id)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deliverRequest(context: AgentRequestContext): DeliveryResult {
|
||||||
|
val activeDevices = deviceRegistry.getAllDevices()
|
||||||
|
if (activeDevices.isEmpty()) {
|
||||||
|
throw IllegalStateException("No active client devices connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
val decision = evaluateRules(context)
|
||||||
|
val targetDevices = selectDevices(decision, activeDevices)
|
||||||
|
targetDevices.forEach { device ->
|
||||||
|
device.session.send(Frame.Text(payloadJson(context, decision, device)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeliveryResult(
|
||||||
|
requestId = context.uiPrimitive.id,
|
||||||
|
attentionTier = decision.attentionTier,
|
||||||
|
deviceIds = targetDevices.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evaluateRules(context: AgentRequestContext): RoutingDecision {
|
||||||
|
if (context.preferredDeviceTypes.isNotEmpty()) {
|
||||||
|
return RoutingDecision(
|
||||||
|
attentionTier = context.requestedAttention,
|
||||||
|
deviceTypes = context.preferredDeviceTypes,
|
||||||
|
fanOut = context.preferredDeviceTypes.size > 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val promptRequiresAttention = (context.uiPrimitive as? ApprovalRequest)
|
||||||
|
?.prompt
|
||||||
|
?.lowercase()
|
||||||
|
?.let { prompt -> userRuleSet.criticalKeywords.any(prompt::contains) }
|
||||||
|
?: false
|
||||||
|
|
||||||
|
val attentionTier = when {
|
||||||
|
context.requestedAttention == AttentionTier.OVERRIDE_SCREEN -> AttentionTier.OVERRIDE_SCREEN
|
||||||
|
context.urgent || promptRequiresAttention -> AttentionTier.SILENT_HAPTIC
|
||||||
|
else -> context.requestedAttention
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceTypes = when (attentionTier) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> {
|
||||||
|
if (userRuleSet.watchFirstForSilentHaptic) {
|
||||||
|
listOf(DeviceType.WEAR_OS_WATCH, DeviceType.PHONE, DeviceType.DESKTOP)
|
||||||
|
} else {
|
||||||
|
listOf(DeviceType.PHONE, DeviceType.WEAR_OS_WATCH, DeviceType.DESKTOP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> {
|
||||||
|
if (userRuleSet.fanOutOverrideScreen) {
|
||||||
|
listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
|
||||||
|
} else {
|
||||||
|
listOf(DeviceType.PHONE, DeviceType.DESKTOP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoutingDecision(
|
||||||
|
attentionTier = attentionTier,
|
||||||
|
deviceTypes = deviceTypes,
|
||||||
|
fanOut = attentionTier == AttentionTier.OVERRIDE_SCREEN && userRuleSet.fanOutOverrideScreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleResponse(response: ApprovalResponse): Boolean {
|
||||||
|
val deferred = pendingRequests.remove(response.requestId)
|
||||||
|
if (deferred != null) {
|
||||||
|
agentCallback.onHumanResponse(response)
|
||||||
|
deferred.complete(response)
|
||||||
|
println("Completed request ${response.requestId} with approved=${response.approved}")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
println("No pending request found for ID ${response.requestId}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectDevices(decision: RoutingDecision, activeDevices: List<ActiveDevice>): List<ActiveDevice> {
|
||||||
|
val orderedMatches = decision.deviceTypes.flatMap { desiredType ->
|
||||||
|
activeDevices.filter { it.type == desiredType }.sortedBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
orderedMatches.isNotEmpty() && decision.fanOut -> orderedMatches
|
||||||
|
orderedMatches.isNotEmpty() -> listOf(orderedMatches.first())
|
||||||
|
else -> listOf(activeDevices.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id }).first())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadJson(
|
||||||
|
context: AgentRequestContext,
|
||||||
|
decision: RoutingDecision,
|
||||||
|
device: ActiveDevice
|
||||||
|
): String {
|
||||||
|
return SDUIJson.json.encodeToString(
|
||||||
|
WebSocketPayload(
|
||||||
|
attentionTier = decision.attentionTier,
|
||||||
|
uiPrimitive = context.uiPrimitive,
|
||||||
|
request = RequestContext(
|
||||||
|
requestId = context.uiPrimitive.id,
|
||||||
|
source = RequestSource(
|
||||||
|
system = "agent",
|
||||||
|
reason = if (context.urgent) "urgent" else null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
deviceContext = device.toDeviceContext(),
|
||||||
|
attentionPolicy = AttentionPolicy(
|
||||||
|
tier = decision.attentionTier,
|
||||||
|
hapticPattern = decision.attentionTier.toHapticPattern(),
|
||||||
|
presentation = decision.toPresentationMode()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ActiveDevice.toDeviceContext(): DeviceContext {
|
||||||
|
return when (type) {
|
||||||
|
DeviceType.WEAR_OS_WATCH -> DeviceContext(
|
||||||
|
deviceId = id,
|
||||||
|
deviceClass = DeviceClass.WEARABLE,
|
||||||
|
platform = DevicePlatform.WEAR_OS,
|
||||||
|
capabilities = setOf(DeviceCapability.HAPTICS, DeviceCapability.NOTIFICATIONS)
|
||||||
|
)
|
||||||
|
DeviceType.PHONE -> DeviceContext(
|
||||||
|
deviceId = id,
|
||||||
|
deviceClass = DeviceClass.PHONE,
|
||||||
|
platform = DevicePlatform.ANDROID,
|
||||||
|
capabilities = setOf(
|
||||||
|
DeviceCapability.HAPTICS,
|
||||||
|
DeviceCapability.NOTIFICATIONS,
|
||||||
|
DeviceCapability.FULL_SCREEN_INTENT,
|
||||||
|
DeviceCapability.TEXT_INPUT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DeviceType.DESKTOP -> DeviceContext(
|
||||||
|
deviceId = id,
|
||||||
|
deviceClass = DeviceClass.DESKTOP,
|
||||||
|
platform = DevicePlatform.DESKTOP,
|
||||||
|
capabilities = setOf(DeviceCapability.NOTIFICATIONS, DeviceCapability.TEXT_INPUT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AttentionTier.toHapticPattern(): HapticPattern {
|
||||||
|
return when (this) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> HapticPattern.DOUBLE_SHORT
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> HapticPattern.SHORT
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> HapticPattern.URGENT_LOOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoutingDecision.toPresentationMode(): PresentationMode {
|
||||||
|
return when {
|
||||||
|
fanOut -> PresentationMode.ALL_DEVICES
|
||||||
|
deviceTypes.firstOrNull() == DeviceType.WEAR_OS_WATCH -> PresentationMode.WATCH_ONLY
|
||||||
|
deviceTypes.firstOrNull() == DeviceType.PHONE -> PresentationMode.PHONE_ONLY
|
||||||
|
else -> PresentationMode.AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/main/resources/logback.xml
Normal file
14
backend/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<configuration>
|
||||||
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="STDOUT"/>
|
||||||
|
</root>
|
||||||
|
|
||||||
|
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||||
|
<logger name="io.netty" level="INFO"/>
|
||||||
|
</configuration>
|
||||||
170
backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt
Normal file
170
backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package meatbag.backend
|
||||||
|
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.plugins.websocket.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import io.ktor.server.testing.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import meatbag.common.*
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class RoutingEngineTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompleteAgentRoutingFlow() = testApplication {
|
||||||
|
// Configure Ktor backend module
|
||||||
|
application {
|
||||||
|
module()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client for WebSocket connection
|
||||||
|
val wsClient = createClient {
|
||||||
|
install(WebSockets)
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(SDUIJson.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client for HTTP API calls
|
||||||
|
val httpClient = createClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(SDUIJson.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the client WebSocket session and agent HTTP request concurrently
|
||||||
|
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
|
||||||
|
val agentResponseDeferred = async {
|
||||||
|
val approvalReq = ApprovalRequest(
|
||||||
|
id = "req_999",
|
||||||
|
prompt = "Urgent: Allow system update?",
|
||||||
|
target = "system-agent"
|
||||||
|
)
|
||||||
|
// Post approval request to backend polymorphically
|
||||||
|
httpClient.post("/api/request") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody<UiPrimitive>(approvalReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client receives the websocket notification frame
|
||||||
|
val frame = incoming.receive()
|
||||||
|
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||||
|
val textFrame = frame as Frame.Text
|
||||||
|
val payloadJson = textFrame.readText()
|
||||||
|
|
||||||
|
// Decode payload using common module's serializer
|
||||||
|
val payload = SDUIJson.json.decodeFromString<WebSocketPayload>(payloadJson)
|
||||||
|
// Due to "Urgent" in prompt, it should resolve to SILENT_HAPTIC and target watch_123
|
||||||
|
assertEquals(AttentionTier.SILENT_HAPTIC, payload.attentionTier)
|
||||||
|
assertEquals("req_999", payload.uiPrimitive.id)
|
||||||
|
assertTrue(payload.uiPrimitive is ApprovalRequest)
|
||||||
|
assertEquals("Urgent: Allow system update?", (payload.uiPrimitive as ApprovalRequest).prompt)
|
||||||
|
|
||||||
|
// Simulate user clicking Yes on their watch by sending response to /api/respond
|
||||||
|
val respondResponse = httpClient.post("/api/respond") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ApprovalResponse(requestId = "req_999", approved = true))
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, respondResponse.status)
|
||||||
|
|
||||||
|
// Await agent response and verify the callback output
|
||||||
|
val agentResponse = agentResponseDeferred.await()
|
||||||
|
assertEquals(HttpStatusCode.OK, agentResponse.status)
|
||||||
|
val result = agentResponse.body<ApprovalResponse>()
|
||||||
|
assertEquals("req_999", result.requestId)
|
||||||
|
assertEquals(true, result.approved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOverrideScreenFansOutToPhoneAndDesktop() = testApplication {
|
||||||
|
application {
|
||||||
|
module()
|
||||||
|
}
|
||||||
|
|
||||||
|
val wsClient = createClient {
|
||||||
|
install(WebSockets)
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(SDUIJson.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val httpClient = createClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(SDUIJson.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val phonePayloadDeferred = async {
|
||||||
|
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
|
||||||
|
val frame = incoming.receive()
|
||||||
|
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||||
|
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopPayloadDeferred = async {
|
||||||
|
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
|
||||||
|
val frame = incoming.receive()
|
||||||
|
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||||
|
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var connected = false
|
||||||
|
var attempts = 0
|
||||||
|
while (!connected && attempts < 20) {
|
||||||
|
val response = httpClient.get("/api/status")
|
||||||
|
val body = response.bodyAsText()
|
||||||
|
if (body.contains("phone_123") && body.contains("desktop_123")) {
|
||||||
|
connected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
attempts += 1
|
||||||
|
delay(25)
|
||||||
|
}
|
||||||
|
assertTrue(connected, "Expected both devices to connect before routing")
|
||||||
|
|
||||||
|
val agentResponseDeferred = async {
|
||||||
|
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody<UiPrimitive>(
|
||||||
|
ApprovalRequest(
|
||||||
|
id = "req_override",
|
||||||
|
prompt = "Approve remote screen takeover?",
|
||||||
|
target = "system-agent"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val phonePayload = phonePayloadDeferred.await()
|
||||||
|
val desktopPayload = desktopPayloadDeferred.await()
|
||||||
|
|
||||||
|
assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier)
|
||||||
|
assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier)
|
||||||
|
assertEquals("req_override", phonePayload.uiPrimitive.id)
|
||||||
|
assertEquals("req_override", desktopPayload.uiPrimitive.id)
|
||||||
|
|
||||||
|
val respondResponse = httpClient.post("/api/respond") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ApprovalResponse(requestId = "req_override", approved = false))
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, respondResponse.status)
|
||||||
|
|
||||||
|
val agentResponse = agentResponseDeferred.await()
|
||||||
|
assertEquals(HttpStatusCode.OK, agentResponse.status)
|
||||||
|
val result = agentResponse.body<ApprovalResponse>()
|
||||||
|
assertEquals("req_override", result.requestId)
|
||||||
|
assertEquals(false, result.approved)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
build.gradle.kts
Normal file
9
build.gradle.kts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
plugins {
|
||||||
|
// Note: Use matching versions of Kotlin and Compose Multiplatform
|
||||||
|
kotlin("multiplatform") version "1.9.23" apply false
|
||||||
|
kotlin("jvm") version "1.9.23" apply false
|
||||||
|
kotlin("plugin.serialization") version "1.9.23" apply false
|
||||||
|
id("org.jetbrains.compose") version "1.6.11" apply false
|
||||||
|
id("io.ktor.plugin") version "2.3.11" apply false
|
||||||
|
id("com.android.library") version "8.2.2" apply false
|
||||||
|
}
|
||||||
81
client/build.gradle.kts
Normal file
81
client/build.gradle.kts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("multiplatform")
|
||||||
|
id("org.jetbrains.compose")
|
||||||
|
kotlin("plugin.serialization")
|
||||||
|
id("com.android.library")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
androidTarget {
|
||||||
|
compilations.all {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jvm("desktop") {
|
||||||
|
compilations.all {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val commonMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":common"))
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.material)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||||
|
|
||||||
|
// Ktor client dependencies for WebSocket connection
|
||||||
|
implementation("io.ktor:ktor-client-core:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-client-websockets:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-client-cio:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val wearMain by creating {
|
||||||
|
dependsOn(commonMain)
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidMain by getting {
|
||||||
|
dependsOn(wearMain)
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "meatbag.client"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "meatbag.client.MainKt"
|
||||||
|
nativeDistributions {
|
||||||
|
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
|
||||||
|
packageName = "meatbag-client"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
|
||||||
|
class AndroidAttentionFeedback(
|
||||||
|
private val notify: suspend (AndroidAttentionSignal) -> Unit
|
||||||
|
) : AttentionFeedback {
|
||||||
|
override suspend fun onAttentionTier(tier: AttentionTier) {
|
||||||
|
notify(tier.toAndroidAttentionSignal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AndroidAttentionSignal {
|
||||||
|
SilentHaptic,
|
||||||
|
BackgroundNotification,
|
||||||
|
OverrideScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AttentionTier.toAndroidAttentionSignal(): AndroidAttentionSignal {
|
||||||
|
return when (this) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> AndroidAttentionSignal.SilentHaptic
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> AndroidAttentionSignal.BackgroundNotification
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> AndroidAttentionSignal.OverrideScreen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
|
||||||
|
interface AttentionFeedback {
|
||||||
|
suspend fun onAttentionTier(tier: AttentionTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
object NoOpAttentionFeedback : AttentionFeedback {
|
||||||
|
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
|
||||||
|
}
|
||||||
476
client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt
Normal file
476
client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.OutlinedTextField
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import meatbag.common.ActionList
|
||||||
|
import meatbag.common.ApprovalResponse
|
||||||
|
import meatbag.common.ApprovalRequest
|
||||||
|
import meatbag.common.Message
|
||||||
|
import meatbag.common.TextInput
|
||||||
|
import meatbag.common.UiPrimitive
|
||||||
|
import meatbag.common.WebSocketPayload
|
||||||
|
|
||||||
|
val DeepBg = Color(0xFF0F111A)
|
||||||
|
val CardBg = Color(0xFF1E2235)
|
||||||
|
val AccentTeal = Color(0xFF00F2FE)
|
||||||
|
val AccentPurple = Color(0xFF9B51E0)
|
||||||
|
val DangerRed = Color(0xFFFF4D4D)
|
||||||
|
val SuccessGreen = Color(0xFF27AE60)
|
||||||
|
val TextPrimary = Color(0xFFFFFFFF)
|
||||||
|
val TextSecondary = Color(0xFF8E92B2)
|
||||||
|
|
||||||
|
data class ComponentRenderState(
|
||||||
|
val payload: WebSocketPayload
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ComponentIntent {
|
||||||
|
val requestId: String
|
||||||
|
|
||||||
|
data class ApprovalDecision(
|
||||||
|
override val requestId: String,
|
||||||
|
val approved: Boolean
|
||||||
|
) : ComponentIntent
|
||||||
|
|
||||||
|
data class ActionSelected(
|
||||||
|
override val requestId: String,
|
||||||
|
val actionId: String
|
||||||
|
) : ComponentIntent
|
||||||
|
|
||||||
|
data class TextSubmitted(
|
||||||
|
override val requestId: String,
|
||||||
|
val text: String
|
||||||
|
) : ComponentIntent
|
||||||
|
|
||||||
|
data class Acknowledged(
|
||||||
|
override val requestId: String,
|
||||||
|
val actionId: String? = null
|
||||||
|
) : ComponentIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ComponentIntent.toApprovalResponse(): ApprovalResponse {
|
||||||
|
return when (this) {
|
||||||
|
is ComponentIntent.Acknowledged -> ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
approved = true,
|
||||||
|
actionId = actionId
|
||||||
|
)
|
||||||
|
is ComponentIntent.ActionSelected -> ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
approved = true,
|
||||||
|
actionId = actionId
|
||||||
|
)
|
||||||
|
is ComponentIntent.ApprovalDecision -> ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
approved = approved
|
||||||
|
)
|
||||||
|
is ComponentIntent.TextSubmitted -> ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
approved = true,
|
||||||
|
text = text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeatbagComponentHost(
|
||||||
|
state: ComponentRenderState,
|
||||||
|
onIntent: (ComponentIntent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
ComponentRegistry.Render(
|
||||||
|
primitive = state.payload.uiPrimitive,
|
||||||
|
onIntent = onIntent,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ComponentRegistry {
|
||||||
|
private val renderers = listOf(
|
||||||
|
ApprovalRequestRenderer,
|
||||||
|
ActionListRenderer,
|
||||||
|
TextInputRenderer,
|
||||||
|
MessageRenderer
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(CardBg)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
AccentTeal.copy(alpha = 0.3f),
|
||||||
|
AccentPurple.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val renderer = renderers.firstOrNull { it.accepts(primitive) }
|
||||||
|
?: error("No renderer registered for primitive id=${primitive.id}")
|
||||||
|
renderer.Render(primitive, onIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface PrimitiveRenderer {
|
||||||
|
fun accepts(primitive: UiPrimitive): Boolean
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ApprovalRequestRenderer : PrimitiveRenderer {
|
||||||
|
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ApprovalRequest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit
|
||||||
|
) {
|
||||||
|
ApprovalRequestComponent(
|
||||||
|
request = primitive as ApprovalRequest,
|
||||||
|
onDecision = { approved ->
|
||||||
|
onIntent(ComponentIntent.ApprovalDecision(primitive.id, approved))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ActionListRenderer : PrimitiveRenderer {
|
||||||
|
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ActionList
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit
|
||||||
|
) {
|
||||||
|
ActionListComponent(
|
||||||
|
list = primitive as ActionList,
|
||||||
|
onActionSelected = { actionId ->
|
||||||
|
onIntent(ComponentIntent.ActionSelected(primitive.id, actionId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object TextInputRenderer : PrimitiveRenderer {
|
||||||
|
override fun accepts(primitive: UiPrimitive): Boolean = primitive is TextInput
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit
|
||||||
|
) {
|
||||||
|
TextInputComponent(
|
||||||
|
input = primitive as TextInput,
|
||||||
|
onSubmit = { text ->
|
||||||
|
onIntent(ComponentIntent.TextSubmitted(primitive.id, text))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object MessageRenderer : PrimitiveRenderer {
|
||||||
|
override fun accepts(primitive: UiPrimitive): Boolean = primitive is Message
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
primitive: UiPrimitive,
|
||||||
|
onIntent: (ComponentIntent) -> Unit
|
||||||
|
) {
|
||||||
|
MessageComponent(
|
||||||
|
message = primitive as Message,
|
||||||
|
onAcknowledge = { actionId ->
|
||||||
|
onIntent(ComponentIntent.Acknowledged(primitive.id, actionId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ApprovalRequestComponent(
|
||||||
|
request: ApprovalRequest,
|
||||||
|
onDecision: (approved: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "TARGET: ${request.target.uppercase()}",
|
||||||
|
color = AccentTeal,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(AccentTeal.copy(alpha = 0.1f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Approval Request",
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(DeepBg, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = request.prompt,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onDecision(false) },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp)
|
||||||
|
) {
|
||||||
|
Text("Reject", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onDecision(true) },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp)
|
||||||
|
) {
|
||||||
|
Text("Approve", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActionListComponent(
|
||||||
|
list: ActionList,
|
||||||
|
onActionSelected: (actionId: String) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = list.title,
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
list.actions.forEach { actionItem ->
|
||||||
|
Button(
|
||||||
|
onClick = { onActionSelected(actionItem.id) },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
AccentTeal.copy(alpha = 0.5f),
|
||||||
|
AccentPurple.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = actionItem.label,
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = ">",
|
||||||
|
color = AccentTeal,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextInputComponent(
|
||||||
|
input: TextInput,
|
||||||
|
onSubmit: (text: String) -> Unit
|
||||||
|
) {
|
||||||
|
var textVal by remember(input.id) { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Input Required",
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = input.prompt,
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textVal,
|
||||||
|
onValueChange = { textVal = it },
|
||||||
|
placeholder = { Text(input.placeholder, color = TextSecondary) },
|
||||||
|
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||||
|
textColor = TextPrimary,
|
||||||
|
focusedBorderColor = AccentTeal,
|
||||||
|
unfocusedBorderColor = TextSecondary.copy(alpha = 0.5f),
|
||||||
|
cursorColor = AccentTeal
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmit(textVal) },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = textVal.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Submit", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageComponent(
|
||||||
|
message: Message,
|
||||||
|
onAcknowledge: (actionId: String?) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.title,
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(DeepBg, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.body,
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onAcknowledge(message.primaryAction?.id) },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = AccentTeal),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = message.primaryAction?.enabled ?: true
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.primaryAction?.label ?: "Acknowledge",
|
||||||
|
color = DeepBg,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt
Normal file
169
client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.plugins.websocket.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import io.ktor.serialization.kotlinx.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
import meatbag.common.WebSocketPayload
|
||||||
|
import meatbag.common.ApprovalResponse
|
||||||
|
import meatbag.common.toWebSocketPayload
|
||||||
|
import meatbag.common.toJson
|
||||||
|
|
||||||
|
sealed interface ClientEvent {
|
||||||
|
data class HapticTrigger(val tier: AttentionTier, val message: String) : ClientEvent
|
||||||
|
data class LogMessage(val text: String, val type: LogType = LogType.INFO) : ClientEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LogType {
|
||||||
|
INFO, SUCCESS, WARNING, ERROR, HAPTIC
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeatbagClient(
|
||||||
|
private val host: String = "localhost",
|
||||||
|
private val port: Int = 8080,
|
||||||
|
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback
|
||||||
|
) {
|
||||||
|
private val jsonConfig = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = HttpClient(CIO) {
|
||||||
|
install(WebSockets) {
|
||||||
|
contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig)
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(jsonConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _currentPayload = MutableStateFlow<WebSocketPayload?>(null)
|
||||||
|
val currentPayload = _currentPayload.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<ClientEvent>(extraBufferCapacity = 100)
|
||||||
|
val events = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private var wsSession: DefaultClientWebSocketSession? = null
|
||||||
|
private var connectionJob: Job? = null
|
||||||
|
|
||||||
|
fun connect(deviceType: String) {
|
||||||
|
connectionJob?.cancel()
|
||||||
|
connectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
|
||||||
|
client.webSocket(
|
||||||
|
method = HttpMethod.Get,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
path = "/ws/connect?deviceType=$deviceType"
|
||||||
|
) {
|
||||||
|
wsSession = this
|
||||||
|
_isConnected.value = true
|
||||||
|
log("Connected successfully as $deviceType!", LogType.SUCCESS)
|
||||||
|
|
||||||
|
for (frame in incoming) {
|
||||||
|
when (frame) {
|
||||||
|
is io.ktor.websocket.Frame.Text -> {
|
||||||
|
val text = frame.readText()
|
||||||
|
log("Received WebSocket frame: $text")
|
||||||
|
try {
|
||||||
|
val payload = text.toWebSocketPayload()
|
||||||
|
log("Parsed payload: primitive=${payload.uiPrimitive.id}, tier=${payload.attentionTier}", LogType.SUCCESS)
|
||||||
|
|
||||||
|
// Trigger haptics, alerts and audio simulations
|
||||||
|
handleAttentionTier(payload.attentionTier)
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
_currentPayload.value = payload
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("Failed to parse WebSocketPayload: ${e.message}", LogType.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_isConnected.value = false
|
||||||
|
log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING)
|
||||||
|
delay(3000)
|
||||||
|
} finally {
|
||||||
|
_isConnected.value = false
|
||||||
|
wsSession = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
connectionJob?.cancel()
|
||||||
|
wsSession = null
|
||||||
|
_isConnected.value = false
|
||||||
|
log("Disconnected by user.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleAttentionTier(tier: AttentionTier) {
|
||||||
|
attentionFeedback.onAttentionTier(tier)
|
||||||
|
val message = when (tier) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> {
|
||||||
|
"[VIBRATION] Double short haptic pulses triggered silently on Wear OS."
|
||||||
|
}
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> {
|
||||||
|
"[SOUND/BANNER] Play standard notification chime + display overlay banner."
|
||||||
|
}
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> {
|
||||||
|
"[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_events.emit(ClientEvent.HapticTrigger(tier, message))
|
||||||
|
log("Attention Action: $message", LogType.HAPTIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun respond(response: ApprovalResponse): Boolean {
|
||||||
|
return try {
|
||||||
|
log("Sending response to POST http://$host:$port/api/respond : ${response.toJson()}")
|
||||||
|
val httpResponse = client.post("http://$host:$port/api/respond") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(response)
|
||||||
|
}
|
||||||
|
if (httpResponse.status.isSuccess()) {
|
||||||
|
log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS)
|
||||||
|
if (_currentPayload.value?.uiPrimitive?.id == response.requestId) {
|
||||||
|
_currentPayload.value = null
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log("Server rejected response: ${httpResponse.status} - ${httpResponse.bodyAsText()}", LogType.ERROR)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("Error sending response: ${e.message}", LogType.ERROR)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(message: String, type: LogType = LogType.INFO) {
|
||||||
|
_events.tryEmit(ClientEvent.LogMessage(message, type))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun injectMockEvent(event: ClientEvent) {
|
||||||
|
_events.tryEmit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
|
||||||
|
class DesktopAttentionFeedback : AttentionFeedback {
|
||||||
|
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
|
||||||
|
}
|
||||||
658
client/src/desktopMain/kotlin/meatbag/client/Main.kt
Normal file
658
client/src/desktopMain/kotlin/meatbag/client/Main.kt
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import meatbag.common.*
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
// Theme Palette (Dark Cyberpunk / Glassmorphism)
|
||||||
|
val SpaceDark = Color(0xFF090A0F)
|
||||||
|
val SurfaceDark = Color(0xFF131520)
|
||||||
|
val ConsoleDark = Color(0xFF07080D)
|
||||||
|
val AccentCyan = Color(0xFF00E5FF)
|
||||||
|
val AccentPurpleGlow = Color(0xFFBD00FF)
|
||||||
|
val ActiveGreen = Color(0xFF00FF66)
|
||||||
|
val AlertOrange = Color(0xFFFF9900)
|
||||||
|
|
||||||
|
data class LogEntry(
|
||||||
|
val timestamp: String,
|
||||||
|
val text: String,
|
||||||
|
val type: LogType
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun handleComponentIntent(
|
||||||
|
intent: ComponentIntent,
|
||||||
|
localMockPayload: WebSocketPayload?,
|
||||||
|
client: MeatbagClient,
|
||||||
|
onMockResolved: () -> Unit,
|
||||||
|
scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
val response = intent.toApprovalResponse()
|
||||||
|
scope.launch {
|
||||||
|
if (localMockPayload?.uiPrimitive?.id == response.requestId) {
|
||||||
|
client.injectMockEvent(
|
||||||
|
ClientEvent.LogMessage(
|
||||||
|
"Mock response triggered: ${response.toJson()}",
|
||||||
|
LogType.SUCCESS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onMockResolved()
|
||||||
|
} else {
|
||||||
|
client.respond(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main() = application {
|
||||||
|
val windowState = rememberWindowState(width = 1280.dp, height = 800.dp)
|
||||||
|
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
state = windowState,
|
||||||
|
title = "Meatbag SDUI Client Simulator"
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colors = darkColors(
|
||||||
|
primary = AccentTeal,
|
||||||
|
secondary = AccentPurple,
|
||||||
|
background = SpaceDark,
|
||||||
|
surface = SurfaceDark
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
SimulatorApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimulatorApp() {
|
||||||
|
var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val networkPayload by client.currentPayload.collectAsState()
|
||||||
|
var localMockPayload by remember { mutableStateOf<WebSocketPayload?>(null) }
|
||||||
|
val activePayload = localMockPayload ?: networkPayload
|
||||||
|
|
||||||
|
val isConnected by client.isConnected.collectAsState()
|
||||||
|
var deviceType by remember { mutableStateOf("WEAR_OS_WATCH") }
|
||||||
|
var hostInput by remember { mutableStateOf("localhost") }
|
||||||
|
var portInput by remember { mutableStateOf("8080") }
|
||||||
|
|
||||||
|
val logs = remember { mutableStateListOf<LogEntry>() }
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
||||||
|
var bannerMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var bannerTier by remember { mutableStateOf<AttentionTier?>(null) }
|
||||||
|
var showBanner by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||||
|
|
||||||
|
// Listen to client events
|
||||||
|
LaunchedEffect(client) {
|
||||||
|
client.events.collectLatest { event ->
|
||||||
|
val timestamp = LocalTime.now().format(timeFormatter)
|
||||||
|
when (event) {
|
||||||
|
is ClientEvent.LogMessage -> {
|
||||||
|
logs.add(LogEntry(timestamp, event.text, event.type))
|
||||||
|
// Auto scroll to bottom
|
||||||
|
if (logs.size > 0) {
|
||||||
|
lazyListState.animateScrollToItem(logs.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClientEvent.HapticTrigger -> {
|
||||||
|
// Show visual banner in simulator
|
||||||
|
bannerTier = event.tier
|
||||||
|
bannerMessage = event.message
|
||||||
|
showBanner = true
|
||||||
|
// Auto hide banner unless it is OVERRIDE_SCREEN
|
||||||
|
if (event.tier != AttentionTier.OVERRIDE_SCREEN) {
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(4000)
|
||||||
|
showBanner = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset banner when payload is resolved/dismissed
|
||||||
|
LaunchedEffect(activePayload) {
|
||||||
|
if (activePayload == null) {
|
||||||
|
showBanner = false
|
||||||
|
bannerMessage = null
|
||||||
|
bannerTier = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(SpaceDark)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// COLUMN 1: CONTROL PANEL & MOCKS
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(320.dp)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Connection Card
|
||||||
|
Card(
|
||||||
|
backgroundColor = SurfaceDark,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Connection Settings",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hostInput,
|
||||||
|
onValueChange = { hostInput = it },
|
||||||
|
label = { Text("Host", color = TextSecondary) },
|
||||||
|
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = portInput,
|
||||||
|
onValueChange = { portInput = it },
|
||||||
|
label = { Text("Port", color = TextSecondary) },
|
||||||
|
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device Selector
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Simulated Device Profile", color = TextSecondary, fontSize = 12.sp)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val profiles = listOf("WEAR_OS_WATCH", "PHONE", "DESKTOP")
|
||||||
|
profiles.forEach { profile ->
|
||||||
|
val isSelected = deviceType == profile
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
deviceType = profile
|
||||||
|
if (isConnected) {
|
||||||
|
client.disconnect()
|
||||||
|
client.connect(profile)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = if (isSelected) AccentTeal else SpaceDark
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.weight(1f).height(36.dp),
|
||||||
|
contentPadding = PaddingValues(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
profile.replace("WEAR_OS_", ""),
|
||||||
|
color = if (isSelected) DeepBg else Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Action
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (isConnected) {
|
||||||
|
client.disconnect()
|
||||||
|
} else {
|
||||||
|
client.disconnect()
|
||||||
|
client = MeatbagClient(
|
||||||
|
host = hostInput,
|
||||||
|
port = portInput.toIntOrNull() ?: 8080,
|
||||||
|
attentionFeedback = DesktopAttentionFeedback()
|
||||||
|
)
|
||||||
|
client.connect(deviceType)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = if (isConnected) DangerRed else SuccessGreen
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isConnected) ActiveGreen else Color.Gray)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (isConnected) "Disconnect" else "Connect WebSocket",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Data Injector Card
|
||||||
|
Card(
|
||||||
|
backgroundColor = SurfaceDark,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Local Ping-Pong Mocking",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Trigger mock payloads to test layout and events without Ktor server running.",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
localMockPayload = WebSocketPayload(
|
||||||
|
attentionTier = AttentionTier.SILENT_HAPTIC,
|
||||||
|
uiPrimitive = ApprovalRequest(
|
||||||
|
id = "mock_approval_1",
|
||||||
|
prompt = "Allow agent to execute 'git push --force origin main'?",
|
||||||
|
target = "deploy-bot"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ApprovalRequest with SILENT_HAPTIC", LogType.INFO))
|
||||||
|
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.SILENT_HAPTIC, "[VIBRATION] Double short haptic pulses triggered silently on Wear OS."))
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
Text("Mock Approval (Silent Haptic)", color = AccentTeal, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
localMockPayload = WebSocketPayload(
|
||||||
|
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
|
||||||
|
uiPrimitive = ActionList(
|
||||||
|
id = "mock_action_2",
|
||||||
|
title = "Select Environment to Deploy",
|
||||||
|
actions = listOf(
|
||||||
|
ActionItem("staging", "Staging Environment (UAT)"),
|
||||||
|
ActionItem("prod", "Production (US-EAST-1)"),
|
||||||
|
ActionItem("sandbox", "Sandbox Playpen")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ActionList with BACKGROUND_NOTIFICATION", LogType.INFO))
|
||||||
|
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.BACKGROUND_NOTIFICATION, "[SOUND/BANNER] Play standard notification chime + display overlay banner."))
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
Text("Mock ActionList (Banner Alert)", color = AccentTeal, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
localMockPayload = WebSocketPayload(
|
||||||
|
attentionTier = AttentionTier.OVERRIDE_SCREEN,
|
||||||
|
uiPrimitive = TextInput(
|
||||||
|
id = "mock_input_3",
|
||||||
|
prompt = "CRITICAL: Database Migration has failed. Type 'CONTINUE' to bypass or 'ABORT' to rollback.",
|
||||||
|
placeholder = "Type command here..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: TextInput with OVERRIDE_SCREEN", LogType.INFO))
|
||||||
|
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.OVERRIDE_SCREEN, "[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."))
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
Text("Mock TextInput (Override)", color = AccentTeal, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (activePayload != null) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
localMockPayload = null
|
||||||
|
client.injectMockEvent(ClientEvent.LogMessage("Mock state cleared.", LogType.INFO))
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed.copy(alpha = 0.2f)),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(40.dp).border(1.dp, DangerRed, RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
Text("Clear Active UI", color = DangerRed, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLUMN 2: SIMULATOR CANVAS (CENTER)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1.5f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
// Visual Attention Banner (Overlay effect)
|
||||||
|
if (showBanner && bannerMessage != null) {
|
||||||
|
val bannerColor = when (bannerTier) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> AccentTeal
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> AlertOrange
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> DangerRed
|
||||||
|
else -> AccentTeal
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
backgroundColor = bannerColor,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (bannerTier) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> Icons.Default.Notifications
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> Icons.Default.Info
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> Icons.Default.Warning
|
||||||
|
else -> Icons.Default.Notifications
|
||||||
|
},
|
||||||
|
contentDescription = "Alert",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "ATTENTION TARGET: ${bannerTier?.name}",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = bannerMessage ?: "",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bannerTier != AttentionTier.OVERRIDE_SCREEN) {
|
||||||
|
IconButton(onClick = { showBanner = false }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "SIMULATOR DEVICE: $deviceType",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device frame rendering
|
||||||
|
if (deviceType == "WEAR_OS_WATCH") {
|
||||||
|
// Round watch frame
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(360.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black)
|
||||||
|
.border(6.dp, Color(0xFF3A3E52), CircleShape)
|
||||||
|
.border(10.dp, Brush.sweepGradient(listOf(AccentTeal, AccentPurple, AccentTeal)), CircleShape)
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (activePayload != null) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
MeatbagComponentHost(
|
||||||
|
state = ComponentRenderState(activePayload!!),
|
||||||
|
onIntent = { intent ->
|
||||||
|
handleComponentIntent(
|
||||||
|
intent = intent,
|
||||||
|
localMockPayload = localMockPayload,
|
||||||
|
client = client,
|
||||||
|
onMockResolved = { localMockPayload = null },
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(32.dp))
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Watch Idle",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Awaiting payload...",
|
||||||
|
color = TextSecondary.copy(alpha = 0.6f),
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Phone / Desktop rectangular frame
|
||||||
|
val frameWidth = if (deviceType == "PHONE") 340.dp else 460.dp
|
||||||
|
val frameHeight = if (deviceType == "PHONE") 580.dp else 360.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = frameWidth, height = frameHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(SpaceDark)
|
||||||
|
.border(6.dp, Color(0xFF2C2F3E), RoundedCornerShape(24.dp))
|
||||||
|
.border(1.dp, AccentPurple, RoundedCornerShape(24.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (activePayload != null) {
|
||||||
|
MeatbagComponentHost(
|
||||||
|
state = ComponentRenderState(activePayload!!),
|
||||||
|
onIntent = { intent ->
|
||||||
|
handleComponentIntent(
|
||||||
|
intent = intent,
|
||||||
|
localMockPayload = localMockPayload,
|
||||||
|
client = client,
|
||||||
|
onMockResolved = { localMockPayload = null },
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(36.dp))
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"$deviceType Idle",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Waiting for Ktor WebSocket payload",
|
||||||
|
color = TextSecondary.copy(alpha = 0.5f),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLUMN 3: EVENT LOGS
|
||||||
|
Card(
|
||||||
|
backgroundColor = SurfaceDark,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(420.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Event Console Logs",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { logs.clear() },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.height(30.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text("Clear", color = TextSecondary, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs list view
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(ConsoleDark)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(logs) { log ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "[${log.timestamp}]",
|
||||||
|
color = TextSecondary,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
|
||||||
|
val typeText = when (log.type) {
|
||||||
|
LogType.INFO -> "INFO"
|
||||||
|
LogType.SUCCESS -> "SUCCESS"
|
||||||
|
LogType.WARNING -> "WARN"
|
||||||
|
LogType.ERROR -> "ERROR"
|
||||||
|
LogType.HAPTIC -> "HAPTIC"
|
||||||
|
}
|
||||||
|
|
||||||
|
val badgeColor = when (log.type) {
|
||||||
|
LogType.INFO -> TextSecondary
|
||||||
|
LogType.SUCCESS -> ActiveGreen
|
||||||
|
LogType.WARNING -> AlertOrange
|
||||||
|
LogType.ERROR -> DangerRed
|
||||||
|
LogType.HAPTIC -> AccentPurple
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$typeText:",
|
||||||
|
color = badgeColor,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.width(60.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = log.text,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package meatbag.client
|
||||||
|
|
||||||
|
import meatbag.common.AttentionTier
|
||||||
|
|
||||||
|
class WearAttentionFeedback(
|
||||||
|
private val trigger: suspend (WearHapticPattern) -> Unit
|
||||||
|
) : AttentionFeedback {
|
||||||
|
override suspend fun onAttentionTier(tier: AttentionTier) {
|
||||||
|
trigger(tier.toWearHapticPattern())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WearHapticPattern {
|
||||||
|
DoubleShort,
|
||||||
|
Notification,
|
||||||
|
UrgentLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AttentionTier.toWearHapticPattern(): WearHapticPattern {
|
||||||
|
return when (this) {
|
||||||
|
AttentionTier.SILENT_HAPTIC -> WearHapticPattern.DoubleShort
|
||||||
|
AttentionTier.BACKGROUND_NOTIFICATION -> WearHapticPattern.Notification
|
||||||
|
AttentionTier.OVERRIDE_SCREEN -> WearHapticPattern.UrgentLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
22
common/build.gradle.kts
Normal file
22
common/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("multiplatform")
|
||||||
|
kotlin("plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvm() // Targets the JVM. Additional targets (android, ios, etc.) can be configured later.
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val commonMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val commonTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
351
common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt
Normal file
351
common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package meatbag.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class UiPrimitive {
|
||||||
|
abstract val id: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("ApprovalRequest")
|
||||||
|
data class ApprovalRequest(
|
||||||
|
override val id: String,
|
||||||
|
val prompt: String,
|
||||||
|
val target: String,
|
||||||
|
val title: String = "Approval required",
|
||||||
|
val body: String? = null,
|
||||||
|
val approveAction: ActionItem = ActionItem(
|
||||||
|
id = "approve",
|
||||||
|
label = "Approve",
|
||||||
|
intent = ActionIntent.APPROVE,
|
||||||
|
),
|
||||||
|
val rejectAction: ActionItem = ActionItem(
|
||||||
|
id = "reject",
|
||||||
|
label = "Reject",
|
||||||
|
intent = ActionIntent.REJECT,
|
||||||
|
style = ActionStyle.DESTRUCTIVE,
|
||||||
|
),
|
||||||
|
val timeoutMs: Long? = null,
|
||||||
|
val callback: CallbackRequest? = null,
|
||||||
|
) : UiPrimitive()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("ActionList")
|
||||||
|
data class ActionList(
|
||||||
|
override val id: String,
|
||||||
|
val title: String,
|
||||||
|
val actions: List<ActionItem>,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
val selectionMode: SelectionMode = SelectionMode.SINGLE,
|
||||||
|
) : UiPrimitive()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("TextInput")
|
||||||
|
data class TextInput(
|
||||||
|
override val id: String,
|
||||||
|
val prompt: String,
|
||||||
|
val placeholder: String,
|
||||||
|
val title: String = prompt,
|
||||||
|
val submitAction: ActionItem = ActionItem(
|
||||||
|
id = "submit",
|
||||||
|
label = "Submit",
|
||||||
|
intent = ActionIntent.SUBMIT,
|
||||||
|
),
|
||||||
|
val callback: CallbackRequest? = null,
|
||||||
|
) : UiPrimitive()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Message")
|
||||||
|
data class Message(
|
||||||
|
override val id: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val tone: MessageTone = MessageTone.INFO,
|
||||||
|
val primaryAction: ActionItem? = null,
|
||||||
|
) : UiPrimitive()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ActionItem(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val intent: ActionIntent = ActionIntent.CUSTOM,
|
||||||
|
val style: ActionStyle = ActionStyle.DEFAULT,
|
||||||
|
val callback: CallbackRequest? = null,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ActionIntent {
|
||||||
|
@SerialName("APPROVE")
|
||||||
|
APPROVE,
|
||||||
|
|
||||||
|
@SerialName("REJECT")
|
||||||
|
REJECT,
|
||||||
|
|
||||||
|
@SerialName("ACKNOWLEDGE")
|
||||||
|
ACKNOWLEDGE,
|
||||||
|
|
||||||
|
@SerialName("SUBMIT")
|
||||||
|
SUBMIT,
|
||||||
|
|
||||||
|
@SerialName("CUSTOM")
|
||||||
|
CUSTOM,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ActionStyle {
|
||||||
|
@SerialName("DEFAULT")
|
||||||
|
DEFAULT,
|
||||||
|
|
||||||
|
@SerialName("PRIMARY")
|
||||||
|
PRIMARY,
|
||||||
|
|
||||||
|
@SerialName("DESTRUCTIVE")
|
||||||
|
DESTRUCTIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SelectionMode {
|
||||||
|
@SerialName("SINGLE")
|
||||||
|
SINGLE,
|
||||||
|
|
||||||
|
@SerialName("MULTIPLE")
|
||||||
|
MULTIPLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class MessageTone {
|
||||||
|
@SerialName("INFO")
|
||||||
|
INFO,
|
||||||
|
|
||||||
|
@SerialName("SUCCESS")
|
||||||
|
SUCCESS,
|
||||||
|
|
||||||
|
@SerialName("WARNING")
|
||||||
|
WARNING,
|
||||||
|
|
||||||
|
@SerialName("ERROR")
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AttentionTier {
|
||||||
|
@SerialName("SILENT_HAPTIC")
|
||||||
|
SILENT_HAPTIC,
|
||||||
|
|
||||||
|
@SerialName("BACKGROUND_NOTIFICATION")
|
||||||
|
BACKGROUND_NOTIFICATION,
|
||||||
|
|
||||||
|
@SerialName("OVERRIDE_SCREEN")
|
||||||
|
OVERRIDE_SCREEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AttentionPolicy(
|
||||||
|
val tier: AttentionTier,
|
||||||
|
val hapticPattern: HapticPattern? = null,
|
||||||
|
val presentation: PresentationMode = PresentationMode.AUTO,
|
||||||
|
val expiresAtEpochMs: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class HapticPattern {
|
||||||
|
@SerialName("NONE")
|
||||||
|
NONE,
|
||||||
|
|
||||||
|
@SerialName("SHORT")
|
||||||
|
SHORT,
|
||||||
|
|
||||||
|
@SerialName("DOUBLE_SHORT")
|
||||||
|
DOUBLE_SHORT,
|
||||||
|
|
||||||
|
@SerialName("URGENT_LOOP")
|
||||||
|
URGENT_LOOP,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class PresentationMode {
|
||||||
|
@SerialName("AUTO")
|
||||||
|
AUTO,
|
||||||
|
|
||||||
|
@SerialName("WATCH_ONLY")
|
||||||
|
WATCH_ONLY,
|
||||||
|
|
||||||
|
@SerialName("PHONE_ONLY")
|
||||||
|
PHONE_ONLY,
|
||||||
|
|
||||||
|
@SerialName("ALL_DEVICES")
|
||||||
|
ALL_DEVICES,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RequestContext(
|
||||||
|
val requestId: String,
|
||||||
|
val source: RequestSource,
|
||||||
|
val correlationId: String? = null,
|
||||||
|
val createdAtEpochMs: Long? = null,
|
||||||
|
val callback: CallbackRequest? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RequestSource(
|
||||||
|
val system: String,
|
||||||
|
val actor: String? = null,
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceContext(
|
||||||
|
val deviceId: String,
|
||||||
|
val deviceClass: DeviceClass,
|
||||||
|
val platform: DevicePlatform,
|
||||||
|
val capabilities: Set<DeviceCapability> = emptySet(),
|
||||||
|
val locale: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DeviceClass {
|
||||||
|
@SerialName("PHONE")
|
||||||
|
PHONE,
|
||||||
|
|
||||||
|
@SerialName("WEARABLE")
|
||||||
|
WEARABLE,
|
||||||
|
|
||||||
|
@SerialName("DESKTOP")
|
||||||
|
DESKTOP,
|
||||||
|
|
||||||
|
@SerialName("TABLET")
|
||||||
|
TABLET,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DevicePlatform {
|
||||||
|
@SerialName("ANDROID")
|
||||||
|
ANDROID,
|
||||||
|
|
||||||
|
@SerialName("WEAR_OS")
|
||||||
|
WEAR_OS,
|
||||||
|
|
||||||
|
@SerialName("IOS")
|
||||||
|
IOS,
|
||||||
|
|
||||||
|
@SerialName("DESKTOP")
|
||||||
|
DESKTOP,
|
||||||
|
|
||||||
|
@SerialName("WEB")
|
||||||
|
WEB,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DeviceCapability {
|
||||||
|
@SerialName("HAPTICS")
|
||||||
|
HAPTICS,
|
||||||
|
|
||||||
|
@SerialName("NOTIFICATIONS")
|
||||||
|
NOTIFICATIONS,
|
||||||
|
|
||||||
|
@SerialName("FULL_SCREEN_INTENT")
|
||||||
|
FULL_SCREEN_INTENT,
|
||||||
|
|
||||||
|
@SerialName("TEXT_INPUT")
|
||||||
|
TEXT_INPUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CallbackRequest(
|
||||||
|
val id: String,
|
||||||
|
val target: CallbackTarget,
|
||||||
|
val method: CallbackMethod = CallbackMethod.POST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class CallbackTarget {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Http")
|
||||||
|
data class Http(
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
|
) : CallbackTarget()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("WebSocket")
|
||||||
|
data class WebSocket(
|
||||||
|
val channel: String,
|
||||||
|
) : CallbackTarget()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Local")
|
||||||
|
data class Local(
|
||||||
|
val handler: String,
|
||||||
|
) : CallbackTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class CallbackMethod {
|
||||||
|
@SerialName("POST")
|
||||||
|
POST,
|
||||||
|
|
||||||
|
@SerialName("PUT")
|
||||||
|
PUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketPayload(
|
||||||
|
val attentionTier: AttentionTier,
|
||||||
|
val uiPrimitive: UiPrimitive,
|
||||||
|
val request: RequestContext? = null,
|
||||||
|
val deviceContext: DeviceContext? = null,
|
||||||
|
val attentionPolicy: AttentionPolicy = AttentionPolicy(attentionTier),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApprovalResponse(
|
||||||
|
val requestId: String,
|
||||||
|
val approved: Boolean,
|
||||||
|
val actionId: String? = null,
|
||||||
|
val text: String? = null,
|
||||||
|
val callbackId: String? = null,
|
||||||
|
val deviceId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object SDUIJson {
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeToString(primitive: UiPrimitive): String {
|
||||||
|
return json.encodeToString(primitive)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeFromString(jsonString: String): UiPrimitive {
|
||||||
|
return json.decodeFromString(jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeAttentionTier(tier: AttentionTier): String {
|
||||||
|
return json.encodeToString(tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeAttentionTier(jsonString: String): AttentionTier {
|
||||||
|
return json.decodeFromString(jsonString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UiPrimitive.toJson(): String = SDUIJson.encodeToString(this)
|
||||||
|
fun String.toUiPrimitive(): UiPrimitive = SDUIJson.decodeFromString(this)
|
||||||
|
|
||||||
|
fun AttentionTier.toJson(): String = SDUIJson.encodeAttentionTier(this)
|
||||||
|
fun String.toAttentionTier(): AttentionTier = SDUIJson.decodeAttentionTier(this)
|
||||||
|
|
||||||
|
fun WebSocketPayload.toJson(): String = SDUIJson.json.encodeToString(this)
|
||||||
|
fun String.toWebSocketPayload(): WebSocketPayload = SDUIJson.json.decodeFromString(this)
|
||||||
|
|
||||||
|
fun ApprovalResponse.toJson(): String = SDUIJson.json.encodeToString(this)
|
||||||
|
fun String.toApprovalResponse(): ApprovalResponse = SDUIJson.json.decodeFromString(this)
|
||||||
145
common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt
Normal file
145
common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package meatbag.common
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class UiPrimitiveTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testApprovalRequestSerialization() {
|
||||||
|
val approval = ApprovalRequest(
|
||||||
|
id = "req-1",
|
||||||
|
prompt = "Grant admin access?",
|
||||||
|
target = "prod-db",
|
||||||
|
callback = CallbackRequest(
|
||||||
|
id = "cb-1",
|
||||||
|
target = CallbackTarget.Http("https://backend.test/approval"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val json = approval.toJson()
|
||||||
|
assertTrue(json.contains("\"type\": \"ApprovalRequest\""))
|
||||||
|
assertTrue(json.contains("\"id\": \"req-1\""))
|
||||||
|
assertTrue(json.contains("\"prompt\": \"Grant admin access?\""))
|
||||||
|
assertTrue(json.contains("\"target\": \"prod-db\""))
|
||||||
|
assertTrue(json.contains("\"intent\": \"APPROVE\""))
|
||||||
|
assertTrue(json.contains("\"type\": \"Http\""))
|
||||||
|
|
||||||
|
val decoded = json.toUiPrimitive()
|
||||||
|
assertTrue(decoded is ApprovalRequest)
|
||||||
|
assertEquals(approval.id, decoded.id)
|
||||||
|
assertEquals(approval.prompt, decoded.prompt)
|
||||||
|
assertEquals(approval.target, decoded.target)
|
||||||
|
assertEquals(ActionIntent.APPROVE, decoded.approveAction.intent)
|
||||||
|
assertTrue(decoded.callback?.target is CallbackTarget.Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testActionListSerialization() {
|
||||||
|
val actionList = ActionList(
|
||||||
|
id = "act-1",
|
||||||
|
title = "Choose Action",
|
||||||
|
actions = listOf(
|
||||||
|
ActionItem("1", "Acknowledge"),
|
||||||
|
ActionItem("2", "Reject")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val json = actionList.toJson()
|
||||||
|
assertTrue(json.contains("\"type\": \"ActionList\""))
|
||||||
|
assertTrue(json.contains("\"title\": \"Choose Action\""))
|
||||||
|
|
||||||
|
val decoded = json.toUiPrimitive()
|
||||||
|
assertTrue(decoded is ActionList)
|
||||||
|
assertEquals(2, decoded.actions.size)
|
||||||
|
assertEquals("Acknowledge", decoded.actions[0].label)
|
||||||
|
assertEquals(ActionIntent.CUSTOM, decoded.actions[0].intent)
|
||||||
|
assertEquals(SelectionMode.SINGLE, decoded.selectionMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTextInputSerialization() {
|
||||||
|
val textInput = TextInput(
|
||||||
|
id = "txt-1",
|
||||||
|
prompt = "Enter passcode",
|
||||||
|
placeholder = "1234"
|
||||||
|
)
|
||||||
|
val json = textInput.toJson()
|
||||||
|
assertTrue(json.contains("\"type\": \"TextInput\""))
|
||||||
|
|
||||||
|
val decoded = json.toUiPrimitive()
|
||||||
|
assertTrue(decoded is TextInput)
|
||||||
|
assertEquals("1234", decoded.placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAttentionTierSerialization() {
|
||||||
|
val tier = AttentionTier.SILENT_HAPTIC
|
||||||
|
val json = tier.toJson()
|
||||||
|
assertEquals("\"SILENT_HAPTIC\"", json.trim())
|
||||||
|
|
||||||
|
val decoded = json.toAttentionTier()
|
||||||
|
assertEquals(tier, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWebSocketPayloadSerialization() {
|
||||||
|
val payload = WebSocketPayload(
|
||||||
|
attentionTier = AttentionTier.OVERRIDE_SCREEN,
|
||||||
|
uiPrimitive = Message(
|
||||||
|
id = "msg-1",
|
||||||
|
title = "Manual intervention",
|
||||||
|
body = "A deployment requires attention.",
|
||||||
|
tone = MessageTone.WARNING,
|
||||||
|
),
|
||||||
|
request = RequestContext(
|
||||||
|
requestId = "req-2",
|
||||||
|
source = RequestSource(system = "deployments", actor = "agent-7"),
|
||||||
|
callback = CallbackRequest(
|
||||||
|
id = "cb-2",
|
||||||
|
target = CallbackTarget.WebSocket(channel = "approvals"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
deviceContext = DeviceContext(
|
||||||
|
deviceId = "watch-1",
|
||||||
|
deviceClass = DeviceClass.WEARABLE,
|
||||||
|
platform = DevicePlatform.WEAR_OS,
|
||||||
|
capabilities = setOf(
|
||||||
|
DeviceCapability.HAPTICS,
|
||||||
|
DeviceCapability.FULL_SCREEN_INTENT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
attentionPolicy = AttentionPolicy(
|
||||||
|
tier = AttentionTier.OVERRIDE_SCREEN,
|
||||||
|
hapticPattern = HapticPattern.URGENT_LOOP,
|
||||||
|
presentation = PresentationMode.ALL_DEVICES,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = payload.toJson()
|
||||||
|
assertTrue(json.contains("\"type\": \"Message\""))
|
||||||
|
assertTrue(json.contains("\"type\": \"WebSocket\""))
|
||||||
|
assertTrue(json.contains("\"tier\": \"OVERRIDE_SCREEN\""))
|
||||||
|
assertTrue(json.contains("\"deviceClass\": \"WEARABLE\""))
|
||||||
|
|
||||||
|
val decoded = json.toWebSocketPayload()
|
||||||
|
assertEquals(AttentionTier.OVERRIDE_SCREEN, decoded.attentionTier)
|
||||||
|
assertTrue(decoded.uiPrimitive is Message)
|
||||||
|
assertEquals("req-2", decoded.request?.requestId)
|
||||||
|
assertEquals(DevicePlatform.WEAR_OS, decoded.deviceContext?.platform)
|
||||||
|
assertEquals(HapticPattern.URGENT_LOOP, decoded.attentionPolicy.hapticPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testApprovalResponseSerialization() {
|
||||||
|
val response = ApprovalResponse(
|
||||||
|
requestId = "req-3",
|
||||||
|
approved = true,
|
||||||
|
actionId = "approve",
|
||||||
|
callbackId = "cb-3",
|
||||||
|
deviceId = "phone-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val decoded = response.toJson().toApprovalResponse()
|
||||||
|
assertEquals(response, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
gradle.properties
Normal file
2
gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
kotlin.native.ignoreDisabledTargets=true
|
||||||
35
gradle/libs.versions.toml
Normal file
35
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
[versions]
|
||||||
|
kotlin = "1.9.23"
|
||||||
|
agp = "8.2.2"
|
||||||
|
compose-multiplatform = "1.6.1"
|
||||||
|
kotlinx-serialization = "1.6.3"
|
||||||
|
androidx-lifecycle = "2.7.0"
|
||||||
|
androidx-savedstate = "1.2.1"
|
||||||
|
androidx-activity = "1.8.2"
|
||||||
|
wear-compose = "1.3.1"
|
||||||
|
play-services-wearable = "18.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
|
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
|
||||||
|
compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" }
|
||||||
|
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
|
||||||
|
# Android Lifecycle IME dependencies
|
||||||
|
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
|
||||||
|
androidx-savedstate = { module = "androidx.savedstate:savedstate-ktx", version.ref = "androidx-savedstate" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
|
|
||||||
|
# Wear OS dependencies
|
||||||
|
wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" }
|
||||||
|
wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" }
|
||||||
|
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
185
gradlew
vendored
Executable file
185
gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
63
handoffs/session_handoff.md
Normal file
63
handoffs/session_handoff.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Project Meatbag: Session Handoff
|
||||||
|
|
||||||
|
**Date**: June 27, 2026
|
||||||
|
**Ecosystem**: WhetForge
|
||||||
|
**Goal**: Scaffolding and Implementing the MVP for **Meatbag**, a multiplatform "Human Latency Optimization Tool" for server-driven UI (SDUI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What We Did This Session
|
||||||
|
We initiated the project from scratch, structured it as a Kotlin Multiplatform Gradle project, and developed a stateful routing backend and a stateless Compose Multiplatform client. The session focused on achieving a successful end-to-end ping-pong loop:
|
||||||
|
1. An agent submits an `ApprovalRequest` via HTTP POST to the backend.
|
||||||
|
2. The backend determines the target device and attention tier based on user context rules, then suspends the request.
|
||||||
|
3. The backend notifies the target device via WebSockets.
|
||||||
|
4. The client simulates haptics (or alerts) based on the `AttentionTier`, parses the JSON UI primitive, and inflates the UI dynamically.
|
||||||
|
5. The human taps "Yes" / interacts, sending a response to the backend.
|
||||||
|
6. The backend resumes the suspended agent request and returns the response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Project Architecture
|
||||||
|
The project directory is structured as a multi-module Kotlin project:
|
||||||
|
|
||||||
|
* **`:common` (Kotlin Multiplatform)**
|
||||||
|
* [UiPrimitive.kt](file:///Users/blindmouse/meatbag/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt): Declares the sealed hierarchy representing JSON UI elements (`UiPrimitive` with sub-types `ApprovalRequest`, `ActionList`, `TextInput`), the `AttentionTier` enum (`SILENT_HAPTIC`, `BACKGROUND_NOTIFICATION`, `OVERRIDE_SCREEN`), and common payload envelopes (`WebSocketPayload`, `ApprovalResponse`). Includes polymorphic serialization helpers.
|
||||||
|
* [UiPrimitiveTest.kt](file:///Users/blindmouse/meatbag/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt): Verifies correct serialization and parsing with class discriminators.
|
||||||
|
|
||||||
|
* **`:backend` (Ktor/JVM)**
|
||||||
|
* [Application.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/Application.kt): Scaffolds Ktor Netty, sets up CORS/WebSockets/ContentNegotiation plugins, exposes client-facing WebSocket (`/ws/connect`), response handler (`/api/respond`), agent request (`/api/request`), and diagnostic endpoints. Includes context-aware urgency auto-detection.
|
||||||
|
* [DeviceRegistry.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt): Registers connected active client devices dynamically.
|
||||||
|
* [RoutingEngine.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt): Implements attention-based routing (e.g. routes urgent/silent haptics to Wear OS if active, otherwise falls back to phone/desktop) and holds coroutine suspend contexts via `CompletableDeferred` pending user callbacks.
|
||||||
|
* [RoutingEngineTest.kt](file:///Users/blindmouse/meatbag/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt): Fully verifies WebSocket connections, payload transmission, attention tier routing, and callback resume resolution under Ktor's `testApplication` engine.
|
||||||
|
|
||||||
|
* **`:client` (Compose Multiplatform / Desktop target)**
|
||||||
|
* [ComponentRegistry.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt): dynamic Compose renderer converting `UiPrimitive` polymorphically into premium, dark cyberpunk UI components using Unidirectional Data Flow (UDF) callbacks.
|
||||||
|
* [MeatbagClient.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt): Manages WebSocket connections and POST responses. Decodes payloads and triggers simulated attention effects (vibrations, sound chimes, or display takeover overlays).
|
||||||
|
* [Main.kt](file:///Users/blindmouse/meatbag/client/src/desktopMain/kotlin/meatbag/client/Main.kt): Cyberpunk glassmorphic client simulator. Displays connection logs and simulates haptic profiles (Smartwatch vs. Phone vs. Desktop) and injects local offline mock payloads for isolated UI debugging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration & Dependency Fixes Applied
|
||||||
|
During compilation and verification, several compatibility fixes were successfully applied:
|
||||||
|
1. **Plugin Portal Registration**: Configured `gradlePluginPortal()` in `settings.gradle.kts` to allow resolution of the Ktor and Kotlin JVM plugins.
|
||||||
|
2. **Kotlin Compiler Version Alignment**: Managed Kotlin compiler plugin versions centrally in the root `build.gradle.kts` and applied them cleanly inside sub-modules to prevent classloader duplicate classpath conflicts.
|
||||||
|
3. **Obsolete Property Removal**: Removed the obsolete `kotlin.mpp.enableGranularSourceSetsMetadata=true` from `gradle.properties` to fix Kotlin Gradle compiler configuration errors.
|
||||||
|
4. **Client Test Classpath**: Added Ktor Client content negotiation and websocket JVM targets to `:backend` test dependencies so that Ktor's test client works flawlessly during JUnit runs.
|
||||||
|
5. **Client Simulator Fixes**:
|
||||||
|
* Fixed window size dimensions to use `Dp` units (`1280.dp`, `800.dp`).
|
||||||
|
* Resolved Ktor 2.x `Frame.Text` read extension function resolution by importing `io.ktor.websocket.readText`.
|
||||||
|
* Refactored client `log` helpers to be non-suspending (using flow `tryEmit` instead of `emit`), and introduced a public `injectMockEvent` helper to safely feed mock events from the simulator UI without coroutine thread blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Current Status
|
||||||
|
* **Compilation Status**: `BUILD SUCCESSFUL` for all modules and targets.
|
||||||
|
* **Testing Status**: Common serialization and backend integration routing tests pass successfully.
|
||||||
|
* **Active Subagents**: Cleaned up and terminated all background subagent processes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Next Steps
|
||||||
|
1. **Develop Android/Wear OS Platform Code**: Implement physical haptic motor triggers (`Vibrator` and `VibratorManager`) in `wearMain` and push banner notifications in `androidMain`.
|
||||||
|
2. **Persistence Layer**: Implement a database (e.g., SQLite via SQLDelight or PostgreSQL) to persist user rule-sets and past agent requests.
|
||||||
|
3. **Authentication**: Secure endpoints using API keys for CLI agents and OAuth/Device Tokens for clients.
|
||||||
79
research/sdui_and_haptics_research.md
Normal file
79
research/sdui_and_haptics_research.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Meatbag Research: SDUI Inflation, Wear OS Haptics, and Server Push
|
||||||
|
|
||||||
|
This document summarizes architectural research for the Meatbag platform, focusing on performance, latency, and platform-specific haptic patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Server-Driven UI (SDUI) in Compose Multiplatform
|
||||||
|
|
||||||
|
### The Reflection-Free Pattern
|
||||||
|
To meet performance constraints and avoid runtime performance degradation on lightweight clients (such as Wear OS watches), Meatbag implements a strictly typed, reflection-free Component Registry.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ComponentRegistry(primitive: UiPrimitive, onAction: (ApprovalResponse) -> Unit) {
|
||||||
|
when (primitive) {
|
||||||
|
is ApprovalRequest -> ApprovalRequestCard(primitive, onAction)
|
||||||
|
is ActionList -> ActionListContainer(primitive, onAction)
|
||||||
|
is TextInput -> TextInputField(primitive, onAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Advantage**: Compile-time safety. Polymorphic serialization (via `kotlinx.serialization` with custom class discriminators like `"type"`) ensures payloads map directly to Kotlin data types without dynamic inspection.
|
||||||
|
* **State Management**: Adheres to Unidirectional Data Flow (UDF). Primitives are read-only configuration blueprints; interactive widgets emit state updates upward through explicit event callbacks (`onAction`), keeping clients entirely stateless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Wear OS Haptic Feedback APIs
|
||||||
|
|
||||||
|
To minimize human response times, we utilize precise physical notifications based on the resolved `AttentionTier`.
|
||||||
|
|
||||||
|
### Android / Wear OS API Mapping
|
||||||
|
For triggering haptics on physical Wear OS smartwatches, the system integrates with Android's hardware `Vibrator` API.
|
||||||
|
|
||||||
|
#### 1. Predefined Effects (Android SDK 29+)
|
||||||
|
Modern Wear OS devices support the `VibrationEffect` API, which yields crisp, hardware-optimized clicks rather than fuzzy motor rumbling.
|
||||||
|
* **`AttentionTier.SILENT_HAPTIC`**: Double short pulses to signal prompt urgency without making sound.
|
||||||
|
```kotlin
|
||||||
|
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
val effect = VibrationEffect.startComposition()
|
||||||
|
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
|
||||||
|
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150) // 150ms delay
|
||||||
|
.compose()
|
||||||
|
vibrator.vibrate(effect)
|
||||||
|
```
|
||||||
|
* **`AttentionTier.BACKGROUND_NOTIFICATION`**: A light tick effect.
|
||||||
|
```kotlin
|
||||||
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||||
|
```
|
||||||
|
* **`AttentionTier.OVERRIDE_SCREEN`**: Repetitive pulsing alert composition.
|
||||||
|
```kotlin
|
||||||
|
val timings = longArrayOf(0, 200, 100, 200, 100, 500) // delay, vibe, pause, vibe...
|
||||||
|
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
|
||||||
|
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Legacy Fallbacks (Android SDK < 29)
|
||||||
|
* For older Wear OS versions, fallback to deprecated pattern arrays:
|
||||||
|
```kotlin
|
||||||
|
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Push Architecture: WebSockets vs. SSE
|
||||||
|
|
||||||
|
Smartwatches are subject to battery constraints and intermittent connectivity. The push transport layer must be responsive yet lightweight.
|
||||||
|
|
||||||
|
| Feature | WebSockets | Server-Sent Events (SSE) |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Protocol** | Bi-directional, custom framing | Uni-directional (Server -> Client), HTTP/1.1 or HTTP/2 |
|
||||||
|
| **Connection Overhead** | Low (post-handshake) | Extremely low (standard HTTP connection) |
|
||||||
|
| **Battery Impact** | Higher (constant connection maintenance) | Lower (leverages HTTP client connection pooling) |
|
||||||
|
| **Firewall Traversal** | Can be blocked by strict proxy rules | Standard HTTP, easily passes through proxies |
|
||||||
|
| **Meatbag MVP Selection** | **WebSockets** (Allows immediate client confirmation frames and bi-directional pings) | **Candidate for mobile clients** (Fallback for phone targets via standard APNS / FCM) |
|
||||||
|
|
||||||
|
### Recommended Hybrid Strategy
|
||||||
|
1. **Wear OS Smartwatches**: Utilize persistent WebSockets with optimized ping-pong intervals (e.g. 30 seconds) when the app is active in the foreground.
|
||||||
|
2. **Mobile Phones (iOS & Android)**: Fall back to native push notification systems (FCM/APNS) to wake up the client, which then opens a short-lived WebSocket or POST connection to render the SDUI primitive.
|
||||||
20
settings.gradle.kts
Normal file
20
settings.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "meatbag"
|
||||||
|
include(":common")
|
||||||
|
include(":backend")
|
||||||
|
include(":client")
|
||||||
Reference in New Issue
Block a user