Initialize Meatbag SDUI platform
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user