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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user