diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index fa40216..192280c 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -5,6 +7,28 @@ plugins { 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 { namespace = "pw.binom.viewmate.phone" compileSdk = 35 @@ -15,6 +39,12 @@ android { targetSdk = 34 versionCode = 1 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 { @@ -36,6 +66,14 @@ android { buildFeatures { 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(project(":lib-core")) + // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) + implementation(libs.koog.openai.client) + implementation(libs.koog.http.client.okhttp) + // Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack 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-serialization-kotlinx-json:3.3.0") testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") } diff --git a/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/ChatDaoTest.kt b/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/ChatDaoTest.kt new file mode 100644 index 0000000..a46b24e --- /dev/null +++ b/app-phone/src/androidTest/kotlin/pw/binom/viewmate/phone/agent/ChatDaoTest.kt @@ -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) + } +} diff --git a/app-phone/src/main/kotlin/ai/koog/utils/io/Coroutines_jvmKt.kt b/app-phone/src/main/kotlin/ai/koog/utils/io/Coroutines_jvmKt.kt new file mode 100644 index 0000000..bde9968 --- /dev/null +++ b/app-phone/src/main/kotlin/ai/koog/utils/io/Coroutines_jvmKt.kt @@ -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 +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index e4d051a..c47e91a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -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. */ diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index a6821f4..0adbeff 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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() diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt new file mode 100644 index 0000000..ed5522d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt @@ -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 { + 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(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 = emptyList(), + attachments: List = 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 { + val cursor = db.readableDatabase.rawQuery( + "SELECT * FROM messages WHERE session_id = ? ORDER BY id", + arrayOf(sessionId.toString()), + ) + return cursor.use { c -> + val messages = ArrayList(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): String? = + if (toolCalls.isEmpty()) null else agentJson.encodeToString(toolCalls) + + private fun encodeAttachments(attachments: List): String? = + if (attachments.isEmpty()) null else agentJson.encodeToString(attachments) + + private fun decodeToolCalls(raw: String?): List = + raw?.let { runCatching { agentJson.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + + private fun decodeAttachments(raw: String?): List = + raw?.let { runCatching { agentJson.decodeFromString>(it) }.getOrNull() } + ?: emptyList() +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDb.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDb.kt new file mode 100644 index 0000000..66843d3 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDb.kt @@ -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)" + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt new file mode 100644 index 0000000..e0e3c9f --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt @@ -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, 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() + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt new file mode 100644 index 0000000..f76b837 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt @@ -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 runBlockingSafe(crossinline block: suspend () -> T): T = + kotlinx.coroutines.runBlocking { block() } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt new file mode 100644 index 0000000..a7e7672 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt @@ -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, + val attachments: List, + val createdAt: Long, +) + +/** Json для сериализации toolCalls/attachments в строковые колонки БД. */ +internal val agentJson: Json = Json { ignoreUnknownKeys = true } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ModelsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ModelsTest.kt new file mode 100644 index 0000000..fe13ddb --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ModelsTest.kt @@ -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 { ChatRole.fromWire("nope") } + } + + @Test + fun toolCallJsonRoundTrip() { + val call = ToolCall("c1", "play_movie", "{\"item\":\"m1\",\"audio\":0}") + val decoded = agentJson.decodeFromString(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(agentJson.encodeToString(attachment)) + assertEquals(attachment, decoded) + } + + @Test + fun attachmentDefaultsTypeToImage() { + val decoded = agentJson.decodeFromString("""{"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>(json)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5435f8d..1cea26f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ androidx-compose-bom = "2025.05.00" androidx-activity-compose = "1.10.1" exoplayer = "2.17.1" media3 = "1.6.1" +koog = "1.1.1" [libraries] 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" } 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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }