feat(android): chat parity + wide-area discovery
parent
c7b80c28a1
commit
d12ca22b19
|
|
@ -3,6 +3,7 @@ package com.steipete.clawdis.node
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
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.CameraCaptureManager
|
||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -29,8 +30,15 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
|
|
||||||
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
|
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||||
|
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||||
|
val chatMessages = runtime.chatMessages
|
||||||
val chatError: StateFlow<String?> = runtime.chatError
|
val chatError: StateFlow<String?> = runtime.chatError
|
||||||
|
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||||
|
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||||
|
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||||
|
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||||
|
val chatSessions = runtime.chatSessions
|
||||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
|
|
@ -85,7 +93,27 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
runtime.loadChat(sessionKey)
|
runtime.loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendChat(sessionKey: String = "main", message: String) {
|
fun refreshChat() {
|
||||||
runtime.sendChat(sessionKey, message)
|
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<OutgoingAttachment>) {
|
||||||
|
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
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.BridgeDiscovery
|
||||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
|
|
@ -62,12 +67,7 @@ class NodeRuntime(context: Context) {
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
},
|
},
|
||||||
onDisconnected = { message ->
|
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||||
_statusText.value = message
|
|
||||||
_serverName.value = null
|
|
||||||
_remoteAddress.value = null
|
|
||||||
_isConnected.value = false
|
|
||||||
},
|
|
||||||
onEvent = { event, payloadJson ->
|
onEvent = { event, payloadJson ->
|
||||||
handleBridgeEvent(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<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
|
@ -90,17 +100,16 @@ class NodeRuntime(context: Context) {
|
||||||
private var suppressWakeWordsSync = false
|
private var suppressWakeWordsSync = false
|
||||||
private var wakeWordsSyncJob: Job? = null
|
private var wakeWordsSyncJob: Job? = null
|
||||||
|
|
||||||
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
|
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||||
|
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||||
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||||
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
|
val chatError: StateFlow<String?> = chat.errorText
|
||||||
|
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||||
private val _chatError = MutableStateFlow<String?>(null)
|
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||||
val chatError: StateFlow<String?> = _chatError.asStateFlow()
|
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||||
|
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||||
private val pendingRuns = mutableSetOf<String>()
|
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||||
private val _pendingRunCount = MutableStateFlow(0)
|
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
|
|
@ -248,57 +257,36 @@ class NodeRuntime(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String = "main") {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
scope.launch {
|
chat.load(sessionKey)
|
||||||
_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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
|
fun refreshChat() {
|
||||||
val trimmed = message.trim()
|
chat.refresh()
|
||||||
if (trimmed.isEmpty()) return
|
}
|
||||||
scope.launch {
|
|
||||||
_chatError.value = null
|
|
||||||
val idem = java.util.UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
_chatMessages.value =
|
fun refreshChatSessions(limit: Int? = null) {
|
||||||
_chatMessages.value +
|
chat.refreshSessions(limit = limit)
|
||||||
ChatMessage(
|
}
|
||||||
id = java.util.UUID.randomUUID().toString(),
|
|
||||||
role = "user",
|
|
||||||
text = trimmed,
|
|
||||||
timestampMs = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
fun setChatThinkingLevel(level: String) {
|
||||||
val params =
|
chat.setThinkingLevel(level)
|
||||||
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
|
}
|
||||||
val res = session.request("chat.send", params)
|
|
||||||
val runId = parseRunId(res) ?: idem
|
fun switchChatSession(sessionKey: String) {
|
||||||
pendingRuns.add(runId)
|
chat.switchSession(sessionKey)
|
||||||
_pendingRunCount.value = pendingRuns.size
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
_chatError.value = e.message
|
fun abortChat() {
|
||||||
}
|
chat.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||||
|
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
if (payloadJson.isNullOrBlank()) return
|
|
||||||
|
|
||||||
if (event == "voicewake.changed") {
|
if (event == "voicewake.changed") {
|
||||||
|
if (payloadJson.isNullOrBlank()) return
|
||||||
try {
|
try {
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
val array = payload["triggers"] as? JsonArray ?: return
|
val array = payload["triggers"] as? JsonArray ?: return
|
||||||
|
|
@ -310,40 +298,7 @@ class NodeRuntime(context: Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event != "chat") return
|
chat.handleBridgeEvent(event, payloadJson)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||||
|
|
@ -384,46 +339,6 @@ class NodeRuntime(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
|
||||||
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 {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
if (command.startsWith("screen.") || command.startsWith("camera.")) {
|
if (command.startsWith("screen.") || command.startsWith("camera.")) {
|
||||||
if (!isForeground.value) {
|
if (!isForeground.value) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import java.net.InetSocketAddress
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.xbill.DNS.ExtendedResolver
|
import org.xbill.DNS.ExtendedResolver
|
||||||
import org.xbill.DNS.Lookup
|
import org.xbill.DNS.Lookup
|
||||||
|
import org.xbill.DNS.SimpleResolver
|
||||||
import org.xbill.DNS.AAAARecord
|
import org.xbill.DNS.AAAARecord
|
||||||
import org.xbill.DNS.ARecord
|
import org.xbill.DNS.ARecord
|
||||||
import org.xbill.DNS.PTRRecord
|
import org.xbill.DNS.PTRRecord
|
||||||
|
|
@ -212,21 +214,28 @@ class BridgeDiscovery(
|
||||||
cm.activeNetwork?.let(::add)
|
cm.activeNetwork?.let(::add)
|
||||||
}.distinct()
|
}.distinct()
|
||||||
|
|
||||||
val addrs =
|
val servers =
|
||||||
candidateNetworks
|
candidateNetworks
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.flatMap { n ->
|
.flatMap { n ->
|
||||||
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||||
}
|
}
|
||||||
.mapNotNull { it.hostAddress }
|
.distinctBy { it.hostAddress ?: it.toString() }
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.distinct()
|
|
||||||
.toList()
|
.toList()
|
||||||
if (addrs.isEmpty()) return null
|
if (servers.isEmpty()) return null
|
||||||
|
|
||||||
return try {
|
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.
|
// Vienna -> London via tailnet: allow a bit more headroom than LAN mDNS.
|
||||||
setTimeout(Duration.ofMillis(3000))
|
setTimeout(Duration.ofMillis(3000))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String> = _sessionKey.asStateFlow()
|
||||||
|
|
||||||
|
private val _sessionId = MutableStateFlow<String?>(null)
|
||||||
|
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorText = MutableStateFlow<String?>(null)
|
||||||
|
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||||
|
|
||||||
|
private val _healthOk = MutableStateFlow(false)
|
||||||
|
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
|
||||||
|
|
||||||
|
private val _thinkingLevel = MutableStateFlow("off")
|
||||||
|
val thinkingLevel: StateFlow<String> = _thinkingLevel.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingRunCount = MutableStateFlow(0)
|
||||||
|
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||||
|
|
||||||
|
private val _streamingAssistantText = MutableStateFlow<String?>(null)
|
||||||
|
val streamingAssistantText: StateFlow<String?> = _streamingAssistantText.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingToolCallsById = ConcurrentHashMap<String, ChatPendingToolCall>()
|
||||||
|
private val _pendingToolCalls = MutableStateFlow<List<ChatPendingToolCall>>(emptyList())
|
||||||
|
val pendingToolCalls: StateFlow<List<ChatPendingToolCall>> = _pendingToolCalls.asStateFlow()
|
||||||
|
|
||||||
|
private val _sessions = MutableStateFlow<List<ChatSessionEntry>>(emptyList())
|
||||||
|
val sessions: StateFlow<List<ChatSessionEntry>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingRuns = mutableSetOf<String>()
|
||||||
|
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||||
|
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<OutgoingAttachment>,
|
||||||
|
) {
|
||||||
|
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<ChatSessionEntry> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.steipete.clawdis.node.chat
|
||||||
|
|
||||||
|
data class ChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val role: String,
|
||||||
|
val content: List<ChatMessageContent>,
|
||||||
|
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<ChatMessage>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OutgoingAttachment(
|
||||||
|
val type: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val fileName: String,
|
||||||
|
val base64: String,
|
||||||
|
)
|
||||||
|
|
@ -1,73 +1,10 @@
|
||||||
package com.steipete.clawdis.node.ui
|
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.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.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatSheet(viewModel: MainViewModel) {
|
fun ChatSheet(viewModel: MainViewModel) {
|
||||||
val messages by viewModel.chatMessages.collectAsState()
|
ChatSheetContent(viewModel = viewModel)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +66,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ClawdisIdleBackground(modifier = Modifier.fillMaxSize())
|
||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<PendingImageAttachment>,
|
||||||
|
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<PendingImageAttachment>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ChatMarkdownBlock> {
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val out = ArrayList<ChatMarkdownBlock>()
|
||||||
|
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<ChatMarkdownBlock> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
|
||||||
|
val out = ArrayList<ChatMarkdownBlock>()
|
||||||
|
|
||||||
|
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<androidx.compose.ui.graphics.ImageBitmap?>(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ChatMessage>,
|
||||||
|
pendingRunCount: Int,
|
||||||
|
pendingToolCalls: List<ChatPendingToolCall>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ChatMessageContent>) {
|
||||||
|
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<ChatPendingToolCall>) {
|
||||||
|
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<androidx.compose.ui.graphics.ImageBitmap?>(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ChatSessionEntry>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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<PendingImageAttachment>() }
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue