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

9.0 KiB
Raw Blame History

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