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:
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -5,6 +7,28 @@ plugins {
|
|||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
|
||||||
|
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
|
||||||
|
val llmApiKey: String = run {
|
||||||
|
val props = Properties()
|
||||||
|
val local = rootProject.file("local.properties")
|
||||||
|
if (local.exists()) local.inputStream().use(props::load)
|
||||||
|
props.getProperty("llm.apiKey", "")
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модель для ассистента — из local.properties (llm.model), вне git.
|
||||||
|
// Дефолт — быстрая дешёвая (пользователь укажет боевую).
|
||||||
|
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")
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "pw.binom.viewmate.phone"
|
namespace = "pw.binom.viewmate.phone"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -15,6 +39,12 @@ android {
|
|||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан
|
||||||
|
buildConfigField("String", "LLM_API_KEY", "\"$llmApiKey\"")
|
||||||
|
// Модель ассистента — из local.properties (llm.model), вне git
|
||||||
|
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -36,6 +66,14 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
// okhttp-sse 5.x и jspecify оба кладут этот OSGI-manifest
|
||||||
|
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -56,6 +94,10 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(project(":lib-core"))
|
implementation(project(":lib-core"))
|
||||||
|
|
||||||
|
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
|
implementation(libs.koog.openai.client)
|
||||||
|
implementation(libs.koog.http.client.okhttp)
|
||||||
|
|
||||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||||
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
||||||
|
|
||||||
@@ -71,4 +113,7 @@ dependencies {
|
|||||||
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
||||||
testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
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 ChatDaoTest {
|
||||||
|
|
||||||
|
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 sessionLifecycleWithMessages() {
|
||||||
|
val sessionId = dao.createSession()
|
||||||
|
assertTrue(sessionId > 0)
|
||||||
|
|
||||||
|
val userMsgId = dao.addMessage(sessionId, ChatRole.USER, "включи Матрицу")
|
||||||
|
val assistantMsgId = dao.addMessage(
|
||||||
|
sessionId,
|
||||||
|
ChatRole.ASSISTANT,
|
||||||
|
"",
|
||||||
|
toolCalls = listOf(ToolCall("c1", "play_movie", "{}")),
|
||||||
|
)
|
||||||
|
val toolMsgId = dao.addMessage(sessionId, ChatRole.TOOL, "", toolCallId = "c1")
|
||||||
|
|
||||||
|
// порядок по id и совпадение ролей/полей
|
||||||
|
val messages = dao.messagesForSession(sessionId)
|
||||||
|
assertEquals(listOf(userMsgId, assistantMsgId, toolMsgId), messages.map { it.id })
|
||||||
|
assertEquals(3, messages.size)
|
||||||
|
|
||||||
|
assertEquals(ChatRole.USER, messages[0].role)
|
||||||
|
assertEquals("включи Матрицу", messages[0].content)
|
||||||
|
assertTrue(messages[0].toolCalls.isEmpty())
|
||||||
|
|
||||||
|
assertEquals(ChatRole.ASSISTANT, messages[1].role)
|
||||||
|
assertEquals(listOf(ToolCall("c1", "play_movie", "{}")), messages[1].toolCalls)
|
||||||
|
|
||||||
|
assertEquals(ChatRole.TOOL, messages[2].role)
|
||||||
|
assertEquals("c1", messages[2].toolCallId)
|
||||||
|
|
||||||
|
// список сессий: созданная есть
|
||||||
|
assertEquals(sessionId, dao.listSessions().single().id)
|
||||||
|
|
||||||
|
// каскадное удаление сообщений вместе с сессией
|
||||||
|
dao.deleteSession(sessionId)
|
||||||
|
assertTrue(dao.messagesForSession(sessionId).isEmpty())
|
||||||
|
assertTrue(dao.listSessions().none { it.id == sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addMessageTouchesSessionUpdatedAt() {
|
||||||
|
val sessionId = dao.createSession()
|
||||||
|
val before = dao.listSessions().single().updatedAt
|
||||||
|
|
||||||
|
dao.addMessage(sessionId, ChatRole.USER, "привет")
|
||||||
|
|
||||||
|
val after = dao.listSessions().single().updatedAt
|
||||||
|
assertTrue("updated_at должен обновиться после addMessage", after >= before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renameSessionChangesTitle() {
|
||||||
|
val sessionId = dao.createSession("первая")
|
||||||
|
dao.renameSession(sessionId, "Матрица")
|
||||||
|
|
||||||
|
val session = dao.listSessions().single()
|
||||||
|
assertEquals("Матрица", session.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package ai.koog.utils.io
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВРЕМЕННАЯ заглушка дефекта публикации Koog 1.1.1: android-артефакт
|
||||||
|
* utils-android НЕ включает jvm-класс Coroutines_jvmKt, хотя его зовёт
|
||||||
|
* OkHttpKoogHttpClient (ai.koog:http-client-okhttp — jvm-артефакт).
|
||||||
|
* Сигнатура сверена с utils-jvm-1.1.1.jar (javap): getSuitableForIO(Dispatchers).
|
||||||
|
* Убрать, когда JetBrains починит публикацию (следить за Koog > 1.1.1).
|
||||||
|
*/
|
||||||
|
object Coroutines_jvmKt {
|
||||||
|
@JvmStatic
|
||||||
|
fun getSuitableForIO(@Suppress("UNUSED_PARAMETER") dispatchers: Dispatchers): CoroutineDispatcher =
|
||||||
|
Dispatchers.IO
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.widget.TextView
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import pw.binom.viewmate.phone.stt.SttDebug
|
import pw.binom.viewmate.phone.stt.SttDebug
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -20,6 +21,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (intent?.getBooleanExtra("stt_debug", false) == true) {
|
if (intent?.getBooleanExtra("stt_debug", false) == true) {
|
||||||
runSttDebug()
|
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. */
|
/** Отладочный прогон распознавания: берёт первый 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.phone.PhoneState
|
||||||
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.ChatDao
|
||||||
|
import pw.binom.viewmate.phone.agent.ChatDb
|
||||||
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
|
||||||
|
|
||||||
@@ -51,6 +53,9 @@ class PhoneApp : Application() {
|
|||||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||||
|
|
||||||
|
/** История общения с ассистентом (SQLite, "assistant.db"). */
|
||||||
|
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
|
||||||
|
|
||||||
/** Автоматика паузы/продолжения звука при обрыве связи. */
|
/** Автоматика паузы/продолжения звука при обрыве связи. */
|
||||||
private val connectionAutoResume = ConnectionAutoResume()
|
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 }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
class ModelsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatRoleWireRoundTrip() {
|
||||||
|
ChatRole.entries.forEach { role ->
|
||||||
|
assertEquals(role, ChatRole.fromWire(role.wire))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatRoleFromWireUnknownThrows() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { ChatRole.fromWire("nope") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolCallJsonRoundTrip() {
|
||||||
|
val call = ToolCall("c1", "play_movie", "{\"item\":\"m1\",\"audio\":0}")
|
||||||
|
val decoded = agentJson.decodeFromString<ToolCall>(agentJson.encodeToString(call))
|
||||||
|
assertEquals(call, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun attachmentJsonRoundTrip() {
|
||||||
|
val attachment = Attachment(type = "image", uri = "file:///sdcard/a.jpg", mime = "image/jpeg")
|
||||||
|
val decoded = agentJson.decodeFromString<Attachment>(agentJson.encodeToString(attachment))
|
||||||
|
assertEquals(attachment, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun attachmentDefaultsTypeToImage() {
|
||||||
|
val decoded = agentJson.decodeFromString<Attachment>("""{"uri":"file:///x.png"}""")
|
||||||
|
assertEquals("image", decoded.type)
|
||||||
|
assertEquals("file:///x.png", decoded.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolCallListToJsonStringAndBack() {
|
||||||
|
val calls = listOf(
|
||||||
|
ToolCall("c1", "play_movie", "{}"),
|
||||||
|
ToolCall("c2", "search", "{\"q\":\"matrix\"}"),
|
||||||
|
)
|
||||||
|
val json = agentJson.encodeToString(calls)
|
||||||
|
assertNotNull(json)
|
||||||
|
assertEquals(calls, agentJson.decodeFromString<List<ToolCall>>(json))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ androidx-compose-bom = "2025.05.00"
|
|||||||
androidx-activity-compose = "1.10.1"
|
androidx-activity-compose = "1.10.1"
|
||||||
exoplayer = "2.17.1"
|
exoplayer = "2.17.1"
|
||||||
media3 = "1.6.1"
|
media3 = "1.6.1"
|
||||||
|
koog = "1.1.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
@@ -22,6 +23,12 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
|
|||||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
|
||||||
|
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
|
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
||||||
|
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
|
||||||
|
koog-openai-client = { module = "ai.koog:prompt-executor-openai-client", version.ref = "koog" }
|
||||||
|
# KoogHttpClient.Factory провайдер на OkHttp (OkHttp уже есть в app-phone)
|
||||||
|
koog-http-client-okhttp = { module = "ai.koog:http-client-okhttp", version.ref = "koog" }
|
||||||
# Ktor client (HTTP)
|
# Ktor client (HTTP)
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
|||||||
Reference in New Issue
Block a user