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 его не отдаёт — детект по > `MainActivity.kt`). `TripleClick`: если SDK его не отдаёт — детект по
> таймстампам кликов в `onTouchEvent`. > таймстампам кликов в `onTouchEvent`.
Ассистент (потоковый): TripleClick → говорите → каждая фраза уходит в LLM (llm.binom.pw), ответ текстом на очки; история диалога — в SQLite на телефоне. Модель/ключ: local.properties llm.model/llm.apiKey.
## Состояния ассистента (единые для обоих ассистентов) ## Состояния ассистента (единые для обоих ассистентов)
``` ```
@@ -42,6 +42,9 @@ class GlassesApp : Application() {
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("") val sttText = MutableStateFlow("")
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** Применить и сохранить масштаб. */ /** Применить и сохранить масштаб. */
fun applyScale(value: Float) { fun applyScale(value: Float) {
val v = value.coerceIn(0.5f, 1f) val v = value.coerceIn(0.5f, 1f)
@@ -267,7 +267,10 @@ class HostConnection(
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") 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}") 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -223,6 +225,7 @@ class MainActivity : BaseEventActivity() {
} }
GlassesApp.instance.listening.value = true GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = "" GlassesApp.instance.sttText.value = ""
GlassesApp.instance.assistantText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value } sttMic.start(filesDir) { GlassesApp.instance.listening.value }
} }
@@ -377,9 +380,12 @@ fun ConnectionScreen() {
) )
// Оверлей распознавания: затемнение + накопленный текст (поверх всего). // Оверлей распознавания: затемнение + накопленный текст (поверх всего).
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
// (assistantText) — ответ читается до новой сессии.
val listening by GlassesApp.instance.listening.collectAsState() val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState() val sttText by GlassesApp.instance.sttText.collectAsState()
if (listening) { val assistantText by GlassesApp.instance.assistantText.collectAsState()
if (listening || assistantText.isNotBlank()) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -395,6 +401,15 @@ fun ConnectionScreen() {
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium, 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 props = Properties()
val local = rootProject.file("local.properties") val local = rootProject.file("local.properties")
if (local.exists()) local.inputStream().use(props::load) 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("\\", "\\\\")
.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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState 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.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase 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.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb 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.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt import pw.binom.viewmate.phone.stt.WhisperStt
@@ -70,6 +76,47 @@ class PhoneApp : Application() {
private var sttLock = Any() private var sttLock = Any()
private var sttStreamer: SttStreamer? = null 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 — * Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
@@ -99,6 +146,7 @@ class PhoneApp : Application() {
onPhrase = { phrase, full -> onPhrase = { phrase, full ->
log("stt", "фраза: $phrase") log("stt", "фраза: $phrase")
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) } scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
scope.launch { assistantChannel.send(phrase) }
}, },
onSilence30s = { onSilence30s = {
log("stt", "тишина 30 с — автo-отмена") log("stt", "тишина 30 с — автo-отмена")
@@ -145,6 +193,7 @@ class PhoneApp : Application() {
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() } server.hub.sttFactory = { ensureStt() }
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) 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) { fun renameSession(id: Long, title: String) {
db.writableDatabase.update( db.writableDatabase.update(
ChatDb.TABLE_SESSIONS, ChatDb.TABLE_SESSIONS,