Очки: история диалога в свайпе + распознавание в LLM только по клику (касяки 1-3)

This commit is contained in:
2026-08-23 20:15:21 +03:00
parent 51049d56a8
commit 706cc10d1e
9 changed files with 254 additions and 28 deletions
@@ -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,12 +147,15 @@ 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),
)
}
}
}
}
}