Очки: история диалога в свайпе + распознавание в LLM только по клику (касяки 1-3)
This commit is contained in:
@@ -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>)`,
|
||||||
|
где `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. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.
|
||||||
@@ -6,6 +6,9 @@ import com.rayneo.arsdk.android.MercurySDK
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
|
data class ChatEntry(val role: String, val text: String)
|
||||||
|
|
||||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||||
object GlassesConfig {
|
object GlassesConfig {
|
||||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
@@ -58,6 +61,13 @@ class GlassesApp : Application() {
|
|||||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||||
val assistantText = MutableStateFlow("")
|
val assistantText = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
||||||
|
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
||||||
|
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
||||||
|
*/
|
||||||
|
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import pw.binom.viewmate.core.MediaCommand
|
|||||||
import pw.binom.viewmate.core.net.GlassesTransport
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
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.ChatSessionMsg
|
||||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||||
@@ -340,6 +342,13 @@ class HostConnection(
|
|||||||
|
|
||||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
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 -> {
|
is SttPhrase -> {
|
||||||
GlassesApp.instance.sttText.value = msg.full
|
GlassesApp.instance.sttText.value = msg.full
|
||||||
log("stt", "фраза: ${msg.phrase}")
|
log("stt", "фраза: ${msg.phrase}")
|
||||||
@@ -351,9 +360,13 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
log("stt", "отменено")
|
// Фразы не было (клик без голоса) или отмена двойным кликом: НЕ выходим
|
||||||
GlassesApp.instance.listening.value = false
|
// из режима, а возвращаемся в «слушаю» — оверлей остаётся, микрофон снова
|
||||||
|
// открыт (TASK-glasses-chat-fix.md касяк №2).
|
||||||
|
log("stt", "отменено — возврат в «слушаю»")
|
||||||
GlassesApp.instance.thinking.value = false
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.mainActivity?.resumeSttListening()
|
||||||
|
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
|
||||||
}
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
|
|||||||
@@ -296,6 +296,19 @@ class MainActivity : BaseEventActivity() {
|
|||||||
sttMic.start(filesDir) { app.listening.value }
|
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 («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
||||||
private fun cancelDictation() {
|
private fun cancelDictation() {
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
log("gesture", "DoubleClick → отмена распознавания")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.viewmate.glasses.ChatEntry
|
||||||
import pw.binom.viewmate.glasses.GlassesApp
|
import pw.binom.viewmate.glasses.GlassesApp
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||||
@@ -35,7 +36,8 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
|
|||||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||||
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
|
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||||
|
* [GlassesApp.answerScrollDelta]).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AssistantOverlay() {
|
fun AssistantOverlay() {
|
||||||
@@ -44,14 +46,15 @@ fun AssistantOverlay() {
|
|||||||
val thinking by app.thinking.collectAsState()
|
val thinking by app.thinking.collectAsState()
|
||||||
val sttText by app.sttText.collectAsState()
|
val sttText by app.sttText.collectAsState()
|
||||||
val assistantText by app.assistantText.collectAsState()
|
val assistantText by app.assistantText.collectAsState()
|
||||||
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
if (!overlayVisible(state)) return
|
if (!overlayVisible(state)) return
|
||||||
overlayBox(app, state)
|
overlayBox(app, state, chatHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
private fun overlayBox(app: GlassesApp, state: AssistantState, chatHistory: List<ChatEntry>) {
|
||||||
Binocular(scale = false) {
|
Binocular(scale = false) {
|
||||||
val scale by app.scale.collectAsState()
|
val scale by app.scale.collectAsState()
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
val screenWidthPx = BinocularScreenSize.current.width
|
||||||
@@ -66,21 +69,24 @@ private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
|||||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||||
.background(Color.Black.copy(alpha = 0.72f)),
|
.background(Color.Black.copy(alpha = 0.72f)),
|
||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state)
|
AssistantOverlayContent(state, chatHistory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AssistantOverlayContent(state: AssistantState) {
|
private fun AssistantOverlayContent(state: AssistantState, chatHistory: List<ChatEntry>) {
|
||||||
val app = GlassesApp.instance
|
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 header = overlayHeader(state)
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
|
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
|
||||||
LaunchedEffect(state.assistantText) {
|
LaunchedEffect(chatHistory.size, state.assistantText) {
|
||||||
scrollState.scrollTo(0)
|
if (hasHistory || state.assistantText.isNotBlank()) {
|
||||||
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
|
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
|
||||||
@@ -101,7 +107,7 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center,
|
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
if (!header.isNullOrBlank()) {
|
if (!header.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -111,7 +117,28 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -120,10 +147,13 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
|||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.assistantText,
|
text = "ассистент: ${state.assistantText}",
|
||||||
color = Color(0xFFB0BEC5),
|
color = Color(0xFFB0BEC5),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Start,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ class GlassesHub(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var stt: SttStreamer? = null
|
var stt: SttStreamer? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||||
|
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят —
|
||||||
|
* только повторный клик пользователя.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onStopFullText: ((String) -> Unit)? = null
|
||||||
|
|
||||||
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
||||||
@Volatile
|
@Volatile
|
||||||
var sttFactory: (() -> SttStreamer?)? = null
|
var sttFactory: (() -> SttStreamer?)? = null
|
||||||
@@ -200,8 +208,17 @@ class GlassesHub(
|
|||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||||
send(connId, SttDone(full))
|
|
||||||
stt?.reset()
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import pw.binom.viewmate.core.phone.PhoneActions
|
|||||||
import pw.binom.viewmate.core.phone.PhoneState
|
import pw.binom.viewmate.core.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantState
|
import pw.binom.viewmate.core.protocol.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
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.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
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.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
import pw.binom.viewmate.phone.agent.ChatDb
|
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.Gemma4E2B
|
||||||
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||||
@@ -290,10 +293,25 @@ class PhoneApp : Application() {
|
|||||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
server.hub.broadcast(ShowText(answer))
|
server.hub.broadcast(ShowText(answer))
|
||||||
|
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||||
|
server.hub.broadcast(chatHistoryMsg())
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
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 сохраняется.
|
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||||
@@ -352,8 +370,8 @@ class PhoneApp : Application() {
|
|||||||
vadModelPath = vadModel.absolutePath,
|
vadModelPath = vadModel.absolutePath,
|
||||||
onPhrase = { phrase, full ->
|
onPhrase = { phrase, full ->
|
||||||
log("stt", "фраза: $phrase")
|
log("stt", "фраза: $phrase")
|
||||||
|
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
|
||||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
||||||
scope.launch { assistantChannel.send(phrase) }
|
|
||||||
},
|
},
|
||||||
onSilence30s = {
|
onSilence30s = {
|
||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
@@ -404,6 +422,7 @@ class PhoneApp : Application() {
|
|||||||
DebugHttpServer.start(this)
|
DebugHttpServer.start(this)
|
||||||
nsd.publish()
|
nsd.publish()
|
||||||
server.hub.sttFactory = { ensureStt() }
|
server.hub.sttFactory = { ensureStt() }
|
||||||
|
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||||
scope.launch { assistantLoop() }
|
scope.launch { assistantLoop() }
|
||||||
|
|
||||||
val lastPositionAt = AtomicLong(0)
|
val lastPositionAt = AtomicLong(0)
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ data class SttDone(val full: String) : HostToGlasses
|
|||||||
@SerialName("stt_cancel")
|
@SerialName("stt_cancel")
|
||||||
class SttCancel : HostToGlasses
|
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<ChatHistoryItem>) : HostToGlasses
|
||||||
|
|
||||||
/** Полное выключение приложения на очках (по команде с телефона). */
|
/** Полное выключение приложения на очках (по команде с телефона). */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("shutdown")
|
@SerialName("shutdown")
|
||||||
|
|||||||
@@ -222,4 +222,28 @@ class HostToGlassesTest {
|
|||||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
assertIs<SttCancel>(decoded)
|
assertIs<SttCancel>(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<ChatHistoryMsg>(decoded)
|
||||||
|
assertEquals(2, decoded.messages.size)
|
||||||
|
assertEquals("user", decoded.messages[0].role)
|
||||||
|
assertEquals("привет", decoded.messages[0].content)
|
||||||
|
assertEquals("assistant", decoded.messages[1].role)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user