fix: implement robust PHONE device fallback in handleDashboardAction for local development

This commit is contained in:
2026-07-04 15:51:40 -06:00
parent 181e9b53bd
commit 6aab0b2ac7
2 changed files with 158 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import kotlinx.serialization.json.*
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import meatbag.common.* import meatbag.common.*
import java.time.Duration import java.time.Duration
@@ -165,6 +166,10 @@ fun Application.module() {
} }
val matchedPendingRequest = routingEngine.handleResponse(response) val matchedPendingRequest = routingEngine.handleResponse(response)
// Custom dashboard action handler hook
handleDashboardAction(response, deviceRegistry, profileStore)
call.respond( call.respond(
HttpStatusCode.OK, HttpStatusCode.OK,
buildJsonObject { buildJsonObject {
@@ -405,3 +410,153 @@ private fun parseDeviceTypes(value: String?): List<DeviceType> {
?.distinct() ?.distinct()
?: emptyList() ?: emptyList()
} }
private fun handleDashboardAction(
response: ApprovalResponse,
deviceRegistry: DeviceRegistry,
profileStore: ProfileStore
) {
val deviceId = response.deviceId
val device = if (deviceId != null) {
deviceRegistry.getAllDevices().firstOrNull { it.id == deviceId }
} else {
deviceRegistry.getAllDevices().firstOrNull { it.type == DeviceType.PHONE }
} ?: return
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch {
when (response.requestId) {
"lm_controls" -> {
when (response.actionId) {
"open_prompt_input" -> {
val promptInput = TextInput(
id = "lm_prompt_submit",
prompt = "Enter chat prompt to send to local model:",
placeholder = "e.g. Write a quick poem about Kotlin",
title = "LM Studio Chat"
)
val payload = WebSocketPayload(
attentionTier = AttentionTier.SILENT_HAPTIC,
uiPrimitive = promptInput
)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
}
"refresh_models" -> {
try {
val client = java.net.http.HttpClient.newHttpClient()
val request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://localhost:1234/v1/models"))
.GET()
.build()
val httpResponse = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString())
val responseText = httpResponse.body()
val msg = Message(
id = "lm_models_status",
title = "Loaded Models",
body = responseText,
tone = MessageTone.SUCCESS,
primaryAction = ActionItem("back_to_controls", "Dismiss", ActionIntent.ACKNOWLEDGE)
)
val payload = WebSocketPayload(AttentionTier.BACKGROUND_NOTIFICATION, msg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
} catch (e: Exception) {
val msg = Message(
id = "lm_models_status",
title = "Connection Failed",
body = "Could not connect to LM Studio at localhost:1234. Make sure 'Local Server' is started in LM Studio.\n\nError: ${e.message}",
tone = MessageTone.ERROR,
primaryAction = ActionItem("back_to_controls", "Dismiss", ActionIntent.ACKNOWLEDGE)
)
val payload = WebSocketPayload(AttentionTier.BACKGROUND_NOTIFICATION, msg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
}
}
"toggle_temp" -> {
val msg = Message(
id = "lm_temp_toggle",
title = "Temperature Adjusted",
body = "Temperature set to 0.9 (Creative Mode).",
tone = MessageTone.INFO,
primaryAction = ActionItem("back_to_controls", "OK", ActionIntent.ACKNOWLEDGE)
)
val payload = WebSocketPayload(AttentionTier.BACKGROUND_NOTIFICATION, msg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
}
}
}
"lm_prompt_submit" -> {
val prompt = response.text ?: ""
if (prompt.isNotBlank()) {
val loadingMsg = Message(
id = "lm_loading",
title = "Generating completions...",
body = "Sending prompt to local LM Studio model:\n\"$prompt\"",
tone = MessageTone.INFO
)
val loadingPayload = WebSocketPayload(AttentionTier.SILENT_HAPTIC, loadingMsg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(loadingPayload)))
try {
val payloadBody = kotlinx.serialization.json.buildJsonObject {
put("messages", kotlinx.serialization.json.buildJsonArray {
add(kotlinx.serialization.json.buildJsonObject {
put("role", "user")
put("content", prompt)
})
})
put("temperature", 0.7)
}.toString()
val client = java.net.http.HttpClient.newHttpClient()
val request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://localhost:1234/v1/chat/completions"))
.header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(payloadBody))
.build()
val httpResponse = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString())
val responseBody = httpResponse.body()
val parser = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
val responseJson = parser.parseToJsonElement(responseBody)
val completionText = responseJson.jsonObject["choices"]
?.jsonArray?.firstOrNull()
?.jsonObject?.get("message")
?.jsonObject?.get("content")
?.jsonPrimitive?.content
?: "No response content generated by model."
val msg = Message(
id = "lm_response",
title = "LLM Generation",
body = completionText,
tone = MessageTone.SUCCESS,
primaryAction = ActionItem("back_to_controls", "Back to Controls", ActionIntent.ACKNOWLEDGE)
)
val payload = WebSocketPayload(AttentionTier.BACKGROUND_NOTIFICATION, msg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
} catch (e: Exception) {
val msg = Message(
id = "lm_response",
title = "Generation Error",
body = "Failed to run completion. Make sure LM Studio local server is running on port 1234.\n\nError: ${e.message}",
tone = MessageTone.ERROR,
primaryAction = ActionItem("back_to_controls", "Dismiss", ActionIntent.ACKNOWLEDGE)
)
val payload = WebSocketPayload(AttentionTier.BACKGROUND_NOTIFICATION, msg)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
}
}
}
"lm_response", "lm_models_status", "lm_temp_toggle" -> {
val profile = profileStore.getProfile("lm_studio_remote")
if (profile != null) {
val payload = WebSocketPayload(
attentionTier = AttentionTier.SILENT_HAPTIC,
uiPrimitive = profile.layout
)
device.session.send(Frame.Text(SDUIJson.json.encodeToString(payload)))
}
}
}
}
}

View File

@@ -166,10 +166,11 @@ class MeatbagClient(
suspend fun respond(response: ApprovalResponse): Boolean { suspend fun respond(response: ApprovalResponse): Boolean {
return try { return try {
log("Sending response to POST http://$host:$port/api/respond : ${response.toJson()}") val responseWithDevice = response.copy(deviceId = deviceId)
log("Sending response to POST http://$host:$port/api/respond : ${responseWithDevice.toJson()}")
val httpResponse = client.post("http://$host:$port/api/respond") { val httpResponse = client.post("http://$host:$port/api/respond") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(response) setBody(responseWithDevice)
} }
if (httpResponse.status.isSuccess()) { if (httpResponse.status.isSuccess()) {
log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS) log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS)