diff --git a/TASK-glasses-chat-fix.md b/TASK-glasses-chat-fix.md new file mode 100644 index 0000000..f2c103a --- /dev/null +++ b/TASK-glasses-chat-fix.md @@ -0,0 +1,90 @@ +# TASK-glasses-chat-fix.md — три кассия по голосовому диалогу (отчёт теста 23.08) + +## Контекст +Режим общения с ассистентом: очки (app-glasses) → BT SPP → телефон (app-phone) → STT (Whisper+VAD) → +LLM → ShowText. Пользовательский тест от 23.08 нашёл три кассия. Корневые причины уже установлены +расследованием — ниже точные правки. Не трогать: аппаратное ускорение GPU на телефоне, скорость — +проверено, не трогать. + +## Кассий №3 (приоритет): VAD-пауза = самоотправка в LLM +**Корень:** `PhoneApp.ensureStt()` (app-phone, ~строка 355): колбэк `onPhrase` SttStreamer делает +`assistantChannel.send(phrase)` — каждая VAD-фраза (сегмент речи, отделённый паузой ~0.25–20с) +немедленно улетает в `assistantLoop` → LLM. Человек говорит, замолчал на секунду — VAD вырезал +сегмент, и запрос уже ушёл в нейронку. + +**Правка:** +1. В `PhoneApp.ensureStt` убрать из `onPhrase` отправку в `assistantChannel` (оставить только + `broadcast(SttPhrase(...))` — экрану очков показывать распознавание по-прежнему нужно). +2. Отправка в `assistantChannel` — только по `StopStt(cancel=false)` из хаба + (`GlassesServer.handle`, ветка `is StopStt`): `val full = stt?.finish() ?: ""; stt?.reset()`, + затем (если `full.isNotBlank()`) — `assistantChannel.send(full.trim())` и бродкаст. + Т.е. единственный путь текста в LLM — повторный клик пользователя. +3. В логах сохранить читаемость: `log("stt", ...)` что фраза в LLM отправлена только по клику. + +Проверка логики: VAD-сегменты во время речи НИКОГДА не уходят в LLM; пустая пауза — тоже нет. + +## Кассий №2: пустой клик → висящее «думаю...» 10+ с → выкидывание из режима +**Корень:** клик без голоса → `StopStt(cancel=false)` → телефон шлёт `SttDone("")` + LLM-цикл +получает пустоту (после правки №3 вообще не получит). Очки в `HostConnection`: `SttDone` → +`listening=false`, а `thinking` сбрасывается только на `ShowText`/`SttCancel` — а их нет (пустая +фраза в LLM не уходит). Оверлей висит в «думаю...» до автo-отмены тишины 30с (SttCancel) → +`listening=false, thinking=false` → оверлей прячется → пользователь видит кино (пауза). +«Выкинуло из режима» + долгое «думаю» объяснено. + +**Правка:** +1. Телефон: `StopStt(cancel=false)` с пустым `full` — НЕ шлёт `SttDone`, а бродкастит `SttCancel` + («фразы не было») и `stt?.reset()` — очки возвращаются в «слушаю» (см. п.2). +2. Очки `HostConnection`: `SttCancel` — сброс `thinking=false`, `listening` = true (вернуться в + «слушаю», микрофон открыть заново — старт `sttMic` вызвать из обработчика через + `GlassesApp.instance.mainActivity`). Заголовок оверлея автоматически вернётся к «слушаю...» + (`sttText` пустой — это OK, он при SttCancel не сбрасывается: оставил бы накопленное, если было). + Реализация — в `MainActivity`: новый хук `onSttCancelledBackToListening()` (по аналогии + `startAssistantMode` без очистки стtText). +3. В `AssistantGestures.overlayHeader` поведение не менять (логика «слушаю...» уже корректна). + +Проверка: клик без голоса → очки мгновенно в «слушаю...», без «думаю» и без выхода из режима. + +## Кассий №1: в свайпе видна только последняя пара сообщений, история диалога не доступна +**Корень:** протокол передаёт очки только последний ответ (`ShowText.text`) и текущую +распознаваемую фразу (`sttText`). История сессии живёт в SQLite телефона +(`ChatDao.messagesForSession`) — очкам её никто не отдаёт. Скроллить в оверлее нечего. + +**Правка:** +1. `lib-core/.../protocol/HostToGlasses.kt`: новое сообщение + `@SerialName("chat_history") data class ChatHistoryMsg(val sessionId: String?, val messages: List)`, + где `ChatHistoryItem(val role: String /*"user"|"assistant"|"tool"*/, val content: String)` + (только user/assistant; tool-сообщения и toolCalls не нужны очкам — фильтр на телефоне). +2. `GlassesServer` (tелефон): на `StopStt(cancel=false)` с непустым full — ПОСЛЕ отправки в + LLM-цикл (или на `Welcome` — при подключении очков, если сессия активна) бродкаст + `ChatHistoryMsg` с историей текущей/активной сессии (`chatDao.messagesForSession`, + `chatDao.latestSessionId()`), роль user/assistant, content (tool-контент не передавать). + Точка отправки — после показа ответа, чтобы в истории был и ответ ассистента: + простейший вариант — бродкаст истории в `assistantLoop` сразу после `broadcast(ShowText(answer))`. +3. Очки: `GlassesApp` — `chatHistory = MutableStateFlow>(emptyList())` + (`ChatEntry(role, text)`); `HostConnection` на `ChatHistoryMsg` — `filter { role == "user" || + role == "assistant" }`, сохранить. Новый `ShowText` не должен стирать историю (это последнее + сообщение, оно уже в истории). +4. `AssistantOverlay`: вместо пары «sttText + assistantText» — список `chatHistory`: + «я: ...» (user, правее/одним цветом) и «ассистент: ...» (вторым цветом), один + `verticalScroll` на весь список, автоскролл к концу при новом `ShowText` + (`LaunchedEffect(lastHistoryVersion) { scrollState.animateScrollTo(scrollState.maxValue) }`). + Режимы «слушаю»/«думаю» заголовок — как сейчас; свайпы — те же `ScrollAnswer` дельты на общий + скролл (механизм `answerScrollDelta` сохранить, теперь он скроллит список, не одно сообщение). +5. `AssistantGestures` — без изменений (матрица жестов не меняется). + +## Ограничения +- Код только Kotlin. Не трогать: `LocalLlm.kt` (GPU-конфиг, uses-native-library), скорость, + клавиатуру телефона, автоименование сессий. +- Протокол — backward-совместимый: новые сообщения с `@SerialName`, старые клиенты не падают + (ксеролайн — ignore-неизвестные в `protocolJson`?). Проверить флаг `ignoreUnknownKeys` + в `ProtocolJson`/`protocolJson` — если не включён, включить (очки новой версии → телефон старой + и обратно). +- Unit-тесты: жесты не менять, но добавить/поправить, если `AssistantState` меняется. +- Сборка: `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug :lib-core:build` — + компиляция и тесты без ошибок. + +## Определяющий тест (прогон на устройстве позже) +1. Кликаю → говорю → пауза 2с → продолжаю → клик. LLM получает ВСЮ фразу целиком, один раз. + (Логи: одна `ВЕСЬ ТЕКСТ`, один `получена фраза`.) +2. Клик без голоса → «слушаю...» без «думаю», остаюсь в режиме. +3. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу. diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index eb04ff3..851233a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -6,6 +6,9 @@ import com.rayneo.arsdk.android.MercurySDK import java.io.File import kotlinx.coroutines.flow.MutableStateFlow +/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ +data class ChatEntry(val role: String, val text: String) + /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ object GlassesConfig { // Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон @@ -58,6 +61,13 @@ class GlassesApp : Application() { /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ val assistantText = MutableStateFlow("") + /** + * История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом + * (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона, + * новый ответ (ShowText) — последнее сообщение истории, не стирает её. + */ + val chatHistory = MutableStateFlow>(emptyList()) + /** * Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText). * Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим: diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index d6eb7d1..47d3424 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -22,6 +22,8 @@ import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.net.GlassesTransport import pw.binom.viewmate.core.net.WifiGlassesTransport import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.ChatHistoryItem +import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg import pw.binom.viewmate.core.protocol.DeleteFiles import pw.binom.viewmate.core.protocol.DownloadFiles @@ -340,6 +342,13 @@ class HostConnection( is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") + is ChatHistoryMsg -> { + log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.") + GlassesApp.instance.chatHistory.value = msg.messages + .filter { it.role == "user" || it.role == "assistant" } + .map { ChatEntry(it.role, it.content) } + } + is SttPhrase -> { GlassesApp.instance.sttText.value = msg.full log("stt", "фраза: ${msg.phrase}") @@ -351,9 +360,13 @@ class HostConnection( } is SttCancel -> { - log("stt", "отменено") - GlassesApp.instance.listening.value = false + // Фразы не было (клик без голоса) или отмена двойным кликом: НЕ выходим + // из режима, а возвращаемся в «слушаю» — оверлей остаётся, микрофон снова + // открыт (TASK-glasses-chat-fix.md касяк №2). + log("stt", "отменено — возврат в «слушаю»") GlassesApp.instance.thinking.value = false + GlassesApp.instance.mainActivity?.resumeSttListening() + ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") } is Shutdown -> { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 309450a..cc6f49d 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -296,6 +296,19 @@ class MainActivity : BaseEventActivity() { sttMic.start(filesDir) { app.listening.value } } + /** + * Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без + * голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается, + * накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2). + */ + fun resumeSttListening() { + val app = GlassesApp.instance + app.thinking.value = false + app.listening.value = true + sttMic.start(filesDir) { app.listening.value } + log("stt", "возврат в «слушаю» (SttCancel)") + } + /** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */ private fun cancelDictation() { log("gesture", "DoubleClick → отмена распознавания") diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index fd48a38..4e4b13a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import pw.binom.viewmate.glasses.ChatEntry import pw.binom.viewmate.glasses.GlassesApp import pw.binom.viewmate.glasses.gestures.AssistantState import pw.binom.viewmate.glasses.gestures.overlayHeader @@ -35,7 +36,8 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset * Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку * (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md). * По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок, - * «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]). + * «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов — + * [GlassesApp.answerScrollDelta]). */ @Composable fun AssistantOverlay() { @@ -44,14 +46,15 @@ fun AssistantOverlay() { val thinking by app.thinking.collectAsState() val sttText by app.sttText.collectAsState() val assistantText by app.assistantText.collectAsState() + val chatHistory by app.chatHistory.collectAsState() val state = AssistantState(listening, thinking, sttText, assistantText) if (!overlayVisible(state)) return - overlayBox(app, state) + overlayBox(app, state, chatHistory) } /** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */ @Composable -private fun overlayBox(app: GlassesApp, state: AssistantState) { +private fun overlayBox(app: GlassesApp, state: AssistantState, chatHistory: List) { Binocular(scale = false) { val scale by app.scale.collectAsState() val screenWidthPx = BinocularScreenSize.current.width @@ -66,42 +69,45 @@ private fun overlayBox(app: GlassesApp, state: AssistantState) { .offset(x = scaleOffsetX(side, screenWidthPx, scale).px) .background(Color.Black.copy(alpha = 0.72f)), ) { - AssistantOverlayContent(state) + AssistantOverlayContent(state, chatHistory) } } } @Composable -private fun AssistantOverlayContent(state: AssistantState) { +private fun AssistantOverlayContent(state: AssistantState, chatHistory: List) { val app = GlassesApp.instance - val hasAnswer = state.assistantText.isNotBlank() + val hasHistory = chatHistory.isNotEmpty() + val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank() val header = overlayHeader(state) val scrollState = rememberScrollState() - // Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста. - LaunchedEffect(state.assistantText) { - scrollState.scrollTo(0) + // Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению. + LaunchedEffect(chatHistory.size, state.assistantText) { + if (hasHistory || state.assistantText.isNotBlank()) { + scrollState.animateScrollTo(scrollState.maxValue) + } } - // Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect], - // аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. - LaunchedEffect(Unit) { - app.answerScrollDelta.collect { pending -> - if (pending == 0f) return@collect - val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) - app.answerScrollDelta.value = 0f - if (target != scrollState.value) { - scrollState.animateScrollTo(target, tween(150)) - } + // Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect], + // аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. + LaunchedEffect(Unit) { + app.answerScrollDelta.collect { pending -> + if (pending == 0f) return@collect + val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) + app.answerScrollDelta.value = 0f + if (target != scrollState.value) { + scrollState.animateScrollTo(target, tween(150)) } } + } Column( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center, + verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center, ) { if (!header.isNullOrBlank()) { Text( @@ -111,7 +117,28 @@ private fun AssistantOverlayContent(state: AssistantState) { style = MaterialTheme.typography.titleMedium, ) } - if (hasAnswer) { + if (hasHistory) { + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState), + ) { + chatHistory.forEach { entry -> + Text( + text = if (entry.role == "user") "я: ${entry.text}" else "ассистент: ${entry.text}", + color = if (entry.role == "user") Color.White else Color(0xFFB0BEC5), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + } else if (fallbackAnswer) { + // Старый клиент телефона не шлёт историю: показываем одиночный ответ. Spacer(modifier = Modifier.height(12.dp)) Column( modifier = Modifier @@ -120,12 +147,15 @@ private fun AssistantOverlayContent(state: AssistantState) { .verticalScroll(scrollState), ) { Text( - text = state.assistantText, + text = "ассистент: ${state.assistantText}", color = Color(0xFFB0BEC5), - textAlign = TextAlign.Center, + textAlign = TextAlign.Start, style = MaterialTheme.typography.titleSmall, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), ) } } } -} +} \ No newline at end of file diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index 91f9620..b0b41ed 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -105,6 +105,14 @@ class GlassesHub( @Volatile var stt: SttStreamer? = null + /** + * Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send) + * на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят — + * только повторный клик пользователя. + */ + @Volatile + var onStopFullText: ((String) -> Unit)? = null + /** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */ @Volatile var sttFactory: (() -> SttStreamer?)? = null @@ -200,8 +208,17 @@ class GlassesHub( } else { val full = stt?.finish() ?: "" log("stt", "ВЕСЬ ТЕКСТ: $full") - send(connId, SttDone(full)) stt?.reset() + if (full.isNotBlank()) { + send(connId, SttDone(full)) + val phrase = full.trim() + log("stt", "фраза в LLM (по клику): $phrase") + onStopFullText?.invoke(phrase) + } else { + // Фразы не было — очки возвращаются в «слушаю» (SttCancel). + log("stt", "фразы не было — очки в «слушаю»") + broadcast(SttCancel()) + } } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 0da379e..19b99d2 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -23,6 +23,8 @@ import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.protocol.AssistantState import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.ChatHistoryItem +import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttPhrase @@ -30,6 +32,7 @@ import pw.binom.viewmate.phone.agent.AgentToolkit import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDb +import pw.binom.viewmate.phone.agent.ChatRole import pw.binom.viewmate.phone.agent.Gemma4E2B import pw.binom.viewmate.phone.agent.GemmaModelDownloader import pw.binom.viewmate.phone.agent.JellyfinMediaSource @@ -290,10 +293,25 @@ class PhoneApp : Application() { .onFailure { log("assistant", "ошибка: ${it.message}") } .getOrElse { "Ассистент недоступен: ${it.message}" } server.hub.broadcast(ShowText(answer)) + // Очки показывают историю всей сессии (свайпами); ответ уже в ней. + server.hub.broadcast(chatHistoryMsg()) server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE)) } } + /** + * История активной (или последней) сессии для очков: только роли user/assistant, + * tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1). + */ + private fun chatHistoryMsg(): ChatHistoryMsg { + val id = _activeSessionId.value ?: chatDao.latestSessionId() + if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList()) + val items = chatDao.messagesForSession(id) + .filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT } + .map { ChatHistoryItem(role = it.role.wire, content = it.content) } + return ChatHistoryMsg(sessionId = id.toString(), messages = items) + } + /** * Отправка текста в ассистента из чата на телефоне (не с очков). * Та же очередь, что и голосовые фразы — сериализация LLM сохраняется. @@ -352,8 +370,8 @@ class PhoneApp : Application() { vadModelPath = vadModel.absolutePath, onPhrase = { phrase, full -> log("stt", "фраза: $phrase") + // Только показ на очках — в LLM фраза НЕ уходит (только по клику). scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) } - scope.launch { assistantChannel.send(phrase) } }, onSilence30s = { log("stt", "тишина 30 с — автo-отмена") @@ -404,6 +422,7 @@ class PhoneApp : Application() { DebugHttpServer.start(this) nsd.publish() server.hub.sttFactory = { ensureStt() } + server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } } scope.launch { assistantLoop() } val lastPositionAt = AtomicLong(0) diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index cb20668..d01681c 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -96,6 +96,16 @@ data class SttDone(val full: String) : HostToGlasses @SerialName("stt_cancel") class SttCancel : HostToGlasses +/** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */ +@Serializable +@SerialName("chat_history_item") +data class ChatHistoryItem(val role: String, val content: String) + +/** История сессии для очков (показ всего диалога со скроллом свайпами). */ +@Serializable +@SerialName("chat_history") +data class ChatHistoryMsg(val sessionId: String?, val messages: List) : HostToGlasses + /** Полное выключение приложения на очках (по команде с телефона). */ @Serializable @SerialName("shutdown") diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index 9cc1e4e..5f72125 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -222,4 +222,28 @@ class HostToGlassesTest { val decoded = json.decodeFromString(HostToGlasses.serializer(), text) assertIs(decoded) } + + @Test + fun chatHistoryRoundTrip() { + val msg: HostToGlasses = ChatHistoryMsg( + sessionId = "s-1", + messages = listOf( + ChatHistoryItem(role = "user", content = "привет"), + ChatHistoryItem(role = "assistant", content = "здравствуй"), + ), + ) + val text = json.encodeToString(HostToGlasses.serializer(), msg) + assertEquals( + """{"type":"chat_history","sessionId":"s-1","messages":[""" + + """{"role":"user","content":"привет"},""" + + """{"role":"assistant","content":"здравствуй"}]}""", + text, + ) + val decoded = json.decodeFromString(HostToGlasses.serializer(), text) + assertIs(decoded) + assertEquals(2, decoded.messages.size) + assertEquals("user", decoded.messages[0].role) + assertEquals("привет", decoded.messages[0].content) + assertEquals("assistant", decoded.messages[1].role) + } }