From a434d5bb125a966b3f59685e869b7083a089c8c3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 12:35:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B0=D1=81=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=84=D1=80=D0=B0=D0=B7=D0=B0=20=E2=86=92?= =?UTF-8?q?=20LLM=20=E2=86=92=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent/Assistant.kt: оркестратор диалога (SQLite-история + Koog), таймаут 30с, сессия одна живая, история — последние 20 сообщений - ChatDao.latestSessionId(); SYSTEM_PROMPT нейтральный (TODO: персона) - PhoneApp: очередь фраз (Channel UNLIMITED), assistantLoop (THINKING/ShowText/IDLE), ensureAssistant ленивый; ключ/модель из BuildConfig (local.properties) - Очки: assistantText StateFlow, ShowText → оверлей (белый текст речи + серый ответ), оверлей остаётся после Click до новой сессии - AssistantTest (androidTest, 3 теста: сессия/история/таймаут) — OK (6 tests всего) - README: строка про ассистента --- README.md | 2 + .../pw/binom/viewmate/glasses/GlassesApp.kt | 3 + .../binom/viewmate/glasses/HostConnection.kt | 5 +- .../pw/binom/viewmate/glasses/MainActivity.kt | 17 ++- app-phone/build.gradle.kts | 2 +- .../viewmate/phone/agent/AssistantTest.kt | 100 ++++++++++++++++++ .../pw/binom/viewmate/phone/PhoneApp.kt | 49 +++++++++ .../binom/viewmate/phone/agent/Assistant.kt | 37 +++++++ .../pw/binom/viewmate/phone/agent/ChatDao.kt | 9 ++ 9 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/AssistantTest.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt diff --git a/README.md b/README.md index 1bb6144..8a83c21 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ sketches/ эскизы UI: раскладка экранов + сценар > `MainActivity.kt`). `TripleClick`: если SDK его не отдаёт — детект по > таймстампам кликов в `onTouchEvent`. +Ассистент (потоковый): TripleClick → говорите → каждая фраза уходит в LLM (llm.binom.pw), ответ текстом на очки; история диалога — в SQLite на телефоне. Модель/ключ: local.properties llm.model/llm.apiKey. + ## Состояния ассистента (единые для обоих ассистентов) ``` 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 ea14722..5ff05ec 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 @@ -42,6 +42,9 @@ class GlassesApp : Application() { /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ val sttText = MutableStateFlow("") + /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ + val assistantText = MutableStateFlow("") + /** Применить и сохранить масштаб. */ fun applyScale(value: Float) { val v = value.coerceIn(0.5f, 1f) 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 c41e8d7..a37039e 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 @@ -267,7 +267,10 @@ class HostConnection( is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") - is ShowText -> addMessage("ShowText: ${msg.text}") + is ShowText -> { + addMessage("ShowText: ${msg.text}") + GlassesApp.instance.assistantText.value = msg.text + } is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") 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 ba1512a..b9e1d91 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 @@ -18,9 +18,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -223,6 +225,7 @@ class MainActivity : BaseEventActivity() { } GlassesApp.instance.listening.value = true GlassesApp.instance.sttText.value = "" + GlassesApp.instance.assistantText.value = "" sttMic.start(filesDir) { GlassesApp.instance.listening.value } } @@ -377,9 +380,12 @@ fun ConnectionScreen() { ) // Оверлей распознавания: затемнение + накопленный текст (поверх всего). + // Остаётся и после завершения распознавания, пока не стёрт ответ ассистента + // (assistantText) — ответ читается до новой сессии. val listening by GlassesApp.instance.listening.collectAsState() val sttText by GlassesApp.instance.sttText.collectAsState() - if (listening) { + val assistantText by GlassesApp.instance.assistantText.collectAsState() + if (listening || assistantText.isNotBlank()) { Box( Modifier .fillMaxSize() @@ -395,6 +401,15 @@ fun ConnectionScreen() { textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium, ) + if (assistantText.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + Text( + assistantText, + color = Color(0xFFB0BEC5), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall, + ) + } } } } diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 192280c..4dedaaa 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -24,7 +24,7 @@ val llmModel: String = run { val props = Properties() val local = rootProject.file("local.properties") if (local.exists()) local.inputStream().use(props::load) - props.getProperty("llm.model", "Routerai/deepseek/deepseek-v4-flash") + props.getProperty("llm.model", "Routerai/deepseek/deepseek-v4-flash-0731") .replace("\\", "\\\\") .replace("\"", "\\\"") } diff --git a/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/AssistantTest.kt b/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/AssistantTest.kt new file mode 100644 index 0000000..feaf48c --- /dev/null +++ b/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/AssistantTest.kt @@ -0,0 +1,100 @@ +package pw.binom.viewmate.phone.agent + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AssistantTest { + + private lateinit var dao: ChatDao + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + context.deleteDatabase("assistant.db") + dao = ChatDao(ChatDb(context)) + } + + @After + fun tearDown() { + context.deleteDatabase("assistant.db") + } + + @Test + fun firstPhraseCreatesSessionAndTwoMessages() = runBlocking { + val histories = mutableListOf>() + val fake: suspend (List, String?) -> String = { history, _ -> + histories += history + "Привет! Чем помочь?" + } + val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000) + + val answer = assistant.process("привет") + + // ответ = то, что вернул фейк + assertEquals("Привет! Чем помочь?", answer) + + // создана сессия, в ней 2 сообщения (user + assistant) + val sessionId = dao.latestSessionId() + assertTrue("сессия должна создаться", sessionId != null) + val messages = dao.messagesForSession(sessionId!!) + assertEquals(2, messages.size) + assertEquals(ChatRole.USER, messages[0].role) + assertEquals("привет", messages[0].content) + assertEquals(ChatRole.ASSISTANT, messages[1].role) + assertEquals("Привет! Чем помочь?", messages[1].content) + } + + @Test + fun secondPhraseReusesSessionAndFakeGetsHistory() = runBlocking { + val histories = mutableListOf>() + val fake: suspend (List, String?) -> String = { history, _ -> + histories += history + "ответ #${history.size}" + } + val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000) + + assistant.process("первый") + val session1 = dao.latestSessionId() + assistant.process("второй") + val session2 = dao.latestSessionId() + + // сессия та же + assertEquals(session1, session2) + + // фейк получил историю из 2+ сообщений (user1 + assistant1 + user2) + assertTrue("фейк получил историю из 2+ сообщений", histories.last().size >= 2) + + // в сессии 4 сообщения + assertEquals(4, dao.messagesForSession(session1!!).size) + } + + @Test + fun timeoutReturnsMessageAndOnlyUserMessagePersisted() = runBlocking { + val fake: suspend (List, String?) -> String = { _, _ -> + delay(150) + "опоздал" + } + val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 100) + + val answer = assistant.process("вопрос") + + assertEquals("Ассистент не ответил (таймаут)", answer) + + // в БД ТОЛЬКО user-сообщение — assistant не пишется + val sessionId = dao.latestSessionId() + val messages = dao.messagesForSession(sessionId!!) + assertEquals(1, messages.size) + assertEquals(ChatRole.USER, messages[0].role) + assertEquals("вопрос", messages[0].content) + } +} 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 0adbeff..c825782 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 @@ -7,6 +7,7 @@ import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,10 +19,15 @@ import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneState +import pw.binom.viewmate.core.protocol.AssistantState +import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttPhrase +import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDb +import pw.binom.viewmate.phone.agent.LlmClient import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.WhisperStt @@ -70,6 +76,47 @@ class PhoneApp : Application() { private var sttLock = Any() private var sttStreamer: SttStreamer? = null + /** + * Очередь распознанных фраз для ассистента. UNLIMITED — `send` неблокирующий, + * STT-поток не блокируется. Единственный consumer — [assistantLoop] (сериализация + * LLM-вызовов: фразы обрабатываются по одной). + */ + private val assistantChannel = Channel(Channel.UNLIMITED) + + private var assistantLock = Any() + private var assistant: Assistant? = null + + /** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */ + private fun ensureAssistant(): Assistant? { + synchronized(assistantLock) { + assistant?.let { return it } + if (BuildConfig.LLM_API_KEY.isBlank()) return null + val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) + return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) }) + .also { assistant = it } + } + } + + /** + * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. + * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. + */ + private suspend fun assistantLoop() { + for (phrase in assistantChannel) { + val assistant = ensureAssistant() + if (assistant == null) { + log("assistant", "LLM-ключ не задан — фраза пропущена") + continue + } + server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) + val answer = runCatching { assistant.process(phrase) } + .onFailure { log("assistant", "ошибка: ${it.message}") } + .getOrElse { "Ассистент недоступен: ${it.message}" } + server.hub.broadcast(ShowText(answer)) + server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE)) + } + } + /** * Лениво создаёт стриминговое распознавание (модели во filesDir/models — * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба @@ -99,6 +146,7 @@ class PhoneApp : Application() { onPhrase = { phrase, full -> log("stt", "фраза: $phrase") scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) } + scope.launch { assistantChannel.send(phrase) } }, onSilence30s = { log("stt", "тишина 30 с — автo-отмена") @@ -145,6 +193,7 @@ class PhoneApp : Application() { log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") nsd.publish() server.hub.sttFactory = { ensureStt() } + scope.launch { assistantLoop() } val lastPositionAt = AtomicLong(0) val lastWasPlaying = AtomicBoolean(false) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt new file mode 100644 index 0000000..0ec8bd0 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt @@ -0,0 +1,37 @@ +package pw.binom.viewmate.phone.agent + +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Оркестратор диалога с локальным ассистентом: фраза → история из SQLite → + * LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM, + * история накапливается в сессии. Синхронный вызов из одной корутины + * (сериализация фраз — LLM-вызовы не пересекаются). + */ +class Assistant( + private val chatDao: ChatDao, + private val chat: suspend (history: List, system: String?) -> String, + private val timeoutMs: Long = 30_000, +) { + /** + * Обработать фразу: сохранить user-сообщение, вызвать LLM с историей + * сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM — + * ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение. + */ + suspend fun process(text: String): String { + val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент") + chatDao.addMessage(sessionId, role = ChatRole.USER, content = text) + val history = chatDao.messagesForSession(sessionId).takeLast(20) + val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) } + ?: return "Ассистент не ответил (таймаут)" + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + chatDao.touchSession(sessionId) + return answer + } + + companion object { + // TODO(персона): промпт выберет пользователь + const val SYSTEM_PROMPT = + "Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него." + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt index ed5522d..7a6293c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt @@ -23,6 +23,15 @@ class ChatDao(private val db: ChatDb) { ) } + /** Последняя активная сессия (по обновлению), или null, если сессий ещё нет. */ + fun latestSessionId(): Long? { + val cursor = db.readableDatabase.rawQuery( + "SELECT id FROM sessions ORDER BY updated_at DESC, id DESC LIMIT 1", + null, + ) + return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null } + } + fun renameSession(id: Long, title: String) { db.writableDatabase.update( ChatDb.TABLE_SESSIONS,