From 92b483a5e32c7a676c89ff20550bfdbcb2a40eb1 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 2 Sep 2026 02:49:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=81=D0=B2=D1=91=D1=80=D0=BD=D1=83=D1=82=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=BB=D0=BE=D0=BA=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20=D1=82=D1=83=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D1=87=D0=B0=D1=82=20=D1=81=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=D0=BC=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pw/binom/viewmate/phone/ui/ChatScreen.kt | 92 +++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt index f906fe5..573568f 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt @@ -1,6 +1,7 @@ package pw.binom.viewmate.phone.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon 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.Text import androidx.compose.material3.TextButton @@ -34,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -44,6 +53,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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.LlmModelChoice import pw.binom.viewmate.phone.agent.Session @@ -61,7 +71,7 @@ fun ChatScreen() { val scope = rememberCoroutineScope() var sessions by remember { mutableStateOf>(emptyList()) } - var messages by remember { mutableStateOf>>(emptyList()) } + var messages by remember { mutableStateOf>(emptyList()) } var input by remember { mutableStateOf("") } var sessionMenu by remember { mutableStateOf(false) } var activeId by remember { mutableStateOf(null) } @@ -74,7 +84,7 @@ fun ChatScreen() { val all = dao.listSessions() val target = activeId ?: all.firstOrNull()?.id val history = target?.let { dao.messagesForSession(it) } ?: emptyList() - all to history.map { it.role to it.content } + all to history.map(::uiMsgOf) } sessions = sess activeId = activeId ?: sess.firstOrNull()?.id @@ -102,7 +112,7 @@ fun ChatScreen() { delay(2_000) val id = activeId 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 } withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s } @@ -176,7 +186,7 @@ fun ChatScreen() { TextButton(onClick = { app.resetChatSession() 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 } }) { @@ -231,8 +241,11 @@ fun ChatScreen() { ) } else { LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { - items(messages) { (role, content) -> - MessageBubble(role = role, text = content) + items(messages) { msg -> + 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() if (text.isEmpty()) return@Button input = "" - messages = messages + (ChatRole.USER to text) + messages = messages + UiMsg(ChatRole.USER, 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): * жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.