Очки: история диалога в свайпе + распознавание в LLM только по клику (касяки 1-3)
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user