diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index bad6c94ea..7977b8e75 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -3,6 +3,7 @@ package com.steipete.clawdis.node import android.app.Application import androidx.lifecycle.AndroidViewModel import com.steipete.clawdis.node.bridge.BridgeEndpoint +import com.steipete.clawdis.node.chat.OutgoingAttachment import com.steipete.clawdis.node.node.CameraCaptureManager import com.steipete.clawdis.node.node.CanvasController import kotlinx.coroutines.flow.StateFlow @@ -29,8 +30,15 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort - val chatMessages: StateFlow> = runtime.chatMessages + val chatSessionKey: StateFlow = runtime.chatSessionKey + val chatSessionId: StateFlow = runtime.chatSessionId + val chatMessages = runtime.chatMessages val chatError: StateFlow = runtime.chatError + val chatHealthOk: StateFlow = runtime.chatHealthOk + val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel + val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText + val chatPendingToolCalls = runtime.chatPendingToolCalls + val chatSessions = runtime.chatSessions val pendingRunCount: StateFlow = runtime.pendingRunCount fun setForeground(value: Boolean) { @@ -85,7 +93,27 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.loadChat(sessionKey) } - fun sendChat(sessionKey: String = "main", message: String) { - runtime.sendChat(sessionKey, message) + fun refreshChat() { + runtime.refreshChat() + } + + fun refreshChatSessions(limit: Int? = null) { + runtime.refreshChatSessions(limit = limit) + } + + fun setChatThinkingLevel(level: String) { + runtime.setChatThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + runtime.switchChatSession(sessionKey) + } + + fun abortChat() { + runtime.abortChat() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + runtime.sendChat(message = message, thinking = thinking, attachments = attachments) } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 2eff0cbfc..e45fbe548 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -2,6 +2,11 @@ package com.steipete.clawdis.node import android.content.Context import android.os.Build +import com.steipete.clawdis.node.chat.ChatController +import com.steipete.clawdis.node.chat.ChatMessage +import com.steipete.clawdis.node.chat.ChatPendingToolCall +import com.steipete.clawdis.node.chat.ChatSessionEntry +import com.steipete.clawdis.node.chat.OutgoingAttachment import com.steipete.clawdis.node.bridge.BridgeDiscovery import com.steipete.clawdis.node.bridge.BridgeEndpoint import com.steipete.clawdis.node.bridge.BridgePairingClient @@ -62,12 +67,7 @@ class NodeRuntime(context: Context) { _isConnected.value = true scope.launch { refreshWakeWordsFromGateway() } }, - onDisconnected = { message -> - _statusText.value = message - _serverName.value = null - _remoteAddress.value = null - _isConnected.value = false - }, + onDisconnected = { message -> handleSessionDisconnected(message) }, onEvent = { event, payloadJson -> handleBridgeEvent(event, payloadJson) }, @@ -76,6 +76,16 @@ class NodeRuntime(context: Context) { }, ) + private val chat = ChatController(scope = scope, session = session, json = json) + + private fun handleSessionDisconnected(message: String) { + _statusText.value = message + _serverName.value = null + _remoteAddress.value = null + _isConnected.value = false + chat.onDisconnected(message) + } + val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled @@ -90,17 +100,16 @@ class NodeRuntime(context: Context) { private var suppressWakeWordsSync = false private var wakeWordsSyncJob: Job? = null - data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?) - - private val _chatMessages = MutableStateFlow>(emptyList()) - val chatMessages: StateFlow> = _chatMessages.asStateFlow() - - private val _chatError = MutableStateFlow(null) - val chatError: StateFlow = _chatError.asStateFlow() - - private val pendingRuns = mutableSetOf() - private val _pendingRunCount = MutableStateFlow(0) - val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() + val chatSessionKey: StateFlow = chat.sessionKey + val chatSessionId: StateFlow = chat.sessionId + val chatMessages: StateFlow> = chat.messages + val chatError: StateFlow = chat.errorText + val chatHealthOk: StateFlow = chat.healthOk + val chatThinkingLevel: StateFlow = chat.thinkingLevel + val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText + val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls + val chatSessions: StateFlow> = chat.sessions + val pendingRunCount: StateFlow = chat.pendingRunCount init { scope.launch(Dispatchers.Default) { @@ -248,57 +257,36 @@ class NodeRuntime(context: Context) { } fun loadChat(sessionKey: String = "main") { - scope.launch { - _chatError.value = null - try { - // Best-effort; push events are optional, but improve latency. - session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""") - } catch (_: Throwable) { - // ignore - } - - try { - val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""") - _chatMessages.value = parseHistory(res) - } catch (e: Exception) { - _chatError.value = e.message - } - } + chat.load(sessionKey) } - fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") { - val trimmed = message.trim() - if (trimmed.isEmpty()) return - scope.launch { - _chatError.value = null - val idem = java.util.UUID.randomUUID().toString() + fun refreshChat() { + chat.refresh() + } - _chatMessages.value = - _chatMessages.value + - ChatMessage( - id = java.util.UUID.randomUUID().toString(), - role = "user", - text = trimmed, - timestampMs = System.currentTimeMillis(), - ) + fun refreshChatSessions(limit: Int? = null) { + chat.refreshSessions(limit = limit) + } - try { - val params = - """{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}""" - val res = session.request("chat.send", params) - val runId = parseRunId(res) ?: idem - pendingRuns.add(runId) - _pendingRunCount.value = pendingRuns.size - } catch (e: Exception) { - _chatError.value = e.message - } - } + fun setChatThinkingLevel(level: String) { + chat.setThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + chat.switchSession(sessionKey) + } + + fun abortChat() { + chat.abort() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) } private fun handleBridgeEvent(event: String, payloadJson: String?) { - if (payloadJson.isNullOrBlank()) return - if (event == "voicewake.changed") { + if (payloadJson.isNullOrBlank()) return try { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return val array = payload["triggers"] as? JsonArray ?: return @@ -310,40 +298,7 @@ class NodeRuntime(context: Context) { return } - if (event != "chat") return - - try { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val state = payload["state"].asStringOrNull() - val runId = payload["runId"].asStringOrNull() - if (!runId.isNullOrBlank()) { - pendingRuns.remove(runId) - _pendingRunCount.value = pendingRuns.size - } - - when (state) { - "final" -> { - val msgObj = payload["message"].asObjectOrNull() - val role = msgObj?.get("role").asStringOrNull() ?: "assistant" - val text = extractTextFromMessage(msgObj) - if (!text.isNullOrBlank()) { - _chatMessages.value = - _chatMessages.value + - ChatMessage( - id = java.util.UUID.randomUUID().toString(), - role = role, - text = text, - timestampMs = System.currentTimeMillis(), - ) - } - } - "error" -> { - _chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" - } - } - } catch (_: Throwable) { - // ignore - } + chat.handleBridgeEvent(event, payloadJson) } private fun applyWakeWordsFromGateway(words: List) { @@ -384,46 +339,6 @@ class NodeRuntime(context: Context) { } } - private fun parseHistory(historyJson: String): List { - val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList() - val raw = root["messages"] ?: return emptyList() - val array = raw as? JsonArray ?: return emptyList() - return array.mapNotNull { item -> - val obj = item as? JsonObject ?: return@mapNotNull null - val role = obj["role"].asStringOrNull() ?: return@mapNotNull null - val text = extractTextFromMessage(obj) ?: return@mapNotNull null - ChatMessage( - id = java.util.UUID.randomUUID().toString(), - role = role, - text = text, - timestampMs = null, - ) - } - } - - private fun extractTextFromMessage(msgObj: JsonObject?): String? { - if (msgObj == null) return null - val content = msgObj["content"] ?: return null - return when (content) { - is JsonPrimitive -> content.asStringOrNull() - else -> { - val arr = (content as? JsonArray) ?: return null - arr.mapNotNull { part -> - val p = part as? JsonObject ?: return@mapNotNull null - p["text"].asStringOrNull() - }.joinToString("\n").trim().ifBlank { null } - } - } - } - - private fun parseRunId(resJson: String): String? { - return try { - json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() - } catch (_: Throwable) { - null - } - } - private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult { if (command.startsWith("screen.") || command.startsWith("camera.")) { if (!isForeground.value) { diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt index 3658c6945..7b555697c 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt @@ -6,6 +6,7 @@ import android.net.NetworkCapabilities import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build +import java.net.InetSocketAddress import java.time.Duration import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.xbill.DNS.ExtendedResolver import org.xbill.DNS.Lookup +import org.xbill.DNS.SimpleResolver import org.xbill.DNS.AAAARecord import org.xbill.DNS.ARecord import org.xbill.DNS.PTRRecord @@ -212,21 +214,28 @@ class BridgeDiscovery( cm.activeNetwork?.let(::add) }.distinct() - val addrs = + val servers = candidateNetworks .asSequence() .flatMap { n -> cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence() } - .mapNotNull { it.hostAddress } - .map { it.trim() } - .filter { it.isNotEmpty() } - .distinct() + .distinctBy { it.hostAddress ?: it.toString() } .toList() - if (addrs.isEmpty()) return null + if (servers.isEmpty()) return null return try { - ExtendedResolver(addrs.toTypedArray()).apply { + val resolvers = + servers.mapNotNull { addr -> + try { + SimpleResolver().apply { setAddress(InetSocketAddress(addr, 53)) } + } catch (_: Throwable) { + null + } + } + if (resolvers.isEmpty()) return null + + ExtendedResolver(resolvers.toTypedArray()).apply { // Vienna -> London via tailnet: allow a bit more headroom than LAN mDNS. setTimeout(Duration.ofMillis(3000)) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt new file mode 100644 index 000000000..50ae7bc9c --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt @@ -0,0 +1,508 @@ +package com.steipete.clawdis.node.chat + +import com.steipete.clawdis.node.bridge.BridgeSession +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +class ChatController( + private val scope: CoroutineScope, + private val session: BridgeSession, + private val json: Json, +) { + private val _sessionKey = MutableStateFlow("main") + val sessionKey: StateFlow = _sessionKey.asStateFlow() + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.asStateFlow() + + private val _healthOk = MutableStateFlow(false) + val healthOk: StateFlow = _healthOk.asStateFlow() + + private val _thinkingLevel = MutableStateFlow("off") + val thinkingLevel: StateFlow = _thinkingLevel.asStateFlow() + + private val _pendingRunCount = MutableStateFlow(0) + val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() + + private val _streamingAssistantText = MutableStateFlow(null) + val streamingAssistantText: StateFlow = _streamingAssistantText.asStateFlow() + + private val pendingToolCallsById = ConcurrentHashMap() + private val _pendingToolCalls = MutableStateFlow>(emptyList()) + val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val pendingRuns = mutableSetOf() + private val pendingRunTimeoutJobs = ConcurrentHashMap() + private val pendingRunTimeoutMs = 120_000L + + private var lastHealthPollAtMs: Long? = null + + fun onDisconnected(message: String) { + _healthOk.value = false + _errorText.value = message + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + } + + fun load(sessionKey: String = "main") { + val key = sessionKey.trim().ifEmpty { "main" } + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun refresh() { + scope.launch { bootstrap(forceHealth = true) } + } + + fun refreshSessions(limit: Int? = null) { + scope.launch { fetchSessions(limit = limit) } + } + + fun setThinkingLevel(thinkingLevel: String) { + val normalized = normalizeThinking(thinkingLevel) + if (normalized == _thinkingLevel.value) return + _thinkingLevel.value = normalized + } + + fun switchSession(sessionKey: String) { + val key = sessionKey.trim() + if (key.isEmpty()) return + if (key == _sessionKey.value) return + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun sendMessage( + message: String, + thinkingLevel: String, + attachments: List, + ) { + val trimmed = message.trim() + if (trimmed.isEmpty() && attachments.isEmpty()) return + if (!_healthOk.value) { + _errorText.value = "Gateway health not OK; cannot send" + return + } + + val runId = UUID.randomUUID().toString() + val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed + val sessionKey = _sessionKey.value + val thinking = normalizeThinking(thinkingLevel) + + // Optimistic user message. + val userContent = + buildList { + add(ChatMessageContent(type = "text", text = text)) + for (att in attachments) { + add( + ChatMessageContent( + type = att.type, + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ), + ) + } + } + _messages.value = + _messages.value + + ChatMessage( + id = UUID.randomUUID().toString(), + role = "user", + content = userContent, + timestampMs = System.currentTimeMillis(), + ) + + armPendingRunTimeout(runId) + synchronized(pendingRuns) { + pendingRuns.add(runId) + _pendingRunCount.value = pendingRuns.size + } + + _errorText.value = null + _streamingAssistantText.value = null + pendingToolCallsById.clear() + publishPendingToolCalls() + + scope.launch { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(sessionKey)) + put("message", JsonPrimitive(text)) + put("thinking", JsonPrimitive(thinking)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(runId)) + if (attachments.isNotEmpty()) { + put( + "attachments", + JsonArray( + attachments.map { att -> + buildJsonObject { + put("type", JsonPrimitive(att.type)) + put("mimeType", JsonPrimitive(att.mimeType)) + put("fileName", JsonPrimitive(att.fileName)) + put("content", JsonPrimitive(att.base64)) + } + }, + ), + ) + } + } + val res = session.request("chat.send", params.toString()) + val actualRunId = parseRunId(res) ?: runId + if (actualRunId != runId) { + clearPendingRun(runId) + armPendingRunTimeout(actualRunId) + synchronized(pendingRuns) { + pendingRuns.add(actualRunId) + _pendingRunCount.value = pendingRuns.size + } + } + } catch (err: Throwable) { + clearPendingRun(runId) + _errorText.value = err.message + } + } + } + + fun abort() { + val runIds = + synchronized(pendingRuns) { + pendingRuns.toList() + } + if (runIds.isEmpty()) return + scope.launch { + for (runId in runIds) { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(_sessionKey.value)) + put("runId", JsonPrimitive(runId)) + } + session.request("chat.abort", params.toString()) + } catch (_: Throwable) { + // best-effort + } + } + } + } + + fun handleBridgeEvent(event: String, payloadJson: String?) { + when (event) { + "tick" -> { + scope.launch { pollHealthIfNeeded(force = false) } + } + "health" -> { + // If we receive a health snapshot, the gateway is reachable. + _healthOk.value = true + } + "seqGap" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + } + "chat" -> { + if (payloadJson.isNullOrBlank()) return + handleChatEvent(payloadJson) + } + "agent" -> { + if (payloadJson.isNullOrBlank()) return + handleAgentEvent(payloadJson) + } + } + } + + private suspend fun bootstrap(forceHealth: Boolean) { + _errorText.value = null + _healthOk.value = false + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + + val key = _sessionKey.value + try { + try { + session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""") + } catch (_: Throwable) { + // best-effort + } + + val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") + val history = parseHistory(historyJson, sessionKey = key) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + + pollHealthIfNeeded(force = forceHealth) + fetchSessions(limit = 50) + } catch (err: Throwable) { + _errorText.value = err.message + } + } + + private suspend fun fetchSessions(limit: Int?) { + try { + val params = + buildJsonObject { + put("includeGlobal", JsonPrimitive(true)) + put("includeUnknown", JsonPrimitive(false)) + if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) + } + val res = session.request("sessions.list", params.toString()) + _sessions.value = parseSessions(res) + } catch (_: Throwable) { + // best-effort + } + } + + private suspend fun pollHealthIfNeeded(force: Boolean) { + val now = System.currentTimeMillis() + val last = lastHealthPollAtMs + if (!force && last != null && now - last < 10_000) return + lastHealthPollAtMs = now + try { + session.request("health", null) + _healthOk.value = true + } catch (_: Throwable) { + _healthOk.value = false + } + } + + private fun handleChatEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return + + val runId = payload["runId"].asStringOrNull() + if (runId != null) { + val isPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!isPending) return + } + + val state = payload["state"].asStringOrNull() + when (state) { + "final", "aborted", "error" -> { + if (state == "error") { + _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" + } + if (runId != null) clearPendingRun(runId) else clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + scope.launch { + try { + val historyJson = + session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") + val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + } catch (_: Throwable) { + // best-effort + } + } + } + } + } + + private fun handleAgentEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val runId = payload["runId"].asStringOrNull() + val sessionId = _sessionId.value + if (sessionId != null && runId != sessionId) return + + val stream = payload["stream"].asStringOrNull() + val data = payload["data"].asObjectOrNull() + + when (stream) { + "assistant" -> { + val text = data?.get("text")?.asStringOrNull() + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } + "tool" -> { + val phase = data?.get("phase")?.asStringOrNull() + val name = data?.get("name")?.asStringOrNull() + val toolCallId = data?.get("toolCallId")?.asStringOrNull() + if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return + + val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() + if (phase == "start") { + pendingToolCallsById[toolCallId] = + ChatPendingToolCall( + toolCallId = toolCallId, + name = name, + startedAtMs = ts, + isError = null, + ) + publishPendingToolCalls() + } else if (phase == "result") { + pendingToolCallsById.remove(toolCallId) + publishPendingToolCalls() + } + } + "error" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + } + } + } + + private fun publishPendingToolCalls() { + _pendingToolCalls.value = + pendingToolCallsById.values.sortedBy { it.startedAtMs } + } + + private fun armPendingRunTimeout(runId: String) { + pendingRunTimeoutJobs[runId]?.cancel() + pendingRunTimeoutJobs[runId] = + scope.launch { + delay(pendingRunTimeoutMs) + val stillPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!stillPending) return@launch + clearPendingRun(runId) + _errorText.value = "Timed out waiting for a reply; try again or refresh." + } + } + + private fun clearPendingRun(runId: String) { + pendingRunTimeoutJobs.remove(runId)?.cancel() + synchronized(pendingRuns) { + pendingRuns.remove(runId) + _pendingRunCount.value = pendingRuns.size + } + } + + private fun clearPendingRuns() { + for ((_, job) in pendingRunTimeoutJobs) { + job.cancel() + } + pendingRunTimeoutJobs.clear() + synchronized(pendingRuns) { + pendingRuns.clear() + _pendingRunCount.value = 0 + } + } + + private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) + val sid = root["sessionId"].asStringOrNull() + val thinkingLevel = root["thinkingLevel"].asStringOrNull() + val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) + + val messages = + array.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val role = obj["role"].asStringOrNull() ?: return@mapNotNull null + val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList() + val ts = obj["timestamp"].asLongOrNull() + ChatMessage( + id = UUID.randomUUID().toString(), + role = role, + content = content, + timestampMs = ts, + ) + } + + return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + } + + private fun parseMessageContent(el: JsonElement): ChatMessageContent? { + val obj = el.asObjectOrNull() ?: return null + val type = obj["type"].asStringOrNull() ?: "text" + return if (type == "text") { + ChatMessageContent(type = "text", text = obj["text"].asStringOrNull()) + } else { + ChatMessageContent( + type = type, + mimeType = obj["mimeType"].asStringOrNull(), + fileName = obj["fileName"].asStringOrNull(), + base64 = obj["content"].asStringOrNull(), + ) + } + } + + private fun parseSessions(jsonString: String): List { + val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() + val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() + return sessions.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val key = obj["key"].asStringOrNull()?.trim().orEmpty() + if (key.isEmpty()) return@mapNotNull null + val updatedAt = obj["updatedAt"].asLongOrNull() + ChatSessionEntry(key = key, updatedAtMs = updatedAt) + } + } + + private fun parseRunId(resJson: String): String? { + return try { + json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() + } catch (_: Throwable) { + null + } + } + + private fun normalizeThinking(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "low" + "medium" -> "medium" + "high" -> "high" + else -> "off" + } + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun JsonElement?.asLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> content.toLongOrNull() + else -> null + } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt new file mode 100644 index 000000000..2d27a5be4 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt @@ -0,0 +1,42 @@ +package com.steipete.clawdis.node.chat + +data class ChatMessage( + val id: String, + val role: String, + val content: List, + val timestampMs: Long?, +) + +data class ChatMessageContent( + val type: String = "text", + val text: String? = null, + val mimeType: String? = null, + val fileName: String? = null, + val base64: String? = null, +) + +data class ChatPendingToolCall( + val toolCallId: String, + val name: String, + val startedAtMs: Long, + val isError: Boolean? = null, +) + +data class ChatSessionEntry( + val key: String, + val updatedAtMs: Long?, +) + +data class ChatHistory( + val sessionKey: String, + val sessionId: String?, + val thinkingLevel: String?, + val messages: List, +) + +data class OutgoingAttachment( + val type: String, + val mimeType: String, + val fileName: String, + val base64: String, +) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ChatSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ChatSheet.kt index d8c1f1616..aaffbcd73 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ChatSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ChatSheet.kt @@ -1,73 +1,10 @@ package com.steipete.clawdis.node.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.steipete.clawdis.node.MainViewModel +import com.steipete.clawdis.node.ui.chat.ChatSheetContent @Composable fun ChatSheet(viewModel: MainViewModel) { - val messages by viewModel.chatMessages.collectAsState() - val error by viewModel.chatError.collectAsState() - val pendingRunCount by viewModel.pendingRunCount.collectAsState() - var input by remember { mutableStateOf("") } - - LaunchedEffect(Unit) { - viewModel.loadChat("main") - } - - Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Clawd Chat · session main") - - if (!error.isNullOrBlank()) { - Text("Error: $error") - } - - LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) { - items(messages) { msg -> - Text("${msg.role}: ${msg.text}") - } - if (pendingRunCount > 0) { - item { Text("assistant: …") } - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.weight(1f), - label = { Text("Message") }, - ) - Button( - onClick = { - val text = input - input = "" - viewModel.sendChat("main", text) - }, - enabled = input.trim().isNotEmpty(), - ) { - Text("Send") - } - } - } + ChatSheetContent(viewModel = viewModel) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ClawdisIdleBackground.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ClawdisIdleBackground.kt new file mode 100644 index 000000000..d940ca655 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/ClawdisIdleBackground.kt @@ -0,0 +1,112 @@ +package com.steipete.clawdis.node.ui + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.unit.dp + +@Composable +fun ClawdisIdleBackground(modifier: Modifier = Modifier) { + val t = rememberInfiniteTransition(label = "clawdis-bg") + val gridX = + t.animateFloat( + initialValue = -18f, + targetValue = 14f, + animationSpec = infiniteRepeatable(animation = tween(durationMillis = 22_000), repeatMode = RepeatMode.Reverse), + label = "gridX", + ).value + val gridY = + t.animateFloat( + initialValue = 12f, + targetValue = -10f, + animationSpec = infiniteRepeatable(animation = tween(durationMillis = 22_000), repeatMode = RepeatMode.Reverse), + label = "gridY", + ).value + + val glowX = + t.animateFloat( + initialValue = -26f, + targetValue = 20f, + animationSpec = infiniteRepeatable(animation = tween(durationMillis = 18_000), repeatMode = RepeatMode.Reverse), + label = "glowX", + ).value + val glowY = + t.animateFloat( + initialValue = 18f, + targetValue = -14f, + animationSpec = infiniteRepeatable(animation = tween(durationMillis = 18_000), repeatMode = RepeatMode.Reverse), + label = "glowY", + ).value + + Canvas(modifier = modifier.fillMaxSize()) { + drawRect(Color.Black) + + val w = size.width + val h = size.height + + fun radial(cx: Float, cy: Float, r: Float, color: Color): Brush = + Brush.radialGradient( + colors = listOf(color, Color.Transparent), + center = Offset(cx, cy), + radius = r, + ) + + drawRect( + brush = radial(w * 0.15f, h * 0.20f, r = maxOf(w, h) * 0.85f, color = Color(0xFF2A71FF).copy(alpha = 0.18f)), + ) + drawRect( + brush = radial(w * 0.85f, h * 0.30f, r = maxOf(w, h) * 0.75f, color = Color(0xFFFF008A).copy(alpha = 0.14f)), + ) + drawRect( + brush = radial(w * 0.60f, h * 0.90f, r = maxOf(w, h) * 0.85f, color = Color(0xFF00D1FF).copy(alpha = 0.10f)), + ) + + rotate(degrees = -7f) { + val spacing = 48.dp.toPx() + val line = Color.White.copy(alpha = 0.02f) + val offset = Offset(gridX.dp.toPx(), gridY.dp.toPx()) + + var x = (-w * 0.6f) + (offset.x % spacing) + while (x < w * 1.6f) { + drawLine(color = line, start = Offset(x, -h * 0.6f), end = Offset(x, h * 1.6f)) + x += spacing + } + + var y = (-h * 0.6f) + (offset.y % spacing) + while (y < h * 1.6f) { + drawLine(color = line, start = Offset(-w * 0.6f, y), end = Offset(w * 1.6f, y)) + y += spacing + } + } + + // Glow drift layer (approximation of iOS WebView scaffold). + val glowOffset = Offset(glowX.dp.toPx(), glowY.dp.toPx()) + drawRect( + brush = radial(w * 0.30f + glowOffset.x, h * 0.30f + glowOffset.y, r = maxOf(w, h) * 0.75f, color = Color(0xFF2A71FF).copy(alpha = 0.16f)), + blendMode = BlendMode.Screen, + alpha = 0.55f, + ) + drawRect( + brush = radial(w * 0.70f + glowOffset.x, h * 0.35f + glowOffset.y, r = maxOf(w, h) * 0.70f, color = Color(0xFFFF008A).copy(alpha = 0.12f)), + blendMode = BlendMode.Screen, + alpha = 0.55f, + ) + drawRect( + brush = radial(w * 0.55f + glowOffset.x, h * 0.75f + glowOffset.y, r = maxOf(w, h) * 0.85f, color = Color(0xFF00D1FF).copy(alpha = 0.10f)), + blendMode = BlendMode.Screen, + alpha = 0.55f, + ) + } +} + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index 9d021f311..fdbefceff 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -66,6 +66,7 @@ fun RootScreen(viewModel: MainViewModel) { PackageManager.PERMISSION_GRANTED Box(modifier = Modifier.fillMaxSize()) { + ClawdisIdleBackground(modifier = Modifier.fillMaxSize()) CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt new file mode 100644 index 000000000..23cf59794 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt @@ -0,0 +1,257 @@ +package com.steipete.clawdis.node.ui.chat + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun ChatComposer( + sessionKey: String, + healthOk: Boolean, + thinkingLevel: String, + pendingRunCount: Int, + errorText: String?, + attachments: List, + onPickImages: () -> Unit, + onRemoveAttachment: (id: String) -> Unit, + onSetThinkingLevel: (level: String) -> Unit, + onRefresh: () -> Unit, + onAbort: () -> Unit, + onSend: (text: String) -> Unit, +) { + var input by rememberSaveable { mutableStateOf("") } + var showThinkingMenu by remember { mutableStateOf(false) } + + val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + tonalElevation = 2.dp, + shadowElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + FilledTonalButton( + onClick = { showThinkingMenu = true }, + contentPadding = ButtonDefaults.ContentPadding, + ) { + Text("Thinking: ${thinkingLabel(thinkingLevel)}") + } + + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + + FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { + Icon(Icons.Default.AttachFile, contentDescription = "Add image") + } + } + + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)), + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Message Clawd…") }, + minLines = 2, + maxLines = 6, + ) + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + ConnectionPill(sessionKey = sessionKey, healthOk = healthOk) + Spacer(modifier = Modifier.weight(1f)) + + if (pendingRunCount > 0) { + FilledTonalIconButton( + onClick = onAbort, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = Color(0x33E74C3C), + contentColor = Color(0xFFE74C3C), + ), + ) { + Icon(Icons.Default.Stop, contentDescription = "Abort") + } + } else { + FilledTonalIconButton(onClick = { + val text = input + input = "" + onSend(text) + }, enabled = canSend) { + Icon(Icons.Default.ArrowUpward, contentDescription = "Send") + } + } + } + } + } + + if (!errorText.isNullOrBlank()) { + Text( + text = errorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + ) + } + } + } +} + +@Composable +private fun ThinkingMenuItem( + value: String, + current: String, + onSet: (String) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenuItem( + text = { Text(thinkingLabel(value)) }, + onClick = { + onSet(value) + onDismiss() + }, + trailingIcon = { + if (value == current.trim().lowercase()) { + Text("✓") + } else { + Spacer(modifier = Modifier.width(10.dp)) + } + }, + ) +} + +private fun thinkingLabel(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "Low" + "medium" -> "Medium" + "high" -> "High" + else -> "Off" + } +} + +@Composable +private fun AttachmentsStrip( + attachments: List, + onRemoveAttachment: (id: String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (att in attachments) { + AttachmentChip( + fileName = att.fileName, + onRemove = { onRemoveAttachment(att.id) }, + ) + } + } +} + +@Composable +private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) + FilledTonalIconButton( + onClick = onRemove, + modifier = Modifier.size(30.dp), + ) { + Text("×") + } + } + } +} + +@Composable +private fun ConnectionPill(sessionKey: String, healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(7.dp), + shape = androidx.compose.foundation.shape.CircleShape, + color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), + ) {} + Text(sessionKey, style = MaterialTheme.typography.labelSmall) + Text( + if (healthOk) "Connected" else "Connecting…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMarkdown.kt new file mode 100644 index 000000000..a11237a93 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMarkdown.kt @@ -0,0 +1,214 @@ +package com.steipete.clawdis.node.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ChatMarkdown(text: String) { + val blocks = remember(text) { splitMarkdown(text) } + val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (b in blocks) { + when (b) { + is ChatMarkdownBlock.Text -> { + val trimmed = b.text.trimEnd() + if (trimmed.isEmpty()) continue + Text( + text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + is ChatMarkdownBlock.Code -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = b.code, language = b.language) + } + } + is ChatMarkdownBlock.InlineImage -> { + InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + } + } + } +} + +private sealed interface ChatMarkdownBlock { + data class Text(val text: String) : ChatMarkdownBlock + data class Code(val code: String, val language: String?) : ChatMarkdownBlock + data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock +} + +private fun splitMarkdown(raw: String): List { + if (raw.isEmpty()) return emptyList() + + val out = ArrayList() + var idx = 0 + while (idx < raw.length) { + val fenceStart = raw.indexOf("```", startIndex = idx) + if (fenceStart < 0) { + out.addAll(splitInlineImages(raw.substring(idx))) + break + } + + if (fenceStart > idx) { + out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) + } + + val langLineStart = fenceStart + 3 + val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } + val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } + + val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd + val fenceEnd = raw.indexOf("```", startIndex = codeStart) + if (fenceEnd < 0) { + out.addAll(splitInlineImages(raw.substring(fenceStart))) + break + } + val code = raw.substring(codeStart, fenceEnd) + out.add(ChatMarkdownBlock.Code(code = code, language = language)) + + idx = fenceEnd + 3 + } + + return out +} + +private fun splitInlineImages(text: String): List { + if (text.isEmpty()) return emptyList() + val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") + val out = ArrayList() + + var idx = 0 + while (idx < text.length) { + val m = regex.find(text, startIndex = idx) ?: break + val start = m.range.first + val end = m.range.last + 1 + if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) + + val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") + val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (b64.isNotEmpty()) { + out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) + } + idx = end + } + + if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) + return out +} + +private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { + if (text.isEmpty()) return AnnotatedString("") + + val out = buildAnnotatedString { + var i = 0 + while (i < text.length) { + if (text.startsWith("**", startIndex = i)) { + val end = text.indexOf("**", startIndex = i + 2) + if (end > i + 2) { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append(text.substring(i + 2, end)) + } + i = end + 2 + continue + } + } + + if (text[i] == '`') { + val end = text.indexOf('`', startIndex = i + 1) + if (end > i + 1) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + ), + ) { + append(text.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { + val end = text.indexOf('*', startIndex = i + 1) + if (end > i + 1) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(text.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + append(text[i]) + i += 1 + } + } + return out +} + +@Composable +private fun InlineBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "image", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } else if (failed) { + Text( + text = "Image unavailable", + modifier = Modifier.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt new file mode 100644 index 000000000..7acf71a7d --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt @@ -0,0 +1,181 @@ +package com.steipete.clawdis.node.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.steipete.clawdis.node.chat.ChatMessage +import com.steipete.clawdis.node.chat.ChatPendingToolCall + +@Composable +fun ChatMessageListCard( + sessionKey: String, + healthOk: Boolean, + messages: List, + pendingRunCount: Int, + pendingToolCalls: List, + streamingAssistantText: String?, + onShowSessions: () -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { + val total = + messages.size + + (if (pendingRunCount > 0) 1 else 0) + + (if (pendingToolCalls.isNotEmpty()) 1 else 0) + + (if (!streamingAssistantText.isNullOrBlank()) 1 else 0) + if (total <= 0) return@LaunchedEffect + listState.animateScrollToItem(index = total - 1) + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 44.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), + ) { + items(count = messages.size, key = { idx -> messages[idx].id }) { idx -> + ChatMessageBubble(message = messages[idx]) + } + + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() + } + } + + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) + } + } + + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) + } + } + } + + ChatStatusPill( + sessionKey = sessionKey, + healthOk = healthOk, + onShowSessions = onShowSessions, + onRefresh = onRefresh, + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +@Composable +private fun ChatStatusPill( + sessionKey: String, + healthOk: Boolean, + onShowSessions: () -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.90f), + shadowElevation = 4.dp, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + modifier = Modifier.size(7.dp), + shape = androidx.compose.foundation.shape.CircleShape, + color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), + ) {} + + Text( + text = sessionKey, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = if (healthOk) "Connected" else "Connecting…", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.alpha(0.9f), + ) + + Spacer(modifier = Modifier.weight(1f)) + + FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.FolderOpen, contentDescription = "Sessions") + } + + FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + } + } +} + +@Composable +private fun EmptyChatHint(modifier: Modifier = Modifier) { + Row( + modifier = modifier.alpha(0.7f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.ArrowCircleDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Message Clawd…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageViews.kt new file mode 100644 index 000000000..d7a99e8e2 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageViews.kt @@ -0,0 +1,218 @@ +package com.steipete.clawdis.node.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.Image +import com.steipete.clawdis.node.chat.ChatMessage +import com.steipete.clawdis.node.chat.ChatMessageContent +import com.steipete.clawdis.node.chat.ChatPendingToolCall +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ChatMessageBubble(message: ChatMessage) { + val isUser = message.role.lowercase() == "user" + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + Box( + modifier = + Modifier + .background(bubbleBackground(isUser)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + ChatMessageBody(content = message.content) + } + } + } +} + +@Composable +private fun ChatMessageBody(content: List) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (part in content) { + when (part.type) { + "text" -> { + val text = part.text ?: continue + ChatMarkdown(text = text) + } + else -> { + val b64 = part.base64 ?: continue + ChatBase64Image(base64 = b64, mimeType = part.mimeType) + } + } + } + } +} + +@Composable +fun ChatTypingIndicatorBubble() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DotPulse() + Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +fun ChatPendingToolsBubble(toolCalls: List) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) + for (t in toolCalls.take(6)) { + Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (toolCalls.size > 6) { + Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} + +@Composable +fun ChatStreamingAssistantBubble(text: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + ChatMarkdown(text = text) + } + } + } +} + +@Composable +private fun bubbleBackground(isUser: Boolean): Brush { + return if (isUser) { + Brush.linearGradient( + colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), + ) + } else { + Brush.linearGradient( + colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), + ) + } +} + +@Composable +private fun ChatBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } else if (failed) { + Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun DotPulse() { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + PulseDot(alpha = 0.38f) + PulseDot(alpha = 0.62f) + PulseDot(alpha = 0.90f) + } +} + +@Composable +private fun PulseDot(alpha: Float) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) {} +} + +@Composable +fun ChatCodeBlock(code: String, language: String?) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = code.trimEnd(), + modifier = Modifier.padding(10.dp), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt new file mode 100644 index 000000000..55d30b9b7 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt @@ -0,0 +1,93 @@ +package com.steipete.clawdis.node.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.steipete.clawdis.node.chat.ChatSessionEntry + +@Composable +fun ChatSessionsDialog( + currentSessionKey: String, + sessions: List, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onSelect: (sessionKey: String) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + title = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Sessions", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.weight(1f)) + FilledTonalIconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + } + }, + text = { + if (sessions.isEmpty()) { + Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(sessions, key = { it.key }) { entry -> + SessionRow( + entry = entry, + isCurrent = entry.key == currentSessionKey, + onClick = { onSelect(entry.key) }, + ) + } + } + } + }, + ) +} + +@Composable +private fun SessionRow( + entry: ChatSessionEntry, + isCurrent: Boolean, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = + if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(entry.key, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.weight(1f)) + if (isCurrent) { + Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt new file mode 100644 index 000000000..4a81cff86 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt @@ -0,0 +1,160 @@ +package com.steipete.clawdis.node.ui.chat + +import android.content.ContentResolver +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.steipete.clawdis.node.MainViewModel +import com.steipete.clawdis.node.chat.OutgoingAttachment +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ChatSheetContent(viewModel: MainViewModel) { + val messages by viewModel.chatMessages.collectAsState() + val errorText by viewModel.chatError.collectAsState() + val pendingRunCount by viewModel.pendingRunCount.collectAsState() + val healthOk by viewModel.chatHealthOk.collectAsState() + val sessionKey by viewModel.chatSessionKey.collectAsState() + val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() + val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() + val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() + val sessions by viewModel.chatSessions.collectAsState() + + var showSessions by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadChat("main") + } + + val context = LocalContext.current + val resolver = context.contentResolver + val scope = rememberCoroutineScope() + + val attachments = remember { mutableStateListOf() } + + val pickImages = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val next = + uris.take(8).mapNotNull { uri -> + try { + loadImageAttachment(resolver, uri) + } catch (_: Throwable) { + null + } + } + withContext(Dispatchers.Main) { + attachments.addAll(next) + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ChatMessageListCard( + sessionKey = sessionKey, + healthOk = healthOk, + messages = messages, + pendingRunCount = pendingRunCount, + pendingToolCalls = pendingToolCalls, + streamingAssistantText = streamingAssistantText, + onShowSessions = { showSessions = true }, + onRefresh = { viewModel.refreshChat() }, + modifier = Modifier.weight(1f, fill = true), + ) + + ChatComposer( + sessionKey = sessionKey, + healthOk = healthOk, + thinkingLevel = thinkingLevel, + pendingRunCount = pendingRunCount, + errorText = errorText, + attachments = attachments, + onPickImages = { pickImages.launch("image/*") }, + onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, + onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, + onRefresh = { viewModel.refreshChat() }, + onAbort = { viewModel.abortChat() }, + onSend = { text -> + val outgoing = + attachments.map { att -> + OutgoingAttachment( + type = "image", + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ) + } + viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) + attachments.clear() + }, + ) + } + + if (showSessions) { + ChatSessionsDialog( + currentSessionKey = sessionKey, + sessions = sessions, + onDismiss = { showSessions = false }, + onRefresh = { viewModel.refreshChatSessions(limit = 50) }, + onSelect = { key -> + viewModel.switchChatSession(key) + showSessions = false + }, + ) + } +} + +data class PendingImageAttachment( + val id: String, + val fileName: String, + val mimeType: String, + val base64: String, +) + +private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val mimeType = resolver.getType(uri) ?: "image/*" + val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') + val bytes = + withContext(Dispatchers.IO) { + resolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + input.copyTo(out) + out.toByteArray() + } ?: ByteArray(0) + } + if (bytes.isEmpty()) throw IllegalStateException("empty attachment") + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + return PendingImageAttachment( + id = uri.toString() + "#" + System.currentTimeMillis().toString(), + fileName = fileName, + mimeType = mimeType, + base64 = base64, + ) +}