feat: потоковый локальный ассистент — фраза → LLM → ответ на очки

- 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: строка про ассистента
This commit is contained in:
Hermes Agent
2026-08-20 12:35:49 +03:00
parent d9d34e9277
commit a434d5bb12
9 changed files with 221 additions and 3 deletions
+2
View File
@@ -87,6 +87,8 @@ sketches/ эскизы UI: раскладка экранов + сценар
> `MainActivity.kt`). `TripleClick`: если SDK его не отдаёт — детект по
> таймстампам кликов в `onTouchEvent`.
Ассистент (потоковый): TripleClick → говорите → каждая фраза уходит в LLM (llm.binom.pw), ответ текстом на очки; история диалога — в SQLite на телефоне. Модель/ключ: local.properties llm.model/llm.apiKey.
## Состояния ассистента (единые для обоих ассистентов)
```
@@ -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)
@@ -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}")
@@ -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,
)
}
}
}
}
+1 -1
View File
@@ -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("\"", "\\\"")
}
@@ -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<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, 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<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, 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<ChatMessage>, 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)
}
}
@@ -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<String>(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)
@@ -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<ChatMessage>, 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 и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
}
}
@@ -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,