9.0 KiB
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 вырезал
сегмент, и запрос уже ушёл в нейронку.
Правка:
- В
PhoneApp.ensureSttубрать изonPhraseотправку вassistantChannel(оставить толькоbroadcast(SttPhrase(...))— экрану очков показывать распознавание по-прежнему нужно). - Отправка в
assistantChannel— только поStopStt(cancel=false)из хаба (GlassesServer.handle, веткаis StopStt):val full = stt?.finish() ?: ""; stt?.reset(), затем (еслиfull.isNotBlank()) —assistantChannel.send(full.trim())и бродкаст. Т.е. единственный путь текста в LLM — повторный клик пользователя. - В логах сохранить читаемость:
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 → оверлей прячется → пользователь видит кино (пауза).
«Выкинуло из режима» + долгое «думаю» объяснено.
Правка:
- Телефон:
StopStt(cancel=false)с пустымfull— НЕ шлётSttDone, а бродкаститSttCancel(«фразы не было») иstt?.reset()— очки возвращаются в «слушаю» (см. п.2). - Очки
HostConnection:SttCancel— сбросthinking=false,listening= true (вернуться в «слушаю», микрофон открыть заново — стартsttMicвызвать из обработчика черезGlassesApp.instance.mainActivity). Заголовок оверлея автоматически вернётся к «слушаю...» (sttTextпустой — это OK, он при SttCancel не сбрасывается: оставил бы накопленное, если было). Реализация — вMainActivity: новый хукonSttCancelledBackToListening()(по аналогииstartAssistantModeбез очистки стtText). - В
AssistantGestures.overlayHeaderповедение не менять (логика «слушаю...» уже корректна).
Проверка: клик без голоса → очки мгновенно в «слушаю...», без «думаю» и без выхода из режима.
Кассий №1: в свайпе видна только последняя пара сообщений, история диалога не доступна
Корень: протокол передаёт очки только последний ответ (ShowText.text) и текущую
распознаваемую фразу (sttText). История сессии живёт в SQLite телефона
(ChatDao.messagesForSession) — очкам её никто не отдаёт. Скроллить в оверлее нечего.
Правка:
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 не нужны очкам — фильтр на телефоне).GlassesServer(tелефон): наStopStt(cancel=false)с непустым full — ПОСЛЕ отправки в LLM-цикл (или наWelcome— при подключении очков, если сессия активна) бродкастChatHistoryMsgс историей текущей/активной сессии (chatDao.messagesForSession,chatDao.latestSessionId()), роль user/assistant, content (tool-контент не передавать). Точка отправки — после показа ответа, чтобы в истории был и ответ ассистента: простейший вариант — бродкаст истории вassistantLoopсразу послеbroadcast(ShowText(answer)).- Очки:
GlassesApp—chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())(ChatEntry(role, text));HostConnectionнаChatHistoryMsg—filter { role == "user" || role == "assistant" }, сохранить. НовыйShowTextне должен стирать историю (это последнее сообщение, оно уже в истории). AssistantOverlay: вместо пары «sttText + assistantText» — списокchatHistory: «я: ...» (user, правее/одним цветом) и «ассистент: ...» (вторым цветом), одинverticalScrollна весь список, автоскролл к концу при новомShowText(LaunchedEffect(lastHistoryVersion) { scrollState.animateScrollTo(scrollState.maxValue) }). Режимы «слушаю»/«думаю» заголовок — как сейчас; свайпы — те жеScrollAnswerдельты на общий скролл (механизмanswerScrollDeltaсохранить, теперь он скроллит список, не одно сообщение).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— компиляция и тесты без ошибок.
Определяющий тест (прогон на устройстве позже)
- Кликаю → говорю → пауза 2с → продолжаю → клик. LLM получает ВСЮ фразу целиком, один раз.
(Логи: одна
ВЕСЬ ТЕКСТ, одинполучена фраза.) - Клик без голоса → «слушаю...» без «думаю», остаюсь в режиме.
- Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.