Files
view-mate/TASK-glasses-chat-fix.md
T

91 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>)`,
где `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<List<ChatEntry>>(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. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.