From 1b817aa9a6e0c3405dae7f4c884c1474e937ca6d Mon Sep 17 00:00:00 2001 From: subochev Date: Mon, 7 Sep 2026 01:09:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D0=BC=20=D1=81=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pw/binom/viewmate/glasses/Context.kt | 182 ++++++++++++ .../pw/binom/viewmate/glasses/GlassesApp.kt | 82 +++--- .../binom/viewmate/glasses/HostConnection.kt | 36 ++- .../pw/binom/viewmate/glasses/MainActivity.kt | 246 ++++------------ .../glasses/gestures/AssistantGestures.kt | 169 +++++++---- .../glasses/status/StatusPanelCompose.kt | 2 +- .../binom/viewmate/glasses/stt/GlassesAsr.kt | 4 +- .../viewmate/glasses/stt/GlassesRecognizer.kt | 31 +++ .../viewmate/glasses/stt/GlassesSttSession.kt | 46 ++- .../viewmate/glasses/stt/SttModelProvider.kt | 2 +- .../viewmate/glasses/ui/AssistantOverlay.kt | 44 ++- .../binom/viewmate/glasses/ui/LayoutStack.kt | 42 +++ .../glasses/ui/layers/AssistantEngine.kt | 263 ++++++++++++++++++ .../glasses/ui/layers/AssistantLayout.kt | 90 ++++++ .../glasses/ui/layers/ListeningLayout.kt | 71 +++++ .../viewmate/glasses/ui/layers/MoveLayout.kt | 73 +++++ .../viewmate/glasses/LayoutManagerTest.kt | 168 +++++++++++ .../glasses/gestures/AssistantGesturesTest.kt | 235 ++++++++++------ .../pw/binom/viewmate/phone/MercuryBridge.kt | 13 + .../pw/binom/viewmate/phone/PhoneApp.kt | 91 ++++-- .../pw/binom/viewmate/phone/ui/ChatScreen.kt | 33 ++- .../viewmate/phone/ui/GlassesInfoScreen.kt | 95 ++++--- .../binom/viewmate/phone/ui/GlassesScreen.kt | 14 + .../binom/viewmate/phone/ui/MercuryPanel.kt | 13 + 24 files changed, 1564 insertions(+), 481 deletions(-) create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Context.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesRecognizer.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/LayoutStack.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LayoutManagerTest.kt diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Context.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Context.kt new file mode 100644 index 0000000..925067c --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Context.kt @@ -0,0 +1,182 @@ +package pw.binom.viewmate.glasses + +import android.view.MotionEvent +import androidx.compose.runtime.Composable +import com.rayneo.arsdk.android.touch.FlingArgs +import com.rayneo.arsdk.android.touch.TempleAction + +/** + * Дискретный жест трекпада. Плавного (continuous) жеста нет: [TempleAction.SlideContinuous] + * по знаку дельты сворачивается в дискретный свайп ([toGesture]) — плавной прокрутки диалога + * ассистента больше не нужно. + */ +enum class Gesture { Click, DoubleClick, TripleClick, SwipeForward, SwipeBackward } + +/** + * SDK-событие трекпада → дискретный [Gesture]. Возвращает null, если жест игнорируется + * (MoveUp/ActionUp/Idle/LongClick). [TempleAction.SlideContinuous] → дискретный свайп по знаку + * дельты (>=0 вперёд). + */ +fun TempleAction.toGesture(): Gesture? = when (this) { + is TempleAction.Click -> Gesture.Click + is TempleAction.DoubleClick -> Gesture.DoubleClick + is TempleAction.TripleClick -> Gesture.TripleClick + is TempleAction.SlideForward -> Gesture.SwipeForward + is TempleAction.SlideBackward -> Gesture.SwipeBackward + is TempleAction.SlideContinuous -> if (this.delta >= 0f) Gesture.SwipeForward else Gesture.SwipeBackward + else -> null +} + +/** + * Обратный [toGesture] — [Gesture] → SDK-событие, чтобы маршрутизацию layout-а прогнать через + * единственную матрицу [pw.binom.viewmate.glasses.gestures.gestureDecision] (она ждёт + * [TempleAction]). Плавного жеста нет — дискретные свайпы. + */ +fun Gesture.toAction(): TempleAction = when (this) { + Gesture.Click -> TempleAction.Click() + Gesture.DoubleClick -> TempleAction.DoubleClick() + Gesture.TripleClick -> TempleAction.TripleClick() + Gesture.SwipeForward -> TempleAction.SlideForward(DUMMY_FLING) + Gesture.SwipeBackward -> TempleAction.SlideBackward(DUMMY_FLING) +} + +/** + * Плейсхолдер [FlingArgs] для свайпов в [toAction]: матрица [gestureDecision] смотрит только + * тип жеста, args свайпа ей не нужны, поэтому одна переиспользуемая заглушка (один + * [MotionEvent] на всё время — pool-утечки нет). + */ +private val DUMMY_FLING: FlingArgs = run { + val e = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0) + FlingArgs(e, e, 0f, 0f) +} + +/** + * Экран/слой очков. Рисуется композитом в стек [LayoutManager]: снизу вверх, между слоями + * затемнение. [gesture] вызывается ТОЛЬКО вершиной стека — [LayoutManager.dispatch] маршрутизирует + * каждый жест в [Layout.gesture] активного (верхнего) layout-а, остальные слои жест не получают. + */ +interface Layout { + /** Основной экран слоя (видео/чат и т.п.). */ + @Composable + fun drawStage() + + /** Панель состояния: вызывается первой, потом стандартные время/заряд. */ + @Composable + fun drawStatusPanel() + + /** Обработка жестов данным слоем. Вызывается менеджером только для верхнего слоя. */ + fun gesture(g: Gesture) + + /** + * Слой открыт (встал вершиной стека) — менеджер вызывает сразу после [LayoutContext.open]. + * Под-слой использует для своего «старта»: напр. [pw.binom.viewmate.glasses.ui.layers.ListeningLayout] + * здесь открывает микрофон. Корневой слой не получает (он не «открывается», а создаётся менеджером). + */ + fun onEnter() {} + + /** Слой закрыт (POP-нут из стека) — менеджер вызывает в [LayoutContext.close]. Симметрично [onEnter]. */ + fun onExit() {} +} + +/** + * Контекст, доступный layout-у: открыть под-слой ([open]), закрыть себя ([close]), управлять + * глобальным индикатором записи ([setRecordingIndicator]). Индикатор горит красным, пока активен + * хотя бы один слой, включивший его; выключается, когда этот слой перестал быть активным (POP) + * или переключил его в false. Экземпляр создаёт [LayoutManager] — каждый layout получает один + * общий контекст (самого менеджера). + */ +interface LayoutContext { + fun setRecordingIndicator(on: Boolean) + + /** Закрыть текущий (верхний) слой. Корневой слой закрыться не может. */ + fun close() + + /** Выйти до корня (корневой слой остаётся). */ + fun closeToRoot() + + /** + * Открыть новый слой поверх: менеджер сам создаст layout через [factory] и передаст ему + * контекст. Инвариант — только активный (верхний) слой открывает под-слой. + */ + fun open(factory: (LayoutContext) -> Layout) +} + +/** + * ЛIFO-стек layout-ов очков (то, что пользователь называет «стек»): корень (двойным кликом не + * вытолкнуть) → сверху под-слои. Владеет стеком и контекстом одновременно — один объект, без + * «расщеплённого» context/manager. Корень создаётся [rootFactory], которому менеджер отдаёт сам + * себя как [LayoutContext] (корень открывает под-слои через [LayoutContext.open]). + * + * Композитинг — [compositing]: рисуются ВСЕ слои снизу вверх, между ними затемнение + * (например `фильм + затемнение + ассистент`). Жесты — [dispatch] в вершину. + */ +class LayoutManager( + rootFactory: (LayoutContext) -> Layout, +) : LayoutContext { + private val stack = ArrayDeque() + private val recordingOn = linkedSetOf() + + /** Колбэк на каждую mутацию стека (open/close/closeToRoot) — для внешних проекций (напр. navStack). */ + var onChange: (List) -> Unit = {} + + init { + stack.addLast(rootFactory(this)) + onChange(stack.toList()) + } + + /** Верхний (активный) слой. */ + val current: Layout get() = stack.last() + + /** Корневой слой (не выталкивается; владелец медиастейта, напр. [MoveLayout]). */ + val root: Layout get() = stack.first() + + /** Глубина стека (1 = только корень). */ + val depth: Int get() = stack.size + + /** Стек снизу вверх. */ + fun layersBottomUp(): List = stack.toList() + + /** Композит: каждый слой + флаг затемнения (затемнять каждый слой, кроме верхнего). */ + data class Layer(val layout: Layout, val dim: Boolean) + + /** Композит снизу вверх: film → dim → assistant. Верхний (последний) — без затемнения. */ + fun compositing(): List = + stack.mapIndexed { i, l -> Layer(l, i < stack.lastIndex) } + + /** Маршрутизация жеста в верхний слой — единственная точка входа. */ + fun dispatch(g: Gesture) { + current.gesture(g) + } + + // LayoutContext: + + override fun open(factory: (LayoutContext) -> Layout) { + val child = factory(this) + stack.addLast(child) + child.onEnter() + onChange(stack.toList()) + } + + override fun close() { + // Корневой слой не закрывается. + if (stack.size > 1) { + val popped = stack.removeLast() + popped.onExit() + recordingOn.remove(popped) + onChange(stack.toList()) + } + } + + /** Выйти до корня (корень остаётся). */ + override fun closeToRoot() { + while (stack.size > 1) close() + } + + override fun setRecordingIndicator(on: Boolean) { + val top = current + if (on) recordingOn.add(top) else recordingOn.remove(top) + } + + /** Индикатор записи: горит, пока активен хотя бы один слой с включённым индикатором. */ + fun recordingIndicator(): Boolean = recordingOn.isNotEmpty() +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index d6a5be5..7d97fd5 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -11,9 +11,14 @@ import kotlinx.coroutines.CoroutineScope import pw.binom.viewmate.core.log.BatchingLogCollector import pw.binom.viewmate.core.log.FileLogSpool import pw.binom.viewmate.core.log.SystemClock +import pw.binom.viewmate.glasses.gestures.NavFrame import pw.binom.viewmate.glasses.stt.GlassesAsr import pw.binom.viewmate.glasses.stt.GlassesSttSession +import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer import pw.binom.viewmate.glasses.stt.SttModelDownloader +import pw.binom.viewmate.glasses.ui.layers.AssistantEngine +import pw.binom.viewmate.glasses.ui.layers.MoveLayout +import pw.binom.viewmate.glasses.ui.layers.toFrame /** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ data class ChatEntry(val role: String, val text: String) @@ -47,9 +52,6 @@ class GlassesApp : Application() { lateinit var hostConnection: HostConnection private set - lateinit var movieController: MovieController - private set - /** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */ lateinit var downloadManager: GlassesDownloadManager private set @@ -66,6 +68,26 @@ class GlassesApp : Application() { lateinit var sttSession: GlassesSttSession private set + /** + * Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и + * контекст приложения для своего медиастейта). Единственный владелец стека: слои + * открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close]; + * [navStack] — проекция этого стека для оверлея. + */ + val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) } + + /** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до + * [MovieController] через него ([rootLayer.controller]). */ + val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout + + /** + * Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты. + * Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout] + * и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine]. + * Зависимости от [GlassesApp] (sttSession/sttModel/hostConnection/rootLayer) — лениво, в методах. + */ + val assistantEngine = AssistantEngine(this) + /** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */ lateinit var logCollector: BatchingLogCollector @@ -84,39 +106,18 @@ class GlassesApp : Application() { /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ val vertical = MutableStateFlow(1f) - /** - * Идёт распознавание речи (TripleClick стартовал; завершает фразу Click, - * отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в - * «слушаю» снова; 30с тишины/выход из режима закрывают сессию). - */ - val listening = MutableStateFlow(false) - - /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ - val sttText = MutableStateFlow("") - - /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ - val assistantText = MutableStateFlow("") + // Стейт ассистента (listening/sttText/assistantText/chatHistory/thinking/sttStartBlocked + // + sttNoSpeech/answerScrollDelta ниже) вынесен в [assistantEngine] — владелец стейта. /** - * История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом - * (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона, - * новый ответ (ShowText) — последнее сообщение истории, не стирает её. + * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не + * вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне). + * Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр: + * из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/ + * «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре + * ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode]. */ - val chatHistory = MutableStateFlow>(emptyList()) - - /** - * Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText). - * Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим: - * до первого ShowText оверлей показывает «думаю...» вместо «слушаю...». - */ - val thinking = MutableStateFlow(false) - - /** - * Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста. - * Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»), - * потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла. - */ - val answerScrollDelta = MutableStateFlow(0f) + val navStack = MutableStateFlow(listOf(NavFrame.Film)) /** * Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс @@ -148,12 +149,12 @@ class GlassesApp : Application() { scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f) vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f) MercurySDK.init(this) - movieController = MovieController(applicationContext) - movieController.eventLogger = { msg -> logToFile("mc: $msg") } + // Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении. + rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } hostConnection = HostConnection( fallbackUrl = GlassesConfig.HOST_URL, - movieController = movieController, + movieController = rootLayer.controller, downloadManager = downloadManager, context = applicationContext, ) @@ -184,10 +185,15 @@ class GlassesApp : Application() { sttSession = GlassesSttSession( filesDir = filesDir, scope = bgScope, - recognizer = { asr.recognize(it) }, - modelReady = { sttModel.status.value.ready }, + recognizer = LocalGlassesRecognizer(asr, sttModel), onResult = { text, auto -> mainActivity?.onSttResult(text, auto) }, ) + + // Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager, + // navStack — лишь его зеркало. Начальная проекция (корень = Film). + layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } } + navStack.value = layoutManager.layersBottomUp().map { it.toFrame() } + log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]") } companion object { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index ba59f4c..2f8894a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -354,39 +354,38 @@ class HostConnection( is ShowText -> { addMessage("ShowText: ${msg.text}") - GlassesApp.instance.thinking.value = false - GlassesApp.instance.answerScrollDelta.value = 0f - GlassesApp.instance.assistantText.value = msg.text + GlassesApp.instance.assistantEngine.onShowText(msg.text) } 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) } + GlassesApp.instance.assistantEngine.onChatHistory( + msg.messages + .filter { it.role == "user" || it.role == "assistant" } + .map { ChatEntry(it.role, it.content) }, + ) } is SttPhrase -> { - GlassesApp.instance.sttText.value = msg.full + GlassesApp.instance.assistantEngine.onSttPhrase(msg.full) log("stt", "фраза: ${msg.phrase}") } is SttDone -> { log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") - GlassesApp.instance.listening.value = false + GlassesApp.instance.assistantEngine.onStopListening() } is SttCancel -> { when (msg.reason) { SttCancel.REASON_CANCEL -> { - // Явная отмена диктовки двойным тапом: очки САМИ уже вернулись - // в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат, - // оверлей остаётся, микрофон готов к следующей записи). Это всего - // подтверждение, что телефон отменил STT-сессию (stt.reset()) — - // состояния (listening/thinking/оверлей) НЕ трогаем. - log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте") + // Явная отмена диктовки двойным тапом: очки САМИ уже сделали стоп + // (POP кадра «слушаю» с ОСТАВАНИЕМ в ассистенте — микрофон закрыт, + // чат не прерван, оверлей на месте). Это всего подтверждение, что + // телефон отменил STT-сессию (stt.reset()) — состояния не трогаем. + log("stt", "отмена диктовки (двойной тап) — стоп, остался в ассистенте, оверлей на месте") } SttCancel.REASON_TIMEOUT -> { // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из @@ -397,11 +396,10 @@ class HostConnection( } else -> { // reason="user" — пустой клик без голоса: не выходим из режима, - // возвращаемся в «слушаю» (микрофон снова открыт). - log("stt", "отменено — возврат в «слушаю»") - GlassesApp.instance.thinking.value = false - GlassesApp.instance.mainActivity?.resumeSttListening() - ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") + // стоп-записи с остаованием в ассистенте (idle), «не расслышал». + log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)") + GlassesApp.instance.mainActivity?.stopSttListening() + ?: log("stt", "mainActivity ещё нет — не могу сделать стоп") } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 4012ec6..3f27037 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -49,17 +49,14 @@ import kotlinx.coroutines.launch import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.SttText -import pw.binom.viewmate.glasses.gestures.AssistantState -import pw.binom.viewmate.glasses.gestures.GestureDecision import pw.binom.viewmate.glasses.gestures.actionName -import pw.binom.viewmate.glasses.gestures.assistantMode -import pw.binom.viewmate.glasses.gestures.gestureDecision import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.ui.AssistantOverlay import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.BinocularScreenSide import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.GlassesMovieScreen +import pw.binom.viewmate.glasses.ui.LayoutStackContent import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.scaleOffsetX @@ -89,10 +86,10 @@ class MainActivity : BaseEventActivity() { when (intent?.action) { Intent.ACTION_SCREEN_OFF -> { log("ui", "SCREEN_OFF receiver") - val was = GlassesApp.instance.movieController.pauseIfPlaying() + val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying() resumeAfterScreenOn = resumeAfterScreenOn || was logEvent("SCREEN_OFF: pause wasPlaying=$was") - GlassesApp.instance.movieController.ensureWifiLock() + GlassesApp.instance.rootLayer.controller.ensureWifiLock() GlassesApp.instance.hostConnection.sendPlaybackPositionNow() if (was) log("ui", "экран погас (receiver) — пауза") } @@ -101,7 +98,7 @@ class MainActivity : BaseEventActivity() { logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn") if (resumeAfterScreenOn) { resumeAfterScreenOn = false - GlassesApp.instance.movieController.resume() + GlassesApp.instance.rootLayer.controller.resume() sendPositionWithRetries() log("ui", "экран включён (receiver) — продолжаю") } @@ -119,24 +116,10 @@ class MainActivity : BaseEventActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { MaterialTheme(colorScheme = darkColorScheme()) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - val controller = GlassesApp.instance.movieController - val movieActive by controller.movieActive.collectAsState() - val title by controller.title.collectAsState() - val player = controller.player - if (movieActive && player != null) { - GlassesMovieScreen(player = player, title = title) - } else { - ConnectionScreen() - } - // Оверлей режима общения: поверх любого экрана (затемняет кино), - // виден в «слушаю»/«думаю»/«ответ виден». - AssistantOverlay() - } + // Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит + // все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции — + // стек менеджера (зеркало navStack, обновляется через ctx.open/close). + LayoutStackContent(manager = GlassesApp.instance.layoutManager) } } hideSystemBars() @@ -172,16 +155,16 @@ class MainActivity : BaseEventActivity() { val state = dm.getDisplay(displayId).state logEvent("display state=$state") if (state == Display.STATE_OFF) { - val was = GlassesApp.instance.movieController.pauseIfPlaying() + val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying() resumeAfterScreenOn = resumeAfterScreenOn || was logEvent("display OFF: pause wasPlaying=$was") - GlassesApp.instance.movieController.ensureWifiLock() + GlassesApp.instance.rootLayer.controller.ensureWifiLock() GlassesApp.instance.hostConnection.sendPlaybackPositionNow() } else if (state == Display.STATE_ON) { logEvent("display ON: resume flag=$resumeAfterScreenOn") if (resumeAfterScreenOn) { resumeAfterScreenOn = false - GlassesApp.instance.movieController.resume() + GlassesApp.instance.rootLayer.controller.resume() sendPositionWithRetries() } } @@ -212,171 +195,48 @@ class MainActivity : BaseEventActivity() { /** * Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast - * (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку - * состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже. + * (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный + * [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом + * делать (MoveLayout / AssistantLayout / ListeningLayout). */ - private fun onTempleAction(action: TempleAction) { - val app = GlassesApp.instance - val state = AssistantState( - listening = app.listening.value, - thinking = app.thinking.value, - sttText = app.sttText.value, - assistantText = app.assistantText.value, - ) - when (val decision = gestureDecision(action, state)) { - GestureDecision.Ignore -> - log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})") - GestureDecision.CallAssistant -> startAssistantMode() - GestureDecision.TogglePlayback -> togglePlayback() - GestureDecision.SendPhrase -> sendPhrase() - GestureDecision.NewPhrase -> startNewPhrase() - GestureDecision.CancelDictation -> cancelDictation() - GestureDecision.ExitMode -> exitAssistantMode() - is GestureDecision.Seek -> seekBy(decision) - is GestureDecision.ScrollAnswer -> { - val acc = app.answerScrollDelta.value + decision.delta - log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px") - app.answerScrollDelta.value = acc - } - } - } + private fun onTempleAction(action: TempleAction) { + val g = action.toGesture() ?: run { + log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор") + return + } + log("gesture", "жест: ${g}") + GlassesApp.instance.layoutManager.dispatch(g) + } - /** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */ - private fun startAssistantMode() { - val app = GlassesApp.instance - if (app.listening.value) { - log("gesture", "TripleClick проигнорирован: распознавание уже идёт") - return - } - log("stt", "ассистент: слушаю") - if (app.movieController.pauseIfPlaying()) { - log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода") - } - GlassesApp.instance.hostConnection.sendPlaybackPositionNow() - app.listening.value = true - app.thinking.value = false - app.sttText.value = "" - app.assistantText.value = "" - app.answerScrollDelta.value = 0f - app.sttSession.start() - } - - /** Click (кино): плей/пауза — как сегодня. */ - private fun togglePlayback() { - log("gesture", "жест: Click → ${MediaCommand.TOGGLE}") - GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) { - GlassesApp.instance.hostConnection.sendPlaybackPositionNow() - } - } - - /** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */ - private fun sendPhrase() { - log("gesture", "Click → отправить фразу (распознавание локально на очках)") - val app = GlassesApp.instance - app.listening.value = false - app.thinking.value = true - app.sttSession.send() - } - - /** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */ - private fun startNewPhrase() { - log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)") - val app = GlassesApp.instance - app.assistantText.value = "" - app.sttText.value = "" - app.thinking.value = false - app.answerScrollDelta.value = 0f - app.listening.value = true - app.sttSession.start() - } + // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]: + // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout. /** - * Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без - * голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается, - * накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2). - */ - fun resumeSttListening() { - val app = GlassesApp.instance - app.thinking.value = false - app.listening.value = true - app.sttSession.start() - log("stt", "возврат в «слушаю» (SttCancel)") - } + * SttCancel(reason="user") с телефона — пустой клик без голоса (старый путь StopStt). + * Стоп-записи с ОСТАВАНИЕМ в ассистенте (idle, «не расслышал»). Реализация — в движке + * общения ([GlassesApp.assistantEngine]). + */ + fun stopSttListening() = GlassesApp.instance.assistantEngine.requestSttStop() - /** - * DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся - * в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на - * экране (даже если истории диалога ещё нет — отмена не закрывает чат). - * Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]). - */ - private fun cancelDictation() { - log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)") - val app = GlassesApp.instance - app.sttSession.cancel() - app.sttText.value = "" - app.thinking.value = false - app.listening.value = true - app.sttSession.start() - } + /** + * Результат локального распознавания ([GlassesApp] → [onSttResult], на главном потоке). + * Не-пустой — POP «слушаю», ждём ответ, шлём [SttText] на телефон; пустой по клику — стоп с + * оставанием в ассистенте; пустой после 30с-лимыта — выход из режима. Состояние/навигация — + * в движке ([GlassesApp.assistantEngine]); отправка [SttText] — здесь (нужен [lifecycleScope]). + */ + fun onSttResult(text: String, auto: Boolean) { + val toSend = GlassesApp.instance.assistantEngine.processSttResult(text, auto) + if (toSend != null) { + val app = GlassesApp.instance + lifecycleScope.launch { app.hostConnection.sendToHost(SttText(toSend)) } + } + } - /** - * Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке). - * [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] — - * сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся - * слушать, пустой после лимита — выход из режима. - */ - fun onSttResult(text: String, auto: Boolean) { - val app = GlassesApp.instance - app.sttText.value = text - when { - text.isNotBlank() -> { - app.listening.value = false - app.thinking.value = true - log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон") - lifecycleScope.launch { - app.hostConnection.sendToHost(SttText(text)) - } - } - auto -> finishAssistantMode("тишина 30с (очки) — выход из режима") - else -> { - log("stt", "клик без голоса — остаюсь в «слушаю»") - app.thinking.value = false - app.listening.value = true - app.sttSession.start() - } - } - } - - /** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */ - private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)") - - /** - * Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл — - * выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection. - */ - fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима") - - /** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */ - private fun finishAssistantMode(reason: String) { - log("gesture", reason) - GlassesApp.instance.sttSession.stop() - val app = GlassesApp.instance - app.listening.value = false - app.thinking.value = false - app.sttText.value = "" - app.assistantText.value = "" - app.answerScrollDelta.value = 0f - } - - /** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */ - private fun seekBy(seek: GestureDecision.Seek) { - log("gesture", "жест: свайп → ${seek.command}") - GlassesApp.instance.movieController.applyMediaCommand( - MediaCommandMsg(seek.command, seek.seekMs) - ) { - GlassesApp.instance.hostConnection.sendPlaybackPositionNow() - } - } + /** + * Таймаут тишины 30с с телефона (SttCancel reason="timeout"): выход из режима общения. + * Реализация — в движке общения ([GlassesApp.assistantEngine]). + */ + fun onSttTimeout() = GlassesApp.instance.assistantEngine.handleSttTimeout() /** * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: @@ -405,15 +265,15 @@ class MainActivity : BaseEventActivity() { log("ui", "onPause") val app = GlassesApp.instance // Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл - // в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым. - if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) { + // в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма. + if (app.navStack.value.size > 1) { log("ui", "onPause: режим общения активен — выход (микрофон закрыт)") - finishAssistantMode("onPause — потеря фокуса, выход из режима общения") + app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения") } - val was = app.movieController.pauseIfPlaying() + val was = app.rootLayer.controller.pauseIfPlaying() resumeAfterScreenOn = resumeAfterScreenOn || was logEvent("onPause: pause wasPlaying=$was") - app.movieController.ensureWifiLock() + app.rootLayer.controller.ensureWifiLock() app.hostConnection.sendPlaybackPositionNow() if (was) log("ui", "экран погас — пауза") } @@ -425,7 +285,7 @@ class MainActivity : BaseEventActivity() { logEvent("onResume: resume flag=$resumeAfterScreenOn") if (resumeAfterScreenOn) { resumeAfterScreenOn = false - GlassesApp.instance.movieController.resume() + GlassesApp.instance.rootLayer.controller.resume() sendPositionWithRetries() log("ui", "экран включён — продолжаю") } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt index 184baa1..c835d6d 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt @@ -8,34 +8,60 @@ import pw.binom.viewmate.glasses.stt.SttModelStatus * Режимы общения с ассистентом (матрица TASK-gestures-modes.md): * «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза». * - * @NONE — кино: ассистент не вызван. - * @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true). - * @THINKING — думает: фраза отправлена, ждём ответ (ShowText). - * @ANSWER — ответ виден: listening=false и assistantText непуст. + * Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть), + * сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне). + * Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра + * ассистента: + * + * @NONE — кино (вершина Film): ассистент не вызван. + * @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне. + * @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText). + * @ANSWER — вершина Assistant, ответ виден (assistantText непуст). + * @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте. */ -enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, } +enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, } + +/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */ +enum class NavFrame { Film, Assistant, Listening } /** Снимок состояния общения для карты жеста (чистые данные, без Android). */ data class AssistantState( - /** Микрофон открыт (TripleClick → слушаю). */ - val listening: Boolean = false, - /** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */ + /** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */ + val stack: List = listOf(NavFrame.Film), + /** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */ val thinking: Boolean = false, - /** Накопленные фразы (SttPhrase, full). */ + /** Накопленные фразы (SttPhrase, full). Контент кадра «слушаю». */ val sttText: String = "", - /** Ответ ассистента (ShowText). */ + /** Ответ ассистента (ShowText). Контент кадра ассистента. */ val assistantText: String = "", -) + /** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */ + val sttStartBlocked: Boolean = false, + /** Последний стоп/отправка не поймала голоса — показать «не расслышал». */ + val sttNoSpeech: Boolean = false, +) { + /** Вершина стека — текущий кадр. */ + val top: NavFrame get() = stack.last() + /** Микрофон открыт: вершина — [NavFrame.Listening]. */ + val listening: Boolean get() = top == NavFrame.Listening + /** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */ + val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening +} /** * Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст), - * он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт — + * он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт — * жесты слушают матрицу «слушаю», даже если thinking при этом стоит). + * + * Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю» + * (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван. */ fun assistantMode(state: AssistantState): AssistantMode = when { - state.listening -> AssistantMode.LISTENING - state.assistantText.isNotBlank() -> AssistantMode.ANSWER - state.thinking -> AssistantMode.THINKING + state.top == NavFrame.Listening -> AssistantMode.LISTENING + state.top == NavFrame.Assistant -> when { + state.assistantText.isNotBlank() -> AssistantMode.ANSWER + state.thinking -> AssistantMode.THINKING + else -> AssistantMode.IDLE + } else -> AssistantMode.NONE } @@ -45,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when { * * | Режим | Клик | Двойной | Тройной | Свайпы | * |------------------|--------------------|-----------|---------|----------------------| - * | Кино | плей/пауза | игнор | вызов | ±30с | - * | Слушаю | отправить фразу | отмена | игнор | игнор | + * | Кино (корень) | плей/пауза | игнор | вызов | ±30с | + * | Слушаю | отправить фразу | стоп → idle | игнор | игнор | + * | Ассистент (idle) | открыть запись | выход | запись | игнор | * | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл | */ sealed interface GestureDecision { @@ -66,14 +93,17 @@ sealed interface GestureDecision { data object NewPhrase : GestureDecision /** - * Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю» - * ([afterCancelDictation]): микрофон снова готов к следующей записи, - * оверлей остаётся на экране даже при пустой истории диалога. - * Сторона очков: StopStt(cancel=true) на телефон. + * Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте + * (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame]. + * Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков: + * StopStt(cancel=true). */ - data object CancelDictation : GestureDecision + data object StopListening : GestureDecision - /** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */ + /** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */ + data object BeginListening : GestureDecision + + /** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */ data object ExitMode : GestureDecision /** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */ @@ -87,15 +117,11 @@ sealed interface GestureDecision { * Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android. * * @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]). - * @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous] - * даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону — - * меняем знак здесь, в одной точке). */ fun gestureDecision( action: TempleAction, state: AssistantState, seekStepMs: Long = 30_000, - pageDeltaPx: Float = 200f, ): GestureDecision = when (val mode = assistantMode(state)) { // Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице. AssistantMode.NONE -> when (action) { @@ -106,9 +132,30 @@ fun gestureDecision( ?: GestureDecision.Ignore } - AssistantMode.LISTENING -> when (action) { - is TempleAction.Click -> GestureDecision.SendPhrase - is TempleAction.DoubleClick -> GestureDecision.CancelDictation + // «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт, + // кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить, + // DoubleClick — стоп (POP «слушаю» → idle). + AssistantMode.LISTENING -> when { + state.sttStartBlocked -> when (action) { + is TempleAction.Click -> GestureDecision.BeginListening + is TempleAction.DoubleClick -> GestureDecision.ExitMode + else -> GestureDecision.Ignore + } + + else -> when (action) { + is TempleAction.Click -> GestureDecision.SendPhrase + // Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван. + is TempleAction.DoubleClick -> GestureDecision.StopListening + else -> GestureDecision.Ignore + } + } + + // Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт. + AssistantMode.IDLE -> when (action) { + is TempleAction.Click -> GestureDecision.BeginListening + is TempleAction.TripleClick -> GestureDecision.BeginListening + // Выход из чата: POP «ассистент» → корень (кино). + is TempleAction.DoubleClick -> GestureDecision.ExitMode else -> GestureDecision.Ignore } @@ -119,44 +166,68 @@ fun gestureDecision( } AssistantMode.ANSWER -> when (action) { + // Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы + // игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст. is TempleAction.Click -> GestureDecision.NewPhrase is TempleAction.DoubleClick -> GestureDecision.ExitMode - is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx) - is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx) - is TempleAction.SlideContinuous -> - // Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант — - // два строка выше. - GestureDecision.ScrollAnswer(action.delta) else -> GestureDecision.Ignore } } /** - * Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»): - * возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый - * текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся - * на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true, - * т.к. listening=true). Закрытие ассистента — отдельное явное действие: - * двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]). - * Чистая функция — unit-тесты без Android. + * Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю» + * (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция. */ -fun afterCancelDictation(state: AssistantState): AssistantState = - state.copy(listening = true, thinking = false, sttText = "") +fun AssistantState.enterAssistant(): AssistantState = + copy( + stack = stack + NavFrame.Assistant + NavFrame.Listening, + thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false, + ) -/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */ +/** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */ +fun AssistantState.beginListeningFrame(): AssistantState = + copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false) + +/** + * Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра + * «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] — + * показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]); + * выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android. + */ +fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState = + copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech) + +/** + * Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины): + * POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция. + */ +fun AssistantState.exitToFilm(): AssistantState = + copy( + stack = listOf(NavFrame.Film), + thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false, + ) + +/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */ fun overlayVisible(state: AssistantState): Boolean = - state.listening || state.thinking || state.assistantText.isNotBlank() + state.inAssistant || state.sttStartBlocked /** * Заголовок оверлея (над ответом). null — только ответ. + * - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY), + * микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]); * - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если * listening ещё true — микрофон уже закрыт); * - «слушаю...»/фраза — микрофон открыт; - * - при «ответе виден» — последняя фраза как контекст (или null). + * - «не расслышал» — стоп/отправка не поймала голоса (пустой клик); + * - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов + * (клик — говорить, двойной — выход); чат прокручивается под заголовком. */ fun overlayHeader(state: AssistantState): String? = when { + state.sttStartBlocked -> "модель не скачалась" state.thinking && state.assistantText.isBlank() -> "думаю..." - state.listening -> state.sttText.ifBlank { "слушаю..." } + state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." } + state.sttNoSpeech -> "не расслышал — клик, чтобы повторить" + state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход" else -> state.sttText.takeIf { it.isNotBlank() } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt index 0c590d6..93e4f2c 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt @@ -41,7 +41,7 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) { // Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive // + свежее значение от телефона); без видео — ничего не выводить. val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState() - val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState() + val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState() Row( modifier = modifier .background(Color(0x99000000)) // полупрозрачный чёрный diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt index 0d72d6c..47ee0e3 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt @@ -37,5 +37,7 @@ class GlassesAsr( /** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */ fun recognize(samples: FloatArray, sampleRate: Int = 16000): String = - recognizer.recognize(samples, sampleRate) + runCatching { recognizer.recognize(samples, sampleRate) } + .onFailure { log("stt", "Qwen3 recognizer упал: ${it.javaClass.simpleName}: ${it.message}") } + .getOrDefault("") } \ No newline at end of file diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesRecognizer.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesRecognizer.kt new file mode 100644 index 0000000..cb2f07c --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesRecognizer.kt @@ -0,0 +1,31 @@ +package pw.binom.viewmate.glasses.stt + +/** + * Абстракция механизма распознавания очков: готов ли механизм + распознать фразу. + * + * [GlassesSttSession] работает с этим интерфейсом, а не с конкретным движком — так + * дальше за ним можно подключить другой механизм (например, удалённое распознавание + * на телефоне) без изменения сессии. + */ +interface GlassesRecognizer { + /** Механизм готов (модель докачана / нативка загрузится). false — не открывать микрофон. */ + fun isReady(): Boolean + + /** Распознать фразу (FloatArray, 16 кГц mono). Пустая строка — тишина / не готово. */ + fun recognize(phrase: FloatArray): String +} + +/** + * Единственная реализация: локальный механизм распознавания очков — Qwen3-ASR + * ([GlassesAsr]); готовность берёт из статуса скачанной модели ([SttModelDownloader]). + * Телефон в распознавании не участвует. + */ +class LocalGlassesRecognizer( + private val asr: GlassesAsr, + private val model: SttModelDownloader, +) : GlassesRecognizer { + + override fun isReady(): Boolean = model.status.value.ready + + override fun recognize(phrase: FloatArray): String = asr.recognize(phrase) +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt index 510c3ed..4c310c3 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt @@ -3,6 +3,7 @@ package pw.binom.viewmate.glasses.stt import java.io.File import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +import kotlin.math.sqrt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -22,7 +23,7 @@ interface GlassesMic { /** * Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём - * локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше + * локально (через [recognizer]) и возвращаем готовый текст — телефон больше * не участвует в распознавании. UX остаётся «говори → клик → текст». * * Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон, @@ -37,8 +38,7 @@ interface GlassesMic { class GlassesSttSession( private val filesDir: File, private val scope: CoroutineScope, - private val recognizer: (FloatArray) -> String, - private val modelReady: () -> Boolean, + private val recognizer: GlassesRecognizer, private val onResult: (text: String, auto: Boolean) -> Unit, private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) }, ) { @@ -59,9 +59,18 @@ class GlassesSttSession( val isRunning: Boolean get() = running - /** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */ + /** + * Начать слушать: чистый буфер + микрофон + таймер автозавершения. + * Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false), + * микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание + * гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked). + */ fun start() { if (running) return + if (!recognizer.isReady()) { + log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю") + return + } running = true pcmLock.withLock { pcm = ByteArray(0) } val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } } @@ -101,10 +110,33 @@ class GlassesSttSession( watchdog?.cancel() val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } } scope.launch(Dispatchers.Default) { - val text = if (audio.isNotEmpty() && modelReady()) { - val phrases = splitPhrases(trimEdges(s16leToFloat(audio))) - phrases.joinToString(" ") { recognizer(it) } + val text = if (audio.isNotEmpty() && recognizer.isReady()) { + val floats = s16leToFloat(audio) + var peak = 0f + for (f in floats) { val a = if (f < 0f) -f else f; if (a > peak) peak = a } + val win = 320 + var w005 = 0; var w001 = 0 + var i = 0 + while (i + win <= floats.size) { + var sum = 0.0 + for (k in i until i + win) sum += floats[k] * floats[k].toDouble() + val rms = sqrt(sum / win).toFloat() + if (rms >= 0.005f) w005++ + if (rms >= 0.01f) w001++ + i += win + } + val trimmed = trimEdges(floats) + val phrases = splitPhrases(trimmed) + log("stt", "diag pcm=${audio.size / 32}мс peak=${peak} окно[≥.005]=${w005} [≥.01]=${w001} trim=${trimmed.size / 32000}с фраз=${phrases.size}") + val joined = phrases.mapIndexed { n, p -> + val r = recognizer.recognize(p) + log("stt", "diag фраза${n + 1} ${p.size * 1000 / 16000}мс → '${r.take(40)}'") + r + }.joinToString(" ") + if (joined.isEmpty()) log("stt", "diag: результат пустой") + joined } else { + log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено") "" } withContext(Dispatchers.Main) { onResult(text, auto) } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt index 8ffcdaf..290900c 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt @@ -186,7 +186,7 @@ class SttModelDownloader( val parent = File(modelsDir, f.relPath).parentFile parent?.mkdirs() val final = File(modelsDir, f.relPath) - val part = File(modelsDir, "$f.relPath.part") + val part = File(modelsDir, "${f.relPath}.part") val existing = if (part.exists()) part.length() else 0L val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index ac20c1b..fa07f14 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -35,6 +35,7 @@ import pw.binom.viewmate.glasses.gestures.overlayHeader import pw.binom.viewmate.glasses.gestures.modelStatusLine import pw.binom.viewmate.glasses.gestures.overlayVisible import pw.binom.viewmate.glasses.gestures.scrollOffset +import pw.binom.viewmate.glasses.ui.layers.AssistantEngine /** * Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт @@ -47,19 +48,30 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset * [GlassesApp.answerScrollDelta]). */ @Composable -fun AssistantOverlay() { +fun AssistantOverlay(engine: AssistantEngine) { val app = GlassesApp.instance - val listening by app.listening.collectAsState() - 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) + // Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine]. + val navStack by app.navStack.collectAsState() + val thinking by engine.thinking.collectAsState() + val sttText by engine.sttText.collectAsState() + val assistantText by engine.assistantText.collectAsState() + val sttStartBlocked by engine.sttStartBlocked.collectAsState() + val sttNoSpeech by engine.sttNoSpeech.collectAsState() + val chatHistory by engine.chatHistory.collectAsState() + val state = AssistantState( + stack = navStack, + thinking = thinking, + sttText = sttText, + assistantText = assistantText, + sttStartBlocked = sttStartBlocked, + sttNoSpeech = sttNoSpeech, + ) if (!overlayVisible(state)) return val modelStatus by app.sttModel.status.collectAsState() - // Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание - // локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему. - val modelLine = if (state.listening) modelStatusLine(modelStatus) else null + // Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ старт отклонён + // (модель не скачалась, стtStartBlocked): распознавание локально, поэтому модель не + // READY = распознавание не готово — объясняем что происходит (качается / не скачалась). + val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать // в единственном экземпляре — иначе глаза скроллят по-разному. @@ -74,12 +86,12 @@ fun AssistantOverlay() { } // Накопленная дельта скролла от жестов. Коллектор один на оба глаза; - // аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. + // аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset]. LaunchedEffect(Unit) { - app.answerScrollDelta.collect { pending -> + engine.answerScrollDelta.collect { pending -> if (pending == 0f) return@collect val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) - app.answerScrollDelta.value = 0f + engine.answerScrollDelta.value = 0f if (target != scrollState.value) { scrollState.animateScrollTo(target, tween(150)) } @@ -114,7 +126,9 @@ private fun overlayBox( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black), + // Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата — + // «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает. + .background(Color.Black.copy(alpha = 0.88f)), ) { Box( modifier = Modifier @@ -127,7 +141,7 @@ private fun overlayBox( ) { AssistantOverlayContent(state, chatHistory, scrollState, modelLine) - if (state.listening) { + if (state.listening && !state.sttStartBlocked) { Box( modifier = Modifier .align(Alignment.TopEnd) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/LayoutStack.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/LayoutStack.kt new file mode 100644 index 0000000..3997256 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/LayoutStack.kt @@ -0,0 +1,42 @@ +package pw.binom.viewmate.glasses.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import pw.binom.viewmate.glasses.GlassesApp +import pw.binom.viewmate.glasses.LayoutManager +import pw.binom.viewmate.glasses.ui.layers.MoveLayout + +/** + * Ленточный (LIFO) layout-стек очков: композитинг снизу вверх — [LayoutManager.compositing] + * рендерит ВСЕ слои (кино → затемнение → чат → слушаю), панель статуса у верхнего. + * Стек правды — персистентный [GlassesApp.layoutManager]; [navStack] — лишь его зеркало + * (для триггера рекомпозиции). + */ +@Composable +fun LayoutStackContent(manager: LayoutManager) { + // Триггер рекомпозиции: стек менеджера меняется через ctx.open/close, а LayoutManager.onChange + // зеркалирует его в [GlassesApp.navStack] (StateFlow). compositing() читает сам стек менеджера + // (deque); изменение navStack запускает рекомпозицию. + val nav by GlassesApp.instance.navStack.collectAsState() + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + manager.compositing().forEach { layer -> + layer.layout.drawStage() + // Затемняем фильм, только когда поверх него что-то есть (чтение nav — и триггер). + if (layer.layout is MoveLayout && nav.size > 1) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + ) + } + } + manager.current.drawStatusPanel() + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt new file mode 100644 index 0000000..aa1be0e --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt @@ -0,0 +1,263 @@ +package pw.binom.viewmate.glasses.ui.layers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import pw.binom.viewmate.glasses.ChatEntry +import pw.binom.viewmate.glasses.GlassesApp +import pw.binom.viewmate.glasses.Layout +import pw.binom.viewmate.glasses.LayoutContext +import pw.binom.viewmate.glasses.log +import pw.binom.viewmate.glasses.gestures.AssistantMode +import pw.binom.viewmate.glasses.gestures.NavFrame + +/** + * Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные + * flows в [GlassesApp]. Слои [AssistantLayout]/[ListeningLayout] получают движок и *сами* + * владеют таблицами жестов, вызывая эффекты здесь; чат-оверлей читает стейт тоже отсюда. + * + * Зависимости (STT-сессия, модель, протокол, медиа-контроллер, менеджер стека) берёт лениво из + * [GlassesApp] в методах — сам движок строится без побочных эффектов (его стейт — лишь flows), + * поэтому порядок инициализации полей приложения не критичен. + */ +class AssistantEngine(private val app: GlassesApp) { + + // ── Стейт ассистента (были размазанными полями в GlassesApp) ────────────────────── + + /** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */ + val thinking = MutableStateFlow(false) + + /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ + val sttText = MutableStateFlow("") + + /** Текст ответа ассистента (ShowText); остаётся до новой сессии. */ + val assistantText = MutableStateFlow("") + + /** Попытка начать диктовку отклонена: модель Qwen3 не скачалась. */ + val sttStartBlocked = MutableStateFlow(false) + + /** Последний стоп/отправка не поймал голоса — оверлей показывает «не расслышал». */ + val sttNoSpeech = MutableStateFlow(false) + + /** Идёт распознавание (TripleClick открыл, завершает Click / 30s-лимит). */ + val listening = MutableStateFlow(false) + + /** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */ + val chatHistory = MutableStateFlow>(emptyList()) + + /** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */ + val answerScrollDelta = MutableStateFlow(0f) + + /** + * Кэлбэки ВЫЗЫВШИГО (слоя ассистента): [listeningSuccess] — куда слой записи возвращает + * распознанный текст, [listeningCancel] — факт отмены ввода. Привязываются + * [ListeningLayout.onEnter] через [bindListening], отцепляются в [ListeningLayout.onExit]; + * null — записи нет / POP-нута. + */ + private var listeningSuccess: ((String) -> Unit)? = null + private var listeningCancel: (() -> Unit)? = null + + /** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */ + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // ── Под-состояние кадра ассистента (top == Assistant) ───────────────────────────── + + /** idle / thinking / answer — по стейту (top кадра ассистента предполагается активным). */ + fun mode(): AssistantMode = when { + assistantText.value.isNotBlank() -> AssistantMode.ANSWER + thinking.value -> AssistantMode.THINKING + else -> AssistantMode.IDLE + } + + // ── Эффекты (перенесены из GlassesGestureEffects) ──────────────────────────────── + + /** + * TripleClick в кино: пауза фильма + открыть кадр «ассистент» ([AssistantLayout]) и + * сразу «слушание» ([ListeningLayout]). Фильм остаётся на паузе, аудиосинхр улетает на + * телефон (продолжит догнанку, не перемотает). + */ + fun enterAssistant() { + if (app.layoutManager.depth > 1) { + log("gesture", "TripleClick проигнорирован: уже в ассистенте") + return + } + log("stt", "ассистент: слушаю (стек: фильм → ассистент → слушаю)") + if (app.rootLayer.controller.pauseIfPlaying()) { + log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода") + } + app.hostConnection.sendPlaybackPositionNow() + sttNoSpeech.value = false + assistantText.value = "" + answerScrollDelta.value = 0f + // Кадр ассистента сам открывает слой записи (в своём onEnter), передавая кэлбэки onSuccess/onCancel. + app.layoutManager.open { ctx -> AssistantLayout(ctx, this) } + } + + /** + * Слой записи открыт микрофон ([ListeningLayout.onEnter]): открыть микрофон только если модель + * готова. Модель Qwen3 ещё не скачалась — микрофон не открываем: показываем «модель не + * скачалась» ([sttStartBlocked]), распознавание не готово. Здесь стек НЕ трогаем — слой уже + * открыт (его открыл [enterAssistant] / кадр ассистента). + */ + fun beginMic() { + sttNoSpeech.value = false + sttText.value = "" + if (!app.sttModel.status.value.ready) { + listening.value = false + thinking.value = true + sttStartBlocked.value = true + log("stt", "старт диктовки отклонён: модель Qwen3 не скачалась — микрофон не открываю, показываю ошибку") + return + } + sttStartBlocked.value = false + thinking.value = false + listening.value = true + app.sttSession.start() + } + + /** Слой записи закрыт микрофон ([ListeningLayout.onExit]): закрыть микрофон, гасить «слушаю». */ + fun endMic() { + app.sttSession.stop() + listening.value = false + sttText.value = "" + } + + /** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */ + fun clearAnswer() { + assistantText.value = "" + answerScrollDelta.value = 0f + } + + /** + * Слой записи открыт ([ListeningLayout.onEnter]): привязать кэлбэки вызвавшего, чтобы сюда + * доставлять распознанный текст и факт отмены ввода. + */ + fun bindListening(onSuccess: (String) -> Unit, onCancel: () -> Unit) { + listeningSuccess = onSuccess + listeningCancel = onCancel + } + + /** Слой записи закрыт ([ListeningLayout.onExit]): отцепить кэлбэки (дальше результатов нет). */ + fun unbindListening() { + listeningSuccess = null + listeningCancel = null + } + + /** + * Доставить распознанный текст вызвавшему ассистенту ([ListeningLayout.onSuccess]). Если слой + * записи уже POP-нут (кэлбэк отцеплен) — эффект выполняю сам, без ассистента. + */ + private fun deliverSuccess(text: String) { + val cb = listeningSuccess + if (cb != null) cb.invoke(text) else continueTurn(text) + } + + /** Доставить факт отмены вызвавшему ассистенту. Если слой записи уже POP-нут — [stopToIdle] выполняю сам. */ + private fun deliverCancel() { + val cb = listeningCancel + if (cb != null) cb.invoke() else stopToIdle("отмена ввода → стоп, остаюсь в ассистенте", noSpeech = true) + } + + /** Ассистент получил фразу: жду ответ ([thinking]), POP записи (её onExit закроет микрофон). */ + fun continueTurn(text: String) { + log("stt", "ассистент: жду ответ на фразу '${text.take(40)}'") + if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic + thinking.value = true + } + + /** Клик в «слушаю»: распознать скопленный микрофон локально Qwen3-ом и отправить на телефон. */ + fun sendPhrase() { + log("gesture", "Click → отправить фразу (распознавание локально на очках)") + thinking.value = true + app.sttSession.send() + } + + /** + * Стоп-записи с ОСТАВАНИЕМ в кадре ассистента: POP [ListeningLayout] (idle) — его onExit + * закрывает микрофон ([endMic]). Микрофон закрывается ПОД-слоем, а не здесь. + */ + fun stopToIdle(reason: String, noSpeech: Boolean) { + log("stt", reason) + if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic + thinking.value = false + sttNoSpeech.value = noSpeech + } + + /** Выход из режима в корень: closeToRoot (POP [ListeningLayout] → onExit → [endMic]), чистим стейт. */ + fun exitMode(reason: String) { + log("stt", reason) + app.layoutManager.closeToRoot() // POP Listening → onExit → endMic (стоп микрофона) + thinking.value = false + sttStartBlocked.value = false + sttNoSpeech.value = false + sttText.value = "" + assistantText.value = "" + answerScrollDelta.value = 0f + } + + /** + * Результат локального распознавания (на очках): не пусто → отправить на телефон и ждать + * ответ; пустой авто-результат (тишина 30с) → выход из режима; пустой клик без голоса → + * стоп с остаованием в ассистенте. Возвращает текст для SttText (null — не отправлять). + */ + fun processSttResult(text: String, auto: Boolean): String? { + sttText.value = text + return when { + text.isNotBlank() -> { + log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → ассистенту") + deliverSuccess(text) // → ассистент: continueTurn (жду ответ) + text + } + auto -> { + exitMode("тишина 30с (очки) — выход из режима") + null + } + else -> { + deliverCancel() // → ассистент: stopToIdle (не расслышал) + null + } + } + } + + /** Host-driven: явная отмена диктовки (user) — стоп, остаёмся в ассистенте (не расслышал). */ + fun requestSttStop() { + log("stt", "SttCancel(user) → стоп, остаюсь в ассистенте (не расслышал)") + deliverCancel() // → ассистент: stopToIdle + } + + /** Host-driven: авто-отмена по тишине 30s — выход из режима. */ + fun handleSttTimeout() = exitMode("СттCancel(timeout) — тишина 30с, выход из режима") + + // ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ────────── + + /** ShowText: ответ ассистента виден, «думаю» сброшено. */ + fun onShowText(text: String) { + thinking.value = false + answerScrollDelta.value = 0f + assistantText.value = text + } + + /** ChatHistory: история сессии с телефона (фильтрация wire-типов — в HostConnection). */ + fun onChatHistory(messages: List) { + chatHistory.value = messages + } + + /** SttPhrase: накопленный текст распознавания (для оверлея). */ + fun onSttPhrase(full: String) { + sttText.value = full + } + + /** SttDone: распознавание завершено, индикатор записи гаснет. */ + fun onStopListening() { + listening.value = false + } +} + +/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */ +fun Layout.toFrame(): NavFrame = when (this) { + is MoveLayout -> NavFrame.Film + is AssistantLayout -> NavFrame.Assistant + is ListeningLayout -> NavFrame.Listening + else -> NavFrame.Film +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt new file mode 100644 index 0000000..cedf9db --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt @@ -0,0 +1,90 @@ +package pw.binom.viewmate.glasses.ui.layers + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import pw.binom.viewmate.glasses.Gesture +import pw.binom.viewmate.glasses.Layout +import pw.binom.viewmate.glasses.LayoutContext +import pw.binom.viewmate.glasses.gestures.AssistantMode +import pw.binom.viewmate.glasses.ui.AssistantOverlay +import kotlin.coroutines.resume + +/** + * Кадр ассистента (чат). Полупрозрачный [AssistantOverlay] — затемнённый фильм просвечивает + * на фоне (фильм остаётся ниже в композите). В под-состоянии «слушаю» оверлей сам показывает + * индикатор записи и текущую фразу — отдельного экрана «слушаю» нет, чат остаётся на фоне. + * + * В кадре остаётся только *его* логика: скролы (свайпы), «думает ассистент или нет» и реакция на + * результат записи. Записью микрофона кадр НЕ занимается: клик *вызывает* слой записи + * ([ListeningLayout]) suspend-функцией [listen] и явно ждёт её окончания (текст / отмена); + * сам микрофон запись открывает/закрывает в своём onEnter/onExit. + */ +class AssistantLayout( + private val ctx: LayoutContext, + private val engine: AssistantEngine, +) : Layout { + + @Composable + override fun drawStage() { + AssistantOverlay(engine) + } + + @Composable + override fun drawStatusPanel() = Unit + + /** + * Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк); + * логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ). + */ + override fun gesture(g: Gesture) { + when (g) { + Gesture.DoubleClick -> engine.exitMode("DoubleClick → выход из чата (кино остаётся на паузе)") + Gesture.Click -> when (engine.mode()) { + AssistantMode.IDLE -> openListening() + AssistantMode.THINKING, AssistantMode.ANSWER -> { + engine.clearAnswer() + openListening() + } + + else -> Unit // NONE/LISTENING при top==Assistant невозможны (тогда топ был бы другим) + } + + Gesture.TripleClick -> if (engine.mode() == AssistantMode.IDLE) openListening() + Gesture.SwipeForward, Gesture.SwipeBackward -> Unit // логика скролов: в диалоге не скроллим + } + } + + /** + * Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания. + * Возвращает распознанный текст ([onSuccess]) или null ([onCancel], отмена) — слой записи + * завершает ожидание этими кэлбэками (текст доставляет движок, отмену слой вызовал сам). + */ + private suspend fun listen() = suspendCancellableCoroutine { context -> + ctx.open { c -> + context.invokeOnCancellation { + c.close() + } + ListeningLayout(c, engine, onSuccess = { text -> + context.resume(text) + }, onCancel = { + context.resume(null) + }) + } + } + + /** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */ + private fun openListening() { + engine.scope.launch { + val text = listen() + if (text != null) { + engine.continueTurn(text) // распознали фразу → жду ответ (thinking, POP записи) + } else { + engine.stopToIdle( + "отмена ввода → остаюсь в ассистенте", + noSpeech = true + ) // отмена → idle + } + } + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt new file mode 100644 index 0000000..e1e6cbd --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt @@ -0,0 +1,71 @@ +package pw.binom.viewmate.glasses.ui.layers + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import pw.binom.viewmate.glasses.Gesture +import pw.binom.viewmate.glasses.Layout +import pw.binom.viewmate.glasses.LayoutContext + +/** + * Под-слой «слушаю» — слой ЗАПИСИ: он *занимается* микрофоном. Пустой, прозрачный поверх кадра + * ассистента (визуал индикатора/фразы рисует [AssistantLayout] под ним, чат остаётся на фоне). + * + * Микрофон открывается/закрывается САМИМ этим слоем: [onEnter] → [AssistantEngine.beginMic] + * (открыть только если модель готова), [onExit] → [AssistantEngine.endMic] (закрыть). + * + * Слой не «знает», что делать с результатом — он возвращает его ВЫЗЫВШИМУ (слою ассистента) двумя + * кэлбэками, которые тот передал при вызове: [onSuccess] (сразу текст распознанной фразы) и + * [onCancel] (просто отмена ввода). Распознанный текст движок доставляет сюда через + * [AssistantEngine.bindListening] (привязан в [onEnter], отцепляется в [onExit]); кэки-жесты слой + * вызывает [onCancel] сам. Если модель не скачалась (sttStartBlocked) — клик пере-начинает попытку, + * двойной клик выходит из режима. + */ +class ListeningLayout( + private val ctx: LayoutContext, + private val engine: AssistantEngine, + private val onSuccess: (String) -> Unit, + private val onCancel: () -> Unit, +) : Layout { + + @Composable + override fun drawStage() { + Box(modifier = Modifier.fillMaxSize()) + } + + @Composable + override fun drawStatusPanel() = Unit + + /** Слой записи открыт: привязать кэлбэки вызвавшего к движку + открыть микрофон (если модель готова). */ + override fun onEnter() { + engine.bindListening(onSuccess, onCancel) + engine.beginMic() + } + + /** + * Слой записи закрыт: отцепить кэлбэки + закрыть микрофон. [onCancel] — страховочный вызов: + * закрытие без успешного текста (выход из режима, стоп) сообщает вызывающему об отмене. Для + * вызывающего [onCancel] идемпотентен (deferred уже завершён — повтор игнорируется). + */ + override fun onExit() { + engine.endMic() + engine.unbindListening() + onCancel() + } + + /** Таблица жестов под-состояния (top == Listening): этот слой *занимается* микрофоном. */ + override fun gesture(g: Gesture) { + when (g) { + Gesture.Click -> + if (engine.sttStartBlocked.value) engine.beginMic() else engine.sendPhrase() + Gesture.DoubleClick -> + if (engine.sttStartBlocked.value) { + engine.exitMode("DoubleClick → выход (модель не скачалась)") // onExit → onCancel + } else { + onCancel() // → вызывающий: stopToIdle (останемся в ассистенте) + } + Gesture.TripleClick, Gesture.SwipeForward, Gesture.SwipeBackward -> Unit + } + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt new file mode 100644 index 0000000..aea232f --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt @@ -0,0 +1,73 @@ +package pw.binom.viewmate.glasses.ui.layers + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.core.protocol.MediaCommandMsg +import pw.binom.viewmate.glasses.ConnectionScreen +import pw.binom.viewmate.glasses.Gesture +import pw.binom.viewmate.glasses.GlassesApp +import pw.binom.viewmate.glasses.Layout +import pw.binom.viewmate.glasses.LayoutContext +import pw.binom.viewmate.glasses.MovieController +import pw.binom.viewmate.glasses.log +import pw.binom.viewmate.glasses.ui.GlassesMovieScreen + +/** + * Кино — корень стека. Рисуется всегда (нижний слой композита): активный фильм — + * [GlassesMovieScreen], иначе экран подключения [ConnectionScreen]. Поверх него LayoutManager + * кладёт затемнение + под-слои (ассистент/слушание), поэтому фильм виден на фоне чата. + * + * Слой *владеет* своим медиастейтом ([controller]: плеер/позиция/wifi-lock) — он живёт со + * стопом корня, а не с жизненным циклом Activity. Протокол/lifecycle/эффекты дотягиваются до + * движка через слой ([controller]). Слой *сам* отвечает за свои жесты: тройной клик — пауза + + * открыть ассистента, клик — плей/пауза, свайпы — seek. Открытие под-слоёв — через [ctx]. + */ +class MoveLayout( + private val ctx: LayoutContext, + context: Context, +) : Layout { + /** Медиадвижок слоя: [MoveLayout] — владелец (корень живёт до конца сессии очков). */ + val controller = MovieController(context) + + @Composable + override fun drawStage() { + val movieActive by controller.movieActive.collectAsState() + val title by controller.title.collectAsState() + val player = controller.player + if (movieActive && player != null) GlassesMovieScreen(player = player, title = title) + else ConnectionScreen() + } + + @Composable + override fun drawStatusPanel() = Unit + + /** Жесты кино (слой активен, когда он вершина стека): каждый жест ведёт себя сам. */ + override fun gesture(g: Gesture) { + when (g) { + Gesture.Click -> togglePlayback() + Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore) + Gesture.TripleClick -> GlassesApp.instance.assistantEngine.enterAssistant() + Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000) + Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000) + } + } + + /** Click (кино): плей/пауза + отправить позицию на телефон. */ + private fun togglePlayback() { + log("gesture", "жест: Click → ${MediaCommand.TOGGLE}") + controller.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) { + GlassesApp.instance.hostConnection.sendPlaybackPositionNow() + } + } + + /** Свайп (кино): seek на ±[ms]. */ + private fun seekBy(cmd: MediaCommand, ms: Long) { + log("gesture", "жест: свайп → $cmd ${ms}ms") + controller.applyMediaCommand(MediaCommandMsg(cmd, ms)) { + GlassesApp.instance.hostConnection.sendPlaybackPositionNow() + } + } +} diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LayoutManagerTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LayoutManagerTest.kt new file mode 100644 index 0000000..3ecbf9f --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LayoutManagerTest.kt @@ -0,0 +1,168 @@ +package pw.binom.viewmate.glasses + +import androidx.compose.runtime.Composable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * [LayoutManager] — ЛIFO-стек layout-ов очков: корень (двойным кликом не вытолкнуть) → под-слои. + * Проверяем: open/close/closeToRoot, вершина, композитинг (затемнение между слоями), индикатор + * записи, маршрутизацию жеста в верхний слой (и только в него). + */ +class LayoutManagerTest { + + /** Тестируемый layout: пустые @Composable тела (в юнит-тестах не компонируются), лог жеста. */ + private class FakeLayout( + val id: String, + private val onGesture: (Gesture) -> Unit = {}, + ) : Layout { + val gestures = mutableListOf() + + @Composable + override fun drawStage() {} + + @Composable + override fun drawStatusPanel() {} + + override fun gesture(g: Gesture) { + gestures += g + onGesture(g) + } + } + + private val root = FakeLayout("film") + private val assistant = FakeLayout("assistant") + + @Test + fun `root is the initial current`() { + val m = LayoutManager { _ -> root } + assertEquals(1, m.depth) + assertIs(m.current) + assertEquals("film", (m.current as FakeLayout).id) + } + + @Test + fun `open pushes a child on top`() { + val m = LayoutManager { _ -> root } + m.open { FakeLayout("assistant") } + assertEquals(2, m.depth) + assertEquals("assistant", (m.current as FakeLayout).id) + assertEquals(listOf("film", "assistant"), m.layersBottomUp().map { (it as FakeLayout).id }) + } + + @Test + fun `close pops to root`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + m.close() + assertEquals(1, m.depth) + assertEquals("film", (m.current as FakeLayout).id) + } + + @Test + fun `close on root is a no-op`() { + val m = LayoutManager { _ -> root } + m.close() + assertEquals(1, m.depth) + assertEquals("film", (m.current as FakeLayout).id) + } + + @Test + fun `closeToRoot drops every non-root layer`() { + val m = LayoutManager { _ -> root } + m.open { FakeLayout("assistant") } + m.open { FakeLayout("listening") } + assertEquals(3, m.depth) + m.closeToRoot() + assertEquals(1, m.depth) + assertEquals(listOf("film"), m.layersBottomUp().map { (it as FakeLayout).id }) + } + + @Test + fun `compositing dims every layer but the top`() { + val m = LayoutManager { _ -> root } + m.open { FakeLayout("assistant") } + m.open { FakeLayout("listening") } + val layers = m.compositing() + assertEquals(3, layers.size) + // film → dim, assistant → dim, listening → без затемнения. + assertEquals(listOf("film", "assistant", "listening"), layers.map { (it.layout as FakeLayout).id }) + assertEquals(listOf(true, true, false), layers.map { it.dim }) + } + + @Test + fun `single layer composites without dim`() { + val m = LayoutManager { _ -> root } + val layers = m.compositing() + assertEquals(listOf(false), layers.map { it.dim }) + } + + @Test + fun `dispatch routes only to the top layer`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + m.dispatch(Gesture.Click) + // Вершина (assistant) получила жест, корень — нет. + assertEquals(listOf(Gesture.Click), assistant.gestures) + assertEquals(0, root.gestures.size) + } + + @Test + fun `dispatch after close routes back to root`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + m.close() + m.dispatch(Gesture.DoubleClick) + assertEquals(0, assistant.gestures.size) + assertEquals(listOf(Gesture.DoubleClick), root.gestures) + } + + @Test + fun `recording indicator reflects an active layer`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + assertFalse(m.recordingIndicator()) + m.setRecordingIndicator(true) + assertTrue(m.recordingIndicator()) + m.setRecordingIndicator(false) + assertFalse(m.recordingIndicator()) + } + + @Test + fun `popping a recording layer turns the indicator off`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + m.setRecordingIndicator(true) + assertTrue(m.recordingIndicator()) + m.close() + // assistant (POP) выключил индикатор — корень его не включал. + assertFalse(m.recordingIndicator()) + } + + @Test + fun `a re-opened layer starts with the indicator off`() { + val m = LayoutManager { _ -> root } + m.open { assistant } + m.setRecordingIndicator(true) + m.close() + m.open { FakeLayout("assistant2") } + // Новый экземпляр не наследует включение — индикатор выключен, пока сам не включит. + assertFalse(m.recordingIndicator()) + } + + @Test + fun `child receives the shared context`() { + var received: LayoutContext? = null + val m = LayoutManager { _ -> root } + m.open { ctx -> received = ctx; assistant } + assertSame(m, received) + assertTrue(m.recordingIndicator() == false) + } + + private fun assertSame(a: Any?, b: Any?) { + if (a !== b) throw AssertionError("ожидал тот же экземпляр контекста") + } +} diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt index 9f34699..7198cfd 100644 --- a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt @@ -18,34 +18,43 @@ import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.glasses.stt.SttModelStatus /** - * Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино, - * скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»). + * Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров: + * [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон). + * Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино, + * скролл ответа (дельта/зажим/усечение), заголовок оверлея. */ class AssistantGesturesTest { - private val movie = AssistantState() - private val listening = AssistantState(listening = true) - private val thinking = AssistantState(thinking = true) - private val answer = AssistantState(assistantText = "ответ") + // Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film]. + private val movie = AssistantState() // [Film] + private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant)) + private val listening = + AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening)) + private val thinking = idle.copy(thinking = true) + private val answer = idle.copy(assistantText = "ответ") + private val blocked = listening.copy(sttStartBlocked = true) - // ————— режим по состоянию ————— + // ————— режим по вершине стека ————— @Test fun movieStateIsNone() { assertEquals(AssistantMode.NONE, assistantMode(movie)) } - /** Listening=true всё побеждает — жесты идут по матрице «слушаю». */ + /** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */ @Test - fun listeningWinsOverEverything() { - val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а") + fun listeningTopWins() { + val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а") assertEquals(AssistantMode.LISTENING, assistantMode(s)) } - /** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */ + /** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */ @Test fun answerWinsOverThinking() { - assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а"))) + assertEquals( + AssistantMode.ANSWER, + assistantMode(idle.copy(thinking = true, assistantText = "а")), + ) } @Test @@ -53,7 +62,12 @@ class AssistantGesturesTest { assertEquals(AssistantMode.THINKING, assistantMode(thinking)) } - // ————— кино ( NONE) ————— + @Test + fun idleWhenAssistantTopNoContent() { + assertEquals(AssistantMode.IDLE, assistantMode(idle)) + } + + // ————— кино (вершина Film) ————— @Test fun movieMatrix() { @@ -78,12 +92,12 @@ class AssistantGesturesTest { assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d) } - // ————— слушаю ————— + // ————— слушаю (вершина Listening) ————— @Test fun listeningMatrix() { assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening)) - assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening)) + assertEquals(GestureDecision.StopListening, gestureDecision(DoubleClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening)) @@ -91,7 +105,16 @@ class AssistantGesturesTest { assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening)) } - // ————— «думаю» (фраза отправлена, ответа ещё нет) ————— + /** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */ + @Test + fun listeningBlockedMatrix() { + assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked)) + assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked)) + assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked)) + assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked)) + } + + // ————— «думаю» / «ответ» (вершина Assistant) ————— @Test fun thinkingMatrix() { @@ -102,27 +125,87 @@ class AssistantGesturesTest { assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking)) } - // ————— «ответ виден» ————— - @Test fun answerMatrix() { assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer)) - assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer)) - assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer)) - // Плавный: накопленная дельта шлейфа, знак — как пришёл. - assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer)) - assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer)) + // Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) — + // все свайпы (дискретные и плавный шлейф) игнорируются. + assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer)) + assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer)) + assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer)) + assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer)) } + // ————— стек-переходы ————— + + /** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */ @Test - fun answerPageDeltaIsParameter() { - assertEquals( - GestureDecision.ScrollAnswer(80f), - gestureDecision(slideForward(), answer, pageDeltaPx = 80f), - ) + fun enterAssistantPushesBothFrames() { + val s = movie.enterAssistant() + assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack) + assertEquals(AssistantMode.LISTENING, assistantMode(s)) + } + + /** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */ + @Test + fun beginListeningFramePushesListening() { + assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack) + assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame())) + } + + /** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */ + @Test + fun stopListeningStaysInAssistant() { + val decision = gestureDecision(DoubleClick(), listening) + assertEquals(GestureDecision.StopListening, decision) + // POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет. + val after = listening.stopListeningFrame(noSpeech = false) + assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack) + assertEquals(AssistantMode.IDLE, assistantMode(after)) + assertTrue(overlayVisible(after)) + assertFalse(after.listening) + // Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму). + assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after)) + assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after)) + assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after)) + } + + /** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */ + @Test + fun stopFirstRecordingKeepsOverlay() { + val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true) + assertTrue(stopped.assistantText.isBlank()) + assertFalse(stopped.listening) + // Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет. + assertTrue(overlayVisible(stopped)) + // И только явный выход (POP до фильма) прячет оверлей. + val exited = stopped.exitToFilm() + assertFalse(overlayVisible(exited)) + assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited)) + } + + /** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */ + @Test + fun stopListeningClearsCancelledText() { + val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false) + assertEquals("", after.sttText) + assertFalse(after.listening) + } + + /** Выход: POP всё до корня (фильм), сброс контента. */ + @Test + fun exitToFilmPopsToRoot() { + val exited = + listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true) + .exitToFilm() + assertEquals(listOf(NavFrame.Film), exited.stack) + assertFalse(overlayVisible(exited)) + assertTrue(exited.assistantText.isBlank()) + assertFalse(exited.thinking) + assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited)) } // ————— сессия: кино → слушаю → «думаю» → ответ → выход ————— @@ -131,9 +214,9 @@ class AssistantGesturesTest { fun sessionTransitions() { var s = movie assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s)) - s = s.copy(listening = true) + s = s.enterAssistant() assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s)) - s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет + s = s.stopListeningFrame(noSpeech = false).copy(thinking = true) // фраза отправлена, ответ ещё нет assertEquals(AssistantMode.THINKING, assistantMode(s)) assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) @@ -141,77 +224,42 @@ class AssistantGesturesTest { assertEquals(AssistantMode.ANSWER, assistantMode(s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) // Выход: оверлей невидим, кино снова управляется кликом. - val exited = s.copy(assistantText = "") + val exited = s.exitToFilm() assertFalse(overlayVisible(exited)) assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited)) } - /** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */ + /** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */ @Test - fun cancelStaysInConversation() { - val decision = gestureDecision(DoubleClick(), listening) - assertEquals(GestureDecision.CancelDictation, decision) - // После отмены (в т.ч. С первой записи, когда истории диалога ещё нет) - // возврат в «слушаю»: оверлей остаётся на экране, выхода нет. - val after = afterCancelDictation(listening) - assertEquals(AssistantMode.LISTENING, assistantMode(after)) - assertTrue(overlayVisible(after)) - // Двойной тап в «слушаю» после отмены — снова просто отмена новой записи. - assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after)) - // Отмена посреди диалога: пользователь говорит новую фразу → ответ виден - // (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick. - val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ") - assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer)) - assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer)) - } - - /** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */ - @Test - fun cancelFirstRecordingKeepsOverlay() { - // Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи. - val listeningNow = movie.copy(listening = true) - val cancelled = afterCancelDictation(listeningNow) - assertTrue(cancelled.assistantText.isBlank()) - assertTrue(cancelled.listening) - // Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов». - assertTrue(overlayVisible(cancelled)) - // И только явный выход (после диалога) прячет оверлей. - val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ") - val closed = exited.copy(listening = false, thinking = false, assistantText = "") - assertFalse(overlayVisible(closed)) - assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed)) - } - - /** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */ - @Test - fun cancelDictationClearsCancelledText() { - val after = afterCancelDictation(listening.copy(sttText = "слова до отмены")) - assertEquals("", after.sttText) - assertTrue(after.listening) + fun filmRootCannotBePopped() { + assertEquals(AssistantMode.NONE, assistantMode(movie)) + assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie)) + assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie)) } // ————— оверлей ————— @Test fun overlayInvisibleInMovie() { - // Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает). + // Настоящее кино-состояние: стек [Film], оверлей невидим. assertFalse(overlayVisible(movie)) } @Test - fun overlayVisibleInListeningThinkingAndAnswer() { + fun overlayVisibleInAssistantFrames() { assertTrue(overlayVisible(listening)) assertTrue(overlayVisible(thinking)) assertTrue(overlayVisible(answer)) + assertTrue(overlayVisible(idle)) } @Test fun thinkingHeaderIsDotDotDot() { assertEquals("думаю...", overlayHeader(thinking)) - // Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...». + // После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...». assertEquals( "думаю...", - overlayHeader(AssistantState(listening = true, thinking = true)), + overlayHeader(listening.copy(thinking = true)), ) } @@ -222,10 +270,37 @@ class AssistantGesturesTest { } @Test - fun answerHeaderIsLastPhraseOrNull() { - assertEquals("привет", overlayHeader(answer.copy(sttText = "привет"))) - assertNull(overlayHeader(answer)) - assertNull(overlayHeader(movie)) + fun idleHeaderHintsGestures() { + assertEquals("клик — говорить · двойной — выход", overlayHeader(idle)) + // В ответе подсказка та же — чат прокручивается, жесты те же. + assertEquals("клик — говорить · двойной — выход", overlayHeader(answer)) + } + + /** Пустой клик без голоса — idle-кадр с «не расслышал». */ + @Test + fun noSpeechIdleShowsHint() { + val noSpeech = idle.copy(sttNoSpeech = true) + assertEquals(AssistantMode.IDLE, assistantMode(noSpeech)) + assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech)) + assertTrue(overlayVisible(noSpeech)) + } + + // ————— старт отклонён: модель не скачалась ————— + + @Test + fun overlayVisibleWhenStartBlocked() { + assertTrue(overlayVisible(blocked)) + assertFalse(overlayVisible(movie)) + } + + @Test + fun startBlockedHeaderIsModelNotDownloaded() { + // Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась». + assertEquals("модель не скачалась", overlayHeader(blocked)) + assertEquals( + "модель не скачалась", + overlayHeader(blocked.copy(assistantText = "ответ")), + ) } // ————— строка статуса модели ————— diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt index b5b5d68..abc6ea8 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt @@ -32,6 +32,9 @@ object MercuryBridge { private const val TAG = "MercuryBridge" + /** Пакет нашего очкового приложения (applicationId из app-glasses). */ + const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses" + private var mercury: RayNeoMercury? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -234,6 +237,16 @@ object MercuryBridge { return ok } + /** + * Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]). + * Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent). + */ + suspend fun launchApp(pkg: String, extras: Map? = null): Boolean { + val ok = mercury?.appMarketLaunch(pkg, extras) ?: false + setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)") + return ok + } + /** Автоблокировка экрана через N секунд; -1 = никогда. */ suspend fun autoLockTime(time: Int): Boolean { val ok = mercury?.setAutoLockTime(time) ?: false diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index e816a42..377479d 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -167,6 +167,9 @@ class PhoneApp : Application() { val localLlmState: StateFlow by lazy { _localLlmState.asStateFlow() } private var localLlmDownloadJob: Job? = null + /** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */ + private var localLlmAutoRetry = false + private fun initialLocalLlmState(): LocalLlmModelState = if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle @@ -186,30 +189,41 @@ class PhoneApp : Application() { * Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)), * прогресс в процентах в [localLlmState], контрольная сумма SHA-256. * Повторный вызов во время скачивания — no-op. + * Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп») + * ждёт 60с и снова пытается, пока файла нет. */ fun downloadLocalLlmModel() { val current = localLlmDownloadJob if (current != null && current.isActive) return + localLlmAutoRetry = true localLlmDownloadJob = scope.launch { - val target = localModelFile() - _localLlmState.value = LocalLlmModelState.Downloading(0) - try { - GemmaModelDownloader(target).download { percent -> - _localLlmState.value = LocalLlmModelState.Downloading(percent) + while (localLlmAutoRetry && !localModelFile().isFile) { + val target = localModelFile() + _localLlmState.value = LocalLlmModelState.Downloading(0) + try { + GemmaModelDownloader(target).download { percent -> + _localLlmState.value = LocalLlmModelState.Downloading(percent) + } + _localLlmState.value = LocalLlmModelState.Ready + log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт") + return@launch + } catch (e: CancellationException) { + _localLlmState.value = LocalLlmModelState.Idle + throw e + } catch (e: Exception) { + val msg = e.message ?: e.toString() + _localLlmState.value = LocalLlmModelState.Error(msg) + log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с") + delay(60_000) } - _localLlmState.value = LocalLlmModelState.Ready - log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт") - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - _localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка") - log("llm", "не удалось скачать модель: ${e.message}") } + // отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle } } - /** Остановить скачивание локальной модели (частичный файл удаляется). */ + /** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */ fun cancelLocalLlmDownload() { + localLlmAutoRetry = false localLlmDownloadJob?.cancel() } @@ -313,25 +327,35 @@ class PhoneApp : Application() { * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. */ + private val NO_LLM_ANSWER = + "Серверный LLM не настроен (нет API-ключа в local.properties). " + + "Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " + + "либо задайте llm.apiKey и пересоберите." + private suspend fun assistantLoop() { for (phrase in assistantChannel) { log("assistant", "получена фраза: $phrase") val assistant = ensureAssistant() + val answer: String if (assistant == null) { - log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена") - continue - } - server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) - val answer = runCatching { - val active = _activeSessionId.value - if (active != null) { - assistant.processInSession(active, phrase) - } else { - assistant.process(phrase) + // Серверный LLM без API-ключа: не «пропущена в никуда», а явная + // подсказка + сохранение фразы в БД (чтобы сообщение не исчезало). + log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю") + answer = NO_LLM_ANSWER + persistNoLlm(phrase) + } else { + server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) + answer = runCatching { + val active = _activeSessionId.value + if (active != null) { + assistant.processInSession(active, phrase) + } else { + assistant.process(phrase) + } } + .onFailure { log("assistant", "ошибка: ${it.message}") } + .getOrElse { "Ассистент недоступен: ${it.message}" } } - .onFailure { log("assistant", "ошибка: ${it.message}") } - .getOrElse { "Ассистент недоступен: ${it.message}" } server.hub.broadcast(ShowText(answer)) // Очки показывают историю всей сессии (свайпами); ответ уже в ней. server.hub.broadcast(chatHistoryMsg()) @@ -339,6 +363,16 @@ class PhoneApp : Application() { } } + /** + * Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и + * короткую подсказку, чтобы в чате/на очках не было «провала» в истории. + */ + private fun persistNoLlm(phrase: String) { + val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM") + chatDao.addMessage(id, role = ChatRole.USER, content = phrase) + chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER) + } + /** * История активной (или последней) сессии для очков: только роли user/assistant, * tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1). @@ -499,6 +533,13 @@ class PhoneApp : Application() { scope.launch { assistantLoop() } + // Автодокачка локальной LLM-модели при старте, если файла нет. + // Каскадный автоповтор (60с) до успеха или «стоп» в настройках. + if (!localModelFile().isFile) { + log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})") + downloadLocalLlmModel() + } + val lastPositionAt = AtomicLong(0) val lastWasPlaying = AtomicBoolean(false) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt index 573568f..f32bf98 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt @@ -157,7 +157,7 @@ fun ChatScreen() { Box { TextButton(onClick = { sessionMenu = true }) { Text( - text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…", + text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет", color = PhoneAccent, fontSize = 12.sp, ) @@ -165,7 +165,7 @@ fun ChatScreen() { DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) { sessions.forEach { s -> DropdownMenuItem( - text = { Text(s.title, fontSize = 12.sp) }, + text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) }, onClick = { activeId = s.id app.selectChatSession(s.id) @@ -198,12 +198,29 @@ fun ChatScreen() { // Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»). val llmChoice by app.llmChoice.collectAsState() - Text( - text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw", - color = PhoneMuted, - fontSize = 10.sp, - ) - + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" + else "Серверный LLM · llm.binom.pw", + color = PhoneMuted, + fontSize = 10.sp, + ) + Spacer(Modifier.width(6.dp)) + // Переключить бэкенд прямо в чате (без захода в настройки). + Text( + text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный", + color = PhoneAccent, + fontSize = 10.sp, + modifier = Modifier + .clickable { + app.setLlmModelChoice( + if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE + else LlmModelChoice.LOCAL, + ) + } + .padding(horizontal = 4.dp, vertical = 2.dp), + ) + } Spacer(Modifier.height(4.dp)) // Лента сообщений diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt index 99c1b30..e9a775f 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt @@ -201,53 +201,60 @@ private fun LlmModelSection() { ) } Spacer(Modifier.height(8.dp)) - if (choice == LlmModelChoice.LOCAL) { - when (val s = llmState) { - LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})", - color = PhoneMuted, - fontSize = 11.sp, - modifier = Modifier.weight(1f), - ) - LlmSmallAction("скачать") { app.downloadLocalLlmModel() } - } - - is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) { - Row(verticalAlignment = Alignment.CenterVertically) { - ProgressBar( - percent = s.percent, - modifier = Modifier - .weight(1f) - .padding(end = 8.dp), - ) - LlmSmallAction("стоп") { app.cancelLocalLlmDownload() } - } - Spacer(Modifier.height(4.dp)) - Text( - text = "Скачивание модели… ${s.percent}%", - color = PhoneMuted, - fontSize = 10.sp, - ) - } - - LocalLlmModelState.Ready -> Text( - text = "Модель готова (Gemma 4 E2B, локально)", - color = PhoneGreen, + // Индикатор локальной модели — независимо от выбора бэкенда: пока модель + // скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL. + when (val s = llmState) { + LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})", + color = PhoneMuted, fontSize = 11.sp, + modifier = Modifier.weight(1f), ) - - is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Ошибка скачивания: ${s.message}", - color = PhoneRed, - fontSize = 11.sp, - modifier = Modifier.weight(1f), - ) - LlmSmallAction("повторить") { app.downloadLocalLlmModel() } - } + LlmSmallAction("скачать") { app.downloadLocalLlmModel() } } - } else { + + is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProgressBar( + percent = s.percent, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + ) + LlmSmallAction("стоп") { app.cancelLocalLlmDownload() } + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Скачивание локальной модели… ${s.percent}%", + color = PhoneMuted, + fontSize = 10.sp, + ) + } + + LocalLlmModelState.Ready -> Text( + text = "Локальная модель готова (Gemma 4 E2B)", + color = PhoneGreen, + fontSize = 11.sp, + ) + + is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Ошибка скачивания: ${s.message}", + color = PhoneRed, + fontSize = 11.sp, + modifier = Modifier.weight(1f), + ) + LlmSmallAction("повторить") { app.downloadLocalLlmModel() } + } + } + + // Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке. + if ( + choice == LlmModelChoice.REMOTE && + llmState !is LocalLlmModelState.Downloading && + llmState !is LocalLlmModelState.Error + ) { Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 0a0f190..d8a2ee2 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.phone.PhoneApp +import pw.binom.viewmate.phone.agent.LocalLlmModelState import pw.binom.viewmate.phone.glassesLivePercent /** Экран 3 — Статус очков + пульт. */ @@ -61,6 +62,7 @@ fun GlassesScreen( val playing by app.state.playingFlow.collectAsState() val mode by app.state.modeFlow.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() + val llmState by app.localLlmState.collectAsState() val durationMs = currentItem?.RunTimeTicks?.div(10_000) var showDownloads by remember { mutableStateOf(false) } @@ -222,6 +224,18 @@ fun GlassesScreen( ) } + // авто-скачивание локальной LLM-модели (Gemma 4 E2B) + val llmModelState = llmState + if (llmModelState is LocalLlmModelState.Downloading) { + Text( + text = "скачивание LLM-модели: ${llmModelState.percent}%", + color = PhoneYellow, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + } + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt index d86bd48..cc02f95 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt @@ -197,6 +197,19 @@ fun MercuryPanel() { } } + Row(modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneBlue, + contentColor = PhoneBlueText, + ), + ) { + Text("Запустить приложение", fontSize = 12.sp) + } + } + // ---- Яркость ---- Text( text = "Яркость: $brightness%",