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 5259410..e25aff0 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 @@ -246,8 +246,11 @@ class PhoneApp : Application() { ).also { toolkit = it } return Assistant( chatDao = chatDao, - chat = { history, system -> llm.chat(history, system) }, + chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) }, toolkit = tk, + // Нативный тул-канал LiteRT: только локальная модель видит схемы + // тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий. + nativeTools = choice == LlmModelChoice.LOCAL, // Локальной модели нужно время: инициализация движка ~17с на // CPU + генерация. Серверная укладывается в 30с, локальной // даём 5 минут (иначе withTimeoutOrNull режет генерацию). diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt index 40d4c5a..48d627e 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt @@ -54,15 +54,24 @@ class AgentToolkit( * Системный промпт для сессии: ядро + каталог + активные тулсеты (с * описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны). * Просроченные тулсеты чистятся здесь же — фоновых таймеров нет. + * + * [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы + * модель видит через JSON-схемы в движке, строку текстового протокола + * TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы). */ - fun systemPrompt(sessionId: Long): String { + fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String { val note = pendingNotes.remove(sessionId) val activeSets = toolsets.activeNames(sessionId) val sb = StringBuilder() sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ") .append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.") - sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ") - .append("После получения результата тула ответь пользователю текстом.") + if (nativeTools) { + sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ") + .append("После получения результата тула ответь пользователю текстом.") + } else { + sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ") + .append("После получения результата тула ответь пользователю текстом.") + } sb.append("\n\nЯдро:") CORE_LINES.forEach { sb.append('\n').append(it) } val catalog = toolsets.catalogLines() @@ -85,6 +94,27 @@ class AgentToolkit( return sb.toString() } + /** + * Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал): + * ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`). + * Неактивные тулсеты сюда не попадают — модель увидит их только после + * enable_toolset (в следующем callLlm схема пересобирается). + */ + fun toolSpecs(sessionId: Long): List { + val out = mutableListOf( + ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"), + ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")), + ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")), + ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")), + ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")), + ) + for (setName in toolsets.activeNames(sessionId)) { + val ts = toolsets.toolset(setName) ?: continue + ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) } + } + return out + } + // ---------- парсинг аргументов ---------- /** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */ @@ -98,31 +128,11 @@ class AgentToolkit( /** * Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для - * одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`, - * лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта. + * одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs] + * (общая с RemoteLlmClient), метод оставлен для API/тестов. */ - fun parseArgs(raw: String?, expected: List): Map { - val s = raw?.trim() - if (s.isNullOrEmpty()) return emptyMap() - val required = expected.filterNot { it.endsWith("?") }.distinct() - val out = LinkedHashMap() - var bareIndex = 0 - for (field in s.split(ALTER)) { - val f = field.trim() - if (f.isEmpty()) continue - val eq = f.indexOf('=') - if (eq > 0) { - val key = f.substring(0, eq).trim() - if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim() - } else if (bareIndex < required.size) { - // «Голое» значение (media_search|Название) идёт первому обязательному аргументу. - out[required[bareIndex++]] = f - } - } - return out - } - - private val ALTER = Regex("[,&]") + fun parseArgs(raw: String?, expected: List): Map = + parseToolCallArgs(raw, expected) // ---------- диспач вызовов ---------- diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt index 103b6bd..905be3d 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt @@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull * (сериализация фраз — LLM-вызовы не пересекаются). * * Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если - * передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной - * строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально). + * передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе + * [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом, + * удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент + * получает единый [LlmResult.toolCalls] и не знает, откуда приехали. * Ассистент выполняет тул и возвращает результат как tool-сообщение в историю, * затем делает повторный вызов LLM. Невидимые системные тулы * (enable_toolset / disable_toolset) в историю НЕ пишутся — просто - * пересобирается системный промпт (состояние 3). Обычный текст (без маркера) + * пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов) * возвращается пользователю как финальный ответ. */ class Assistant( private val chatDao: ChatDao, - private val chat: suspend (history: List, system: String?) -> String, + private val chat: suspend (history: List, tools: List, system: String?) -> LlmResult, private val toolkit: AgentToolkit? = null, private val tools: List = AgentTools.ALL, + private val nativeTools: Boolean = false, private val timeoutMs: Long = 30_000, ) { /** @@ -50,7 +53,7 @@ class Assistant( private suspend fun processInSession(sessionId: Long): String { val history = chatDao.messagesForSession(sessionId).takeLast(20) - var answer = callLlm(history, sessionId) + var result = callLlm(history, sessionId) var lastToolResult: String? = null var visibleCalls = 0 var llmCalls = 1 @@ -58,31 +61,31 @@ class Assistant( // зацикливания агента). Невидимые (silent) вызовы лимит не съедают, // но общее число LLM-вызовов ограничено [MAX_LLM_CALLS]. while (true) { - val parsed = parseToolCall(answer) - ?: return answer // callLlm уже сохранил ASSISTANT-сообщение - val toolName = parsed.name + val call = result.toolCalls.firstOrNull() + ?: return result.text // callLlm уже сохранил ASSISTANT-сообщение + val toolName = call.name if (toolkit == null) { // Наследственный путь (без механики тулсетов): простые тулы без аргументов. val tool = tools.firstOrNull { it.name == toolName } if (tool == null) { - chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text) return "Тул $toolName не найден" } lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName) logTool(sessionId, toolName, lastToolResult) if (++visibleCalls >= MAX_TOOL_CALLS) break - answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) llmCalls++ continue } - when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) { + when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) { is AgentToolkit.Dispatch.Run -> { - lastToolResult = result.result - chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName) - logTool(sessionId, toolName, result.result) + lastToolResult = dispatch.result + chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName) + logTool(sessionId, toolName, dispatch.result) if (++visibleCalls >= MAX_TOOL_CALLS) break - answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) llmCalls++ } @@ -92,29 +95,34 @@ class Assistant( // (переход в состояние 3 — «Активные тулсеты»). logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)") if (llmCalls++ >= MAX_LLM_CALLS) break - answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) } } } - // LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ, - // чтобы пользователь получил данные, а не сырой маркер. - val stillTool = parseToolCall(answer) + // LLM зациклилась на тул-вызовах — отдаём последний результат тула как + // ответ, чтобы пользователь получил данные, а не сырой маркер. + val stillTool = result.toolCalls.firstOrNull() return if (stillTool != null && lastToolResult != null) { "TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult" } else { - answer + result.text } } - private suspend fun callLlm(history: List, sessionId: Long): String { - val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt() - val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) } - ?: return "Ассистент не ответил (таймаут)" - if (!isToolCall(answer)) { - chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + private suspend fun callLlm(history: List, sessionId: Long): LlmResult { + val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt() + val specs = if (toolkit != null) { + toolkit.toolSpecs(sessionId) + } else { + tools.map { ToolSpec(name = it.name, description = it.description) } + } + val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) } + ?: return LlmResult("Ассистент не ответил (таймаут)") + if (result.toolCalls.isEmpty()) { + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text) chatDao.touchSession(sessionId) } - return answer + return result } /** Системный промпт (наследственный путь, без [AgentToolkit]). */ @@ -131,23 +139,6 @@ class Assistant( /** Общий лимит LLM-вызовов на фразу (включая невидимые). */ const val MAX_LLM_CALLS = 8 - /** - * Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2` - * (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов). - */ - private val TOOL_CALL_RE = - Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE) - - data class ParsedToolCall(val name: String, val argsRaw: String?) - - private fun parseToolCall(answer: String): ParsedToolCall? { - val m = TOOL_CALL_RE.find(answer) ?: return null - val argsRaw = m.groupValues[2].trim().ifBlank { null } - return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw) - } - - private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null - /** Случайное имя сессии (UUID-подобное, 8 символов). */ private fun randomTitle(): String = "сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("") 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 index b576f21..3768df7 100644 --- 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 @@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import pw.binom.viewmate.phone.log import java.util.concurrent.TimeUnit /** @@ -26,6 +27,106 @@ interface LlmClient { * [system] — системный промпт тулкита/настроек или null (не обязателен). */ suspend fun chat(messages: List, system: String? = null): String + + /** + * Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]). + * Локальный клиент регистрирует их в движке (нативный LiteRT-канал), + * удалённый игнорирует список (тулы описывает текстовый протокол в промпте). + * Результат [LlmResult] несёт И текст, И структурированные вызовы + * [LlmToolCall] — клиент нормализует источник (нативный / текстовый + * маркер), [Assistant] потребляет оба бэкенда одинаково. + */ + suspend fun chatWithTools( + messages: List, + tools: List = emptyList(), + system: String? = null, + ): LlmResult = LlmResult(text = chat(messages, system)) +} + +/** + * Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и + * аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» — + * «?» = опциональный). + */ +data class ToolSpec( + val name: String, + val description: String, + val args: List = emptyList(), +) { + /** Обязательные аргументы (без маркера «?»). */ + fun requiredArgs(): List = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') } + + /** Все имена аргументов (обязательные и опциональные, без маркера «?»). */ + fun argNames(): List = args.map { it.substringBefore('?') } +} + +/** Вызов тула, декларированный моделью: имя + распарсенные именованные аргументы. */ +data class LlmToolCall( + val name: String, + val arguments: Map = emptyMap(), +) + +/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */ +data class LlmResult( + val text: String, + val toolCalls: List = emptyList(), +) + +// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ---------- + +/** + * Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2` + * (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов). + */ +val TOOL_CALL_RE = + Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE) + +/** + * Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для + * одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`, + * лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта. + * [expected] — имена аргументов схемы ([ToolSpec.argNames]). + */ +fun parseToolCallArgs(raw: String?, expected: List): Map { + val s = raw?.trim() + if (s.isNullOrEmpty()) return emptyMap() + val required = expected.filterNot { it.endsWith("?") }.distinct() + val out = LinkedHashMap() + var bareIndex = 0 + for (field in s.split(Regex("[,&]"))) { + val f = field.trim() + if (f.isEmpty()) continue + val eq = f.indexOf('=') + if (eq > 0) { + val key = f.substring(0, eq).trim() + if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim() + } else if (bareIndex < required.size) { + // «Голое» значение (media_search|Название) идёт первому обязательному аргументу. + out[required[bareIndex++]] = f + } + } + return out +} + +/** + * Лог длинного текста (тела запроса, промпта, ответа) в канал [log] + * кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы. + */ +internal fun logChunked(tag: String, label: String, text: String) { + if (text.length <= 3500) { + log(tag, "$label: $text") + return + } + val size = 3500 + val total = (text.length + size - 1) / size + var from = 0 + var i = 0 + while (from < text.length && i < total) { + val to = minOf(from + size, text.length) + log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}") + from = to + i++ + } } /** @@ -101,6 +202,8 @@ class RemoteLlmClient( if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.") val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto)) + log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}") + logChunked("llm", "remote: тело запроса", body) val request = Request.Builder() .url("$baseUrl/v1/chat/completions") .header("Authorization", "Bearer $apiKey") @@ -115,11 +218,34 @@ class RemoteLlmClient( val err = runCatching { json.decodeFromString(raw).error?.message }.getOrNull() throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}") } - val text = runCatching { + val reply = runCatching { json.decodeFromString(raw).choices.firstOrNull()?.message?.content }.getOrNull() - text?.trim() ?: "" + val text = reply?.trim() ?: "" + logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" }) + text } } } + + /** + * Текстовый протокол: тулы описываются в системном промпте, модель отвечает + * строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и + * нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое» + * значение прилетает первому обязательному аргументу). + */ + override suspend fun chatWithTools( + messages: List, + tools: List, + system: String?, + ): LlmResult { + val text = chat(messages, system) + val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text) + val name = m.groupValues[1].lowercase() + val argsRaw = m.groupValues[2].trim().ifBlank { null } + val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList() + val args = parseToolCallArgs(argsRaw, expected) + log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args") + return LlmResult(text, listOf(LlmToolCall(name, args))) + } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt index 3415996..8625aaf 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt @@ -2,15 +2,25 @@ package pw.binom.viewmate.phone.agent import pw.binom.litert.LiteBackend import pw.binom.litert.LiteConfig +import pw.binom.litert.LiteConversation +import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteExperimental import pw.binom.litert.LiteLlm import pw.binom.litert.LiteMessage -import pw.binom.litert.LiteRequest +import pw.binom.litert.LiteTool +import pw.binom.litert.LiteToolCall import pw.binom.litert.google.googleLiteLlm import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject import okhttp3.OkHttpClient import okhttp3.Request import java.io.File @@ -172,6 +182,55 @@ fun buildLocalLlmPrompt(messages: List, system: String? = null): Lo ) } +/** + * OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект + * `{"name","description","parameters"}` — так его читает движок + * (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на + * верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}` + * падает ToolException «Failed to parse field name»). Движок передаёт + * строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя + * pw.binom.litert, tag 5), поэтому JSON строим сами. + * Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string». + */ + fun toolJson(spec: ToolSpec): String = buildJsonObject { + put("name", spec.name) + put("description", spec.description) + putJsonObject("parameters") { + put("type", "object") + if (spec.argNames().isNotEmpty()) { + putJsonObject("properties") { + spec.args.forEach { a -> + putJsonObject(a.substringBefore('?')) { + put("type", "string") + } + } + } + val required = spec.requiredArgs() + if (required.isNotEmpty()) { + putJsonArray("required") { + required.forEach { add(JsonPrimitive(it)) } + } + } + } + } + }.toString() + +/** + * Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное + * OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме + * (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не + * исполняет тулы — если движок всё же позвал `invoke`, это баг движка, + * падаем с понятным сообщением. + */ +private class SpecLiteTool(spec: ToolSpec) : LiteTool { + private val descriptor: String = toolJson(spec) + + override fun describe(): String = descriptor + + override fun invoke(arguments: String): String = + error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы") +} + // ---------- Скачивание модели ---------- /** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */ @@ -289,9 +348,9 @@ class GemmaModelDownloader( * * Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]), * переиспользуется — модель заново не грузится. Каждый чатовый вызов — - * новый RAW-инференс: [LiteRequest] несёт полный промпт (системная - * инструкция + вся история + входящее), движок не хранит состояние между - * вызовами. Ошибки — [IllegalStateException] с читаемым русским + * новый RAW-инференс: [LiteConversationConfig] несёт полный промпт + * (системная инструкция + вся история + входящее) и схемы тул, + * движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским * сообщением («локальная модель не скачана…») — [Assistant] покажет его * пользователю, падение приложения исключено. * @@ -310,43 +369,87 @@ class LocalLlmClient( /** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */ fun modelDownloaded(): Boolean = modelFile.isFile - override suspend fun chat(messages: List, system: String?): String { - log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)") + override suspend fun chat(messages: List, system: String?): String = + chatWithTools(messages, emptyList(), system).text + + /** + * Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом + * (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]). + * Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) — + * цикл «вызов → dispatch → результат в историю → повторный инференс» + * ведёт [Assistant]; каждый круг — новый RAW-инференс. + */ + override suspend fun chatWithTools( + messages: List, + tools: List, + system: String?, + ): LlmResult { + log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)") val llm = ensureLlm() log("llm", "chat: движок готов, строю промпт") val prompt = buildLocalLlmPrompt(messages, system) log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}") - val request = LiteRequest( + // Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул + logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)") + prompt.initialMessages.forEachIndexed { i, m -> + logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text) + } + logChunked("llm", "local: incoming", prompt.incoming.text) + tools.forEachIndexed { i, spec -> + log("llm", "local: tool[$i/${tools.size}] ${spec.name}: ${toolJson(spec)}") + } + val config = LiteConversationConfig( systemInstruction = prompt.systemInstruction, // RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт - messages = prompt.initialMessages + prompt.incoming, + initialMessages = prompt.initialMessages + prompt.incoming, + tools = tools.map { SpecLiteTool(it) }, + // Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода) + automaticToolCalling = false, ) var text = "" + var toolCalls = emptyList() var chunks = 0 var tokens = 0 val t0 = System.currentTimeMillis() var firstTokenAt = 0L + var conversation: LiteConversation? = null try { withTimeout(120_000) { - // либа сама закрывает беседу по завершении/отмене потока - llm.inferStream(request).collect { delta -> - chunks++ - val chunkText = delta.text - text += chunkText - if (chunkText.isNotEmpty()) { - if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() - tokens += 1 + conversation = llm.createConversation(config) + // Поток либы сам себя не завершает (callbackFlow живёт до отмены): + // остановка — по терминальной дельте isDone (см. KDoc LiteDelta). + conversation.sendStream(prompt.incoming.text) + .onEach { delta -> + chunks++ + val chunkText = delta.text + text += chunkText + if (chunkText.isNotEmpty()) { + if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() + tokens += 1 + } + if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls + if (chunks <= 3 || chunks % 20 == 0) { + log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}") + } } - if (chunks <= 3 || chunks % 20 == 0) { - log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}") - } - } + .takeWhile { !it.isDone } + .collect { } } val totalMs = System.currentTimeMillis() - t0 val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0 - log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)") - return text.trim() + log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)") + val normalized = toolCalls.map { call -> + LlmToolCall( + name = call.name, + arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" }, + ) + } + logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" }) + normalized.forEach { call -> + log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}") + } + return LlmResult(text.trim(), normalized) } catch (e: CancellationException) { log("llm", "chat: отменено") throw e @@ -354,6 +457,10 @@ class LocalLlmClient( log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}") log("llm", "chat: EXC msg=${sanitize(e.message)}") throw IllegalStateException("Локальная модель не ответила: ${e.message}", e) + } finally { + // либа (RAW-путь) закрывает беседу сама, здесь — явный close для + // прямого createConversation: каждый вызов — своя беседа + runCatching { conversation?.close() } } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/OpenAiHttpServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/OpenAiHttpServerTest.kt new file mode 100644 index 0000000..565099b --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/OpenAiHttpServerTest.kt @@ -0,0 +1,164 @@ +package pw.binom.viewmate.phone + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import pw.binom.viewmate.phone.agent.ChatMessage +import pw.binom.viewmate.phone.agent.ChatRole +import pw.binom.viewmate.phone.agent.LlmClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */ +class OpenAiHttpServerTest { + + private val json = Json + + private class FakeLlm( + private val text: String = "Привет, мир!", + private val failure: String? = null, + ) : LlmClient { + var calls = 0 + var lastSystem: String? = null + var lastMessages: List = emptyList() + + override suspend fun chat(messages: List, system: String?): String { + calls++ + lastSystem = system + lastMessages = messages + failure?.let { throw IllegalStateException(it) } + return text + } + } + + private val chatBody = + """{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}""" + + private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome = + runBlocking { OpenAiHttpServer.handleChat(llm, body) } + + @Test + fun nonStream_returnsCompletionJson() { + val llm = FakeLlm() + val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(200, outcome.code) + val body = outcome.body + assertTrue(body.contains("\"object\":\"chat.completion\"")) + assertTrue(body.contains("\"content\":\"Привет, мир!\"")) + assertTrue(body.contains("\"finish_reason\":\"stop\"")) + assertTrue(body.contains("\"id\":\"chatcmpl-")) + assertEquals(1, llm.calls) + assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages) + assertEquals(null, llm.lastSystem) + } + + @Test + fun systemAndDeveloperRoles_goToSystem() { + val llm = FakeLlm() + call( + """{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""", + llm, + ) + assertEquals("S1\nS2", llm.lastSystem) + assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role }) + } + + @Test + fun toolRole_mapsToToolMessage() { + val llm = FakeLlm() + call( + """{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""", + llm, + ) + val tool = llm.lastMessages.last() + assertEquals(ChatRole.TOOL, tool.role) + assertEquals("t1", tool.toolCallId) + } + + @Test + fun contentAsArrayOfTextParts_joined() { + val llm = FakeLlm() + call( + """{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""", + llm, + ) + assertEquals("часть1\nчасть2", llm.lastMessages.last().content) + } + + @Test + fun assistantNullContentWithToolCalls_decodes() { + val llm = FakeLlm() + call( + """{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""", + llm, + ) + val roles = llm.lastMessages.map { it.role } + assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles) + } + + @Test + fun stream_emitsSseChunksAndDone() { + val llm = FakeLlm() + val outcome = call( + """{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""", + llm, + ) as OpenAiHttpServer.ChatOutcome.Sse + val data = outcome.frames + assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" }) + val chunks = data.filter { !it.contains("[DONE]") }.map { + json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim()) + } + val choices = chunks.map { chunk -> + chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject + } + val deltas = choices.map { it["delta"]!!.jsonObject } + assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content) + val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("") + assertEquals("Привет, мир!", full) + assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content) + assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" }) + } + + @Test + fun invalidBody_400() { + val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(400, outcome.code) + assertTrue(outcome.body.contains("\"error\"")) + } + + @Test + fun emptyMessages_400() { + val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(400, outcome.code) + } + + @Test + fun unknownRole_400() { + val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(400, outcome.code) + assertTrue(outcome.body.contains("\"error\"")) + } + + @Test + fun noLlm_503() { + val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(503, outcome.code) + assertTrue(outcome.body.contains("\"error\"")) + } + + @Test + fun llmFailure_502() { + val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json + assertEquals(502, outcome.code) + assertTrue(outcome.body.contains("boom")) + } + + @Test + fun allIps_containsLoopback() { + assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1")) + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmClientTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmClientTest.kt new file mode 100644 index 0000000..b69e5d5 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmClientTest.kt @@ -0,0 +1,125 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Тесты тул-канала на границе клиента: парсер текстового маркера + * (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал). + */ +class LlmClientTest { + + // ---------- parseToolCallArgs (текстовый протокол) ---------- + + @Test + fun parseArgsEmptyWhenNoArgs() { + assertEquals(emptyMap(), parseToolCallArgs(null, emptyList())) + assertEquals(emptyMap(), parseToolCallArgs("", emptyList())) + } + + @Test + fun parseArgsNamedAndBare() { + val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?")) + assertEquals("old songs", args["query"]) + assertEquals("30000", args["seek_ms"]) + } + + @Test + fun parseArgsBareValueGoesToFirstExpected() { + val args = parseToolCallArgs("old songs", listOf("query")) + assertEquals("old songs", args["query"]) + } + + @Test + fun parseArgsDuplicateNameLastWins() { + val args = parseToolCallArgs("id=1,id=2", listOf("id")) + assertEquals("2", args["id"]) + } + + @Test + fun parseArgsUnknownNamePassedThrough() { + val args = parseToolCallArgs("id=1,foo=bar", listOf("id")) + assertEquals("1", args["id"]) + assertTrue(args.containsKey("foo")) + } + + @Test + fun parseArgsQuotedValueKeepsQuotes() { + // Парсер прощающий: кавычки — литеральные символы, пары режутся запятой + val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b")) + assertEquals("\"hello world\"", args["a"]) + assertEquals("1", args["b"]) + } + + @Test + fun parseArgsUnknownFormatGoesToFirstRequired() { + // Неизвестный формат (без `=`) не падает: всё сырое значение + // уходит первому обязательному аргументу + val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id")) + assertEquals("""{"id":"c42"}""", args["id"]) + } + + // ---------- TOOL_CALL-маркер (текстовый протокол, remote) ---------- + + @Test + fun markerFoundWithArgs() { + val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000") + assertNotNull(m) + assertEquals("media_search", m.groupValues[1]) + assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim()) + } + + @Test + fun markerFoundWithoutArgs() { + val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time") + assertNotNull(m) + assertEquals("get_current_time", m.groupValues[1]) + assertTrue(m.groupValues[2].isEmpty()) + } + + @Test + fun markerNotFoundInPlainText() { + assertNull(TOOL_CALL_RE.find("Какое сейчас время?")) + } + + // ---------- ToolSpec ---------- + + @Test + fun specArgNamesAndRequired() { + val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?")) + assertEquals(listOf("query", "seek_ms"), spec.argNames()) + assertEquals(listOf("query"), spec.requiredArgs()) + } + + // ---------- toolJson (нативный LiteRT-канал) ---------- + + @Test + fun toolJsonHasFunctionFields() { + val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?"))) + val obj = Json.parseToJsonElement(json) as JsonObject + assertEquals("media_search", (obj["name"] as JsonPrimitive).content) + val params = obj["parameters"] as JsonObject + val props = params["properties"] as JsonObject + assertTrue(props.containsKey("query")) + assertTrue(props.containsKey("seek_ms")) + val required = params["required"] as JsonArray + assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content }) + } + + @Test + fun toolJsonWithoutArgsHasNoProperties() { + val json = toolJson(ToolSpec("get_current_time", "текущее время")) + val obj = Json.parseToJsonElement(json) as JsonObject + val params = obj["parameters"] as JsonObject + assertFalse(params.containsKey("properties")) + assertFalse(params.containsKey("required")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93e3632..b286c58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1" exoplayer = "2.17.1" media3 = "1.6.1" koog = "1.1.1" -litert-google = "3" +litert-google = "5" [libraries] kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -25,9 +25,10 @@ exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = " media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } # LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне. -# КMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp): +# KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp): # универсальный контракт (LiteLlm/LiteRequest — RAW-инференс, владелец истории — -# потребитель) + google-бэкенд над официальным litertlm:0.16.1 (транзитивно). +# потребитель) + нативный тул-слой (LiteTool/LiteToolCall, tag 5) + google-бэкенд +# над официальным litertlm:0.16.1 (транзитивно). litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" } # Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются # приложением напрямую; в litert-google он runtime-only, поэтому явный.