Добавляем свёрнутый блок результата тула в чат с раскрытием по тапу

This commit is contained in:
2026-09-02 02:49:48 +03:00
parent edf7cf63f6
commit 92b483a5e3
@@ -1,6 +1,7 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -12,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -20,7 +23,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -34,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -44,6 +53,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.ChatMessage
import pw.binom.viewmate.phone.agent.ChatRole import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmModelChoice import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.Session import pw.binom.viewmate.phone.agent.Session
@@ -61,7 +71,7 @@ fun ChatScreen() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) } var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) } var messages by remember { mutableStateOf<List<UiMsg>>(emptyList()) }
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) } var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(null) } var activeId by remember { mutableStateOf<Long?>(null) }
@@ -74,7 +84,7 @@ fun ChatScreen() {
val all = dao.listSessions() val all = dao.listSessions()
val target = activeId ?: all.firstOrNull()?.id val target = activeId ?: all.firstOrNull()?.id
val history = target?.let { dao.messagesForSession(it) } ?: emptyList() val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
all to history.map { it.role to it.content } all to history.map(::uiMsgOf)
} }
sessions = sess sessions = sess
activeId = activeId ?: sess.firstOrNull()?.id activeId = activeId ?: sess.firstOrNull()?.id
@@ -102,7 +112,7 @@ fun ChatScreen() {
delay(2_000) delay(2_000)
val id = activeId val id = activeId
if (id != null) { if (id != null) {
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } } val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map(::uiMsgOf) }
if (fresh != messages) messages = fresh if (fresh != messages) messages = fresh
} }
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s } withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
@@ -176,7 +186,7 @@ fun ChatScreen() {
TextButton(onClick = { TextButton(onClick = {
app.resetChatSession() app.resetChatSession()
activeId?.let { id -> activeId?.let { id ->
val history = app.chatDao.messagesForSession(id).map { it.role to it.content } val history = app.chatDao.messagesForSession(id).map(::uiMsgOf)
messages = history messages = history
} }
}) { }) {
@@ -231,8 +241,11 @@ fun ChatScreen() {
) )
} else { } else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { (role, content) -> items(messages) { msg ->
MessageBubble(role = role, text = content) when (msg.role) {
ChatRole.TOOL -> ToolBubble(msg)
else -> MessageBubble(role = msg.role, text = msg.content)
}
} }
} }
} }
@@ -255,7 +268,7 @@ fun ChatScreen() {
val text = input.trim() val text = input.trim()
if (text.isEmpty()) return@Button if (text.isEmpty()) return@Button
input = "" input = ""
messages = messages + (ChatRole.USER to text) messages = messages + UiMsg(ChatRole.USER, text)
app.sendToAssistant(text) app.sendToAssistant(text)
}, },
) { ) {
@@ -290,6 +303,71 @@ private fun MessageBubble(role: ChatRole, text: String) {
} }
} }
/** Элемент ленты: tool-сообщения дополнительно несут имя тула (в БД toolCallId = имя тула). */
private data class UiMsg(
val role: ChatRole,
val content: String,
val tool: String? = null,
)
private fun uiMsgOf(m: ChatMessage) = UiMsg(
role = m.role,
content = m.content,
tool = if (m.role == ChatRole.TOOL) m.toolCallId else null,
)
/**
* Системный блок результата тула (ChatRole.TOOL): свёрнут по умолчанию —
* иконка + «тул · имя», тап раскрывает текст результата. Система видна
* по требованию, но не шумит в основной переписке.
*/
@Composable
private fun ToolBubble(msg: UiMsg) {
var expanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.Start,
) {
Column(
modifier = Modifier
.widthIn(max = 340.dp)
.clip(RoundedCornerShape(10.dp))
.background(PhoneCard)
.clickable { expanded = !expanded }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = "тул",
tint = PhoneMuted,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = "тул · ${msg.tool ?: "результат"}",
color = PhoneMuted,
fontSize = 12.sp,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expanded) "свернуть" else "развернуть",
tint = PhoneMuted,
modifier = Modifier.size(14.dp),
)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Text(
text = msg.content,
color = PhoneTextDim,
fontSize = 12.sp,
)
}
}
}
}
/** /**
* Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3): * Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3):
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад. * жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.