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