Очки: история диалога в свайпе + распознавание в 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
@@ -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),
)
}
}
}
}
}