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 его не отдаёт — детект по
|
> `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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user