# 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. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.