feat: история общения (SQLite) + Koog LLM-клиент в app-phone

SQLite (голый SQLiteOpenHelper, без ORM — как решили):
- sessions (id, title, created_at, updated_at) + messages (role, content,
  tool_call_id, tool_calls JSON, attachments JSON, created_at) по утверждённой
  схеме; FK-каскад; CHECK на роли.
- ChatDb + ChatDao (createSession/listSessions/rename/touch/delete, addMessage,
  messagesForSession), доступ через PhoneApp.chatDao.
- Unit: ModelsTest (роли, JSON tool_calls/attachments). androidTest:
  ChatDaoTest — CRUD-круг + каскад — OK (3 tests) на эмуляторе.

Koog (JetBrains) — базовый LLM-клиент, без агентного слоя:
- ai.koog:prompt-executor-openai-client:1.1.1 + http-client-okhttp
- LlmClient: OpenAILLMClient(apiKey, baseUrl=llm.binom.pw) → chat(история)
- BuildConfig LLM_API_KEY/LLM_MODEL из local.properties (вне git); llm_debug
  smoke (--ez llm_debug true) — проверен на эмуляторе: реальный ответ от
  llm.binom.pw (deepseek-v4-flash).
- ЗАГЛУШКА Coroutines_jvmKt: дефект публикации Koog 1.1.1 — utils-android не
  включает jvm-класс, который зовёт OkHttpKoogHttpClient (без неё
  NoClassDefFoundError на первом вызове). Сигнатура сверена с utils-jvm.
  Убрать после фикса JetBrains.
This commit is contained in:
Hermes Agent
2026-08-20 11:52:50 +03:00
parent 3ed63012f4
commit d9d34e9277
12 changed files with 591 additions and 0 deletions
@@ -8,6 +8,7 @@ import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp
import java.io.File
@@ -20,6 +21,18 @@ class MainActivity : ComponentActivity() {
if (intent?.getBooleanExtra("stt_debug", false) == true) {
runSttDebug()
}
if (intent?.getBooleanExtra("llm_debug", false) == true) {
runLlmDebug()
}
}
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...")
Thread {
LlmDebug.run(BuildConfig.LLM_API_KEY, BuildConfig.LLM_MODEL)
runOnUiThread { showOverlay("[llm] готово — смотри лог") }
}.start()
}
/** Отладочный прогон распознавания: берёт первый wav из filesDir/samples. */
@@ -20,6 +20,8 @@ import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
@@ -51,6 +53,9 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
/** Автоматика паузы/продолжения звука при обрыве связи. */
private val connectionAutoResume = ConnectionAutoResume()
@@ -0,0 +1,142 @@
package pw.binom.viewmate.phone.agent
import android.content.ContentValues
import android.database.Cursor
import kotlinx.serialization.encodeToString
/**
* DAO истории общения. Все методы синхронные — вызывать из Dispatchers.IO
* на стороне клиента. Голый SQLiteOpenHelper + ContentValues/rawQuery, без ORM.
*/
class ChatDao(private val db: ChatDb) {
fun createSession(title: String = ""): Long {
val now = System.currentTimeMillis()
return db.writableDatabase.insert(
ChatDb.TABLE_SESSIONS,
null,
ContentValues().apply {
put("title", title)
put("created_at", now)
put("updated_at", now)
},
)
}
fun renameSession(id: Long, title: String) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
ContentValues().apply { put("title", title) },
"id = ?",
arrayOf(id.toString()),
)
}
fun touchSession(id: Long) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
ContentValues().apply { put("updated_at", System.currentTimeMillis()) },
"id = ?",
arrayOf(id.toString()),
)
}
fun listSessions(): List<Session> {
val cursor = db.readableDatabase.rawQuery(
"SELECT id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC",
null,
)
return cursor.use { c ->
val sessions = ArrayList<Session>(c.count)
while (c.moveToNext()) sessions += c.toSession()
sessions
}
}
/** Каскад в messages.session_id удалит и все сообщения. */
fun deleteSession(id: Long) {
db.writableDatabase.delete(
ChatDb.TABLE_SESSIONS,
"id = ?",
arrayOf(id.toString()),
)
}
fun addMessage(
sessionId: Long,
role: ChatRole,
content: String = "",
toolCallId: String? = null,
toolCalls: List<ToolCall> = emptyList(),
attachments: List<Attachment> = emptyList(),
): Long {
val id = db.writableDatabase.insert(
ChatDb.TABLE_MESSAGES,
null,
ContentValues().apply {
put("session_id", sessionId)
put("role", role.wire)
put("content", content)
put("tool_call_id", toolCallId)
put("tool_calls", encodeToolCalls(toolCalls))
put("attachments", encodeAttachments(attachments))
put("created_at", System.currentTimeMillis())
},
)
touchSession(sessionId)
return id
}
fun messagesForSession(sessionId: Long): List<ChatMessage> {
val cursor = db.readableDatabase.rawQuery(
"SELECT * FROM messages WHERE session_id = ? ORDER BY id",
arrayOf(sessionId.toString()),
)
return cursor.use { c ->
val messages = ArrayList<ChatMessage>(c.count)
while (c.moveToNext()) messages += c.toChatMessage()
messages
}
}
// ---------- маппинг строк ----------
private fun Cursor.toSession(): Session = Session(
id = getLong(getColumnIndexOrThrow("id")),
title = getString(getColumnIndexOrThrow("title")),
createdAt = getLong(getColumnIndexOrThrow("created_at")),
updatedAt = getLong(getColumnIndexOrThrow("updated_at")),
)
private fun Cursor.toChatMessage(): ChatMessage = ChatMessage(
id = getLong(getColumnIndexOrThrow("id")),
sessionId = getLong(getColumnIndexOrThrow("session_id")),
role = ChatRole.fromWire(getString(getColumnIndexOrThrow("role"))),
content = getString(getColumnIndexOrThrow("content")),
toolCallId = getNullableString(getColumnIndexOrThrow("tool_call_id")),
toolCalls = decodeToolCalls(getNullableString(getColumnIndexOrThrow("tool_calls"))),
attachments = decodeAttachments(getNullableString(getColumnIndexOrThrow("attachments"))),
createdAt = getLong(getColumnIndexOrThrow("created_at")),
)
private fun Cursor.getNullableString(columnIndex: Int): String? {
if (isNull(columnIndex)) return null
return getString(columnIndex)
}
// ---------- сериализация toolCalls/attachments ----------
private fun encodeToolCalls(toolCalls: List<ToolCall>): String? =
if (toolCalls.isEmpty()) null else agentJson.encodeToString(toolCalls)
private fun encodeAttachments(attachments: List<Attachment>): String? =
if (attachments.isEmpty()) null else agentJson.encodeToString(attachments)
private fun decodeToolCalls(raw: String?): List<ToolCall> =
raw?.let { runCatching { agentJson.decodeFromString<List<ToolCall>>(it) }.getOrNull() }
?: emptyList()
private fun decodeAttachments(raw: String?): List<Attachment> =
raw?.let { runCatching { agentJson.decodeFromString<List<Attachment>>(it) }.getOrNull() }
?: emptyList()
}
@@ -0,0 +1,65 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
/**
* Хранилище истории общения с ассистентом (v1).
* Голый SQLiteOpenHelper, без ORM. Миграции появятся позже; onUpgrade — drop + create.
*/
class ChatDb(context: Context) : SQLiteOpenHelper(context, "assistant.db", null, VERSION) {
override fun onConfigure(db: SQLiteDatabase) {
super.onConfigure(db)
// нужно для ON DELETE CASCADE в messages.session_id
db.setForeignKeyConstraintsEnabled(true)
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_SESSIONS)
db.execSQL(CREATE_MESSAGES)
db.execSQL(CREATE_INDEX)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// v1: миграций пока нет — просто пересоздаём схему
db.execSQL("DROP TABLE IF EXISTS messages")
db.execSQL("DROP TABLE IF EXISTS sessions")
onCreate(db)
}
companion object {
const val VERSION = 1
const val TABLE_SESSIONS = "sessions"
const val TABLE_MESSAGES = "messages"
private val CREATE_SESSIONS =
"""
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""".trimIndent()
private val CREATE_MESSAGES =
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')),
content TEXT NOT NULL DEFAULT '',
tool_call_id TEXT,
tool_calls TEXT,
attachments TEXT,
created_at INTEGER NOT NULL
)
""".trimIndent()
private val CREATE_INDEX =
"CREATE INDEX idx_messages_session ON messages(session_id, id)"
}
}
@@ -0,0 +1,64 @@
package pw.binom.viewmate.phone.agent
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.llm.LLMProvider
/**
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
*
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
*/
class LlmClient(
private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw",
private val model: String, // модель укажет пользователь
) {
private val client: OpenAILLMClient = OpenAILLMClient(
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl),
httpClientFactory = OkHttpKoogHttpClient.Factory(),
)
private val llmModel: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = model,
capabilities = listOf(
LLMCapability.Completion,
LLMCapability.OpenAIEndpoint.Completions,
),
)
/**
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
* System-сообщение, если задано, добавляется первым.
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
val prompt = prompt("assistant-chat") {
if (system != null) {
this.system(system)
}
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
}
}
}
return client.execute(prompt, llmModel).textContent()
}
}
@@ -0,0 +1,41 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.phone.log
/**
* Smoke-проверка Koog-клиента: --ez llm_debug true → один вызов llm.binom.pw,
* ответ в лог [llm]. Модель: llm.model из local.properties (вне git), дефолт —
* быстрая дешёвая для проверки (пользователь укажет боевую).
*/
object LlmDebug {
fun run(apiKey: String, model: String) {
log("llm", "smoke: модель=$model, ключ=${if (apiKey.isBlank()) "ПУСТОЙ" else "задан"}")
if (apiKey.isBlank()) {
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
return
}
val client = LlmClient(apiKey = apiKey, model = model)
try {
val reply = runBlockingSafe {
client.chat(
messages = listOf(
ChatMessage(
id = 0, sessionId = 0, role = ChatRole.USER,
content = "Привет! Ты локальный ассистент для AR-очков. Ответь одним предложением.",
toolCallId = null, toolCalls = emptyList(), attachments = emptyList(),
createdAt = 0,
),
),
system = "Ты — лаконичный ассистент.",
)
}
log("llm", "ОТВЕТ: $reply")
} catch (e: Exception) {
log("llm", "ОШИБКА: ${e::class.simpleName}: ${e.message}")
}
}
private inline fun <T> runBlockingSafe(crossinline block: suspend () -> T): T =
kotlinx.coroutines.runBlocking { block() }
}
@@ -0,0 +1,50 @@
package pw.binom.viewmate.phone.agent
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** Роль сообщения в переписке с ассистентом. */
enum class ChatRole(val wire: String) {
USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");
companion object {
/** Обратный маппинг из wire-строки; неизвестное значение бросает исключение. */
fun fromWire(wire: String): ChatRole =
entries.firstOrNull { it.wire == wire }
?: throw IllegalArgumentException("Unknown chat role: $wire")
}
}
/** Tool-call ассистента: arguments — JSON-строка аргументов как пришла из API. */
@Serializable
data class ToolCall(val id: String, val name: String, val arguments: String)
/** Вложение в сообщении пользователя (например, фото с камеры). */
@Serializable
data class Attachment(val type: String = "image", val uri: String, val mime: String? = null)
/** Сессия переписки. */
data class Session(
val id: Long,
val title: String,
val createdAt: Long,
val updatedAt: Long,
)
/** Сообщение в сессии. */
data class ChatMessage(
val id: Long,
val sessionId: Long,
val role: ChatRole,
val content: String,
val toolCallId: String?,
val toolCalls: List<ToolCall>,
val attachments: List<Attachment>,
val createdAt: Long,
)
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
internal val agentJson: Json = Json { ignoreUnknownKeys = true }