diff --git a/backend/src/main/kotlin/meatbag/backend/Application.kt b/backend/src/main/kotlin/meatbag/backend/Application.kt index 868157d..8cb5ad3 100644 --- a/backend/src/main/kotlin/meatbag/backend/Application.kt +++ b/backend/src/main/kotlin/meatbag/backend/Application.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.json.* import kotlinx.serialization.encodeToString import kotlinx.coroutines.isActive import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import meatbag.common.* import java.time.Duration @@ -165,6 +166,10 @@ fun Application.module() { } val matchedPendingRequest = routingEngine.handleResponse(response) + + // Custom dashboard action handler hook + handleDashboardAction(response, deviceRegistry, profileStore) + call.respond( HttpStatusCode.OK, buildJsonObject { @@ -405,3 +410,153 @@ private fun parseDeviceTypes(value: String?): List { ?.distinct() ?: 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))) + } + } + } + } +} diff --git a/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt index 30dd400..6083acd 100644 --- a/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt +++ b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt @@ -166,10 +166,11 @@ class MeatbagClient( suspend fun respond(response: ApprovalResponse): Boolean { 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") { contentType(ContentType.Application.Json) - setBody(response) + setBody(responseWithDevice) } if (httpResponse.status.isSuccess()) { log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS)