feat(android): chat parity + wide-area discovery
This commit is contained in:
@@ -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<String> = runtime.manualHost
|
||||
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 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
|
||||
|
||||
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<OutgoingAttachment>) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = 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<List<ChatMessage>>(emptyList())
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
|
||||
|
||||
private val _chatError = MutableStateFlow<String?>(null)
|
||||
val chatError: StateFlow<String?> = _chatError.asStateFlow()
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val _pendingRunCount = MutableStateFlow(0)
|
||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||
val chatError: StateFlow<String?> = chat.errorText
|
||||
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||
val pendingRunCount: StateFlow<Int> = 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<OutgoingAttachment>) {
|
||||
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<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 {
|
||||
if (command.startsWith("screen.") || command.startsWith("camera.")) {
|
||||
if (!isForeground.value) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ClawdisIdleBackground(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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user