diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index de62d16..c0116a5 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -101,8 +101,11 @@ dependencies { implementation(libs.koog.openai.client) implementation(libs.koog.http.client.okhttp) - // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU) - implementation(libs.litertlm.android) + // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU): + // KMP-обёртка pw.binom.litert (универсальный контракт RAW-инференса + google-бэкенд) + implementation(libs.litert.google) + // универсальный контракт (LiteLlm/LiteRequest…) — импортируется напрямую + implementation(libs.litert.api) // Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6") diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt index e55d888..8c5c9f9 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt @@ -1,6 +1,5 @@ package pw.binom.viewmate.phone -import com.google.ai.edge.litertlm.Backend import pw.binom.viewmate.phone.agent.LlmModelChoice import pw.binom.viewmate.phone.agent.LocalLlmClient import java.io.BufferedReader diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/OpenAiHttpServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/OpenAiHttpServer.kt new file mode 100644 index 0000000..fc6111d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/OpenAiHttpServer.kt @@ -0,0 +1,310 @@ +package pw.binom.viewmate.phone + +import ai.koog.prompt.executor.clients.openai.base.models.OpenAIMessage +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.receiveText +import io.ktor.server.response.respondText +import io.ktor.server.response.respondTextWriter +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +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 java.util.UUID + +/** + * OpenAI-совместимый HTTP-сервер (Ktor-CIO, в стиле [WifiServerTransport]): + * - POST /v1/chat/completions — stream=false → JSON chat.completion, + * stream=true → SSE-чанки chat.completion.chunk + [DONE]; + * - GET /v1/models, GET / — самодокументация. + * + * Мессиджи запроса парсятся DTO koog [OpenAIMessage] (парсер OpenAI-клиента: + * роль → типизированный сабтип, content — строка или массив parts, + * tool + tool_call_id). Ответы — ручной snake_case JSON: DTO koog для + * ответов (OpenAIChatCompletionResponse) сериализуют finishReason в + * camelCase (клиентская конвенция), что не OpenAI wire-формат. + * + * Бэкенд — [PhoneApp.openAiLlm] (текущий выбор модели: локальный LiteRT-LM + * или удалённый по ключу); null → 503. Ядро [handleChat] вынесено из + * сокета/корутины Ktor — юнит-тестится на JVM. + */ +object OpenAiHttpServer { + + private const val MODEL_NAME = "viewmate" + + /** Задержка между SSE-фреймами эмулированного стрима (движок не стримит нативно). */ + private const val CHUNK_DELAY_MS = 25L + + private val json = Json { ignoreUnknownKeys = true } + + @Volatile + private var started = false + private var server: EmbeddedServer<*, *>? = null + + /** Поднимает Ktor-сервер на [PhoneConfig.OPENAI_SERVER_PORT] и печатает все IP устройства. */ + fun start(app: PhoneApp) { + if (started) return + started = true + try { + server = embeddedServer(CIO, port = PhoneConfig.OPENAI_SERVER_PORT, host = "0.0.0.0") { + openAiModule(app) + } + server!!.start(wait = false) + log("openai", "OpenAI-HTTP (Ktor-CIO) на 0.0.0.0:${PhoneConfig.OPENAI_SERVER_PORT}") + log("openai", "Эндпоинты: /v1/chat/completions (stream=true|false), /v1/models, /") + allIps().forEach { ip -> + log("openai", "IP: $ip → http://$ip:${PhoneConfig.OPENAI_SERVER_PORT}/v1/chat/completions") + } + } catch (e: Exception) { + log("openai", "не удалось запустить: ${e.message}") + } + } + + // ---------- Ktor-модуль ---------- + + private fun Application.openAiModule(app: PhoneApp) { + routing { + post("/v1/chat/completions") { + call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*") + when (val outcome = handleChat(app.openAiLlm(), call.receiveText())) { + is ChatOutcome.Json -> call.respondText( + text = outcome.body, + contentType = ContentType.Application.Json, + status = statusCode(outcome.code), + ) + + is ChatOutcome.Sse -> sendSse(call, outcome) + } + } + get("/v1/models") { + call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*") + call.respondText(modelsJson(), ContentType.Application.Json) + } + get("/") { + call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*") + call.respondText(helpJson(), ContentType.Application.Json) + } + } + } + + /** SSE-стрим: фреймы [ChatOutcome.Sse.frames] с задержкой [CHUNK_DELAY_MS]. */ + private suspend fun sendSse(call: ApplicationCall, sse: ChatOutcome.Sse) { + call.respondTextWriter( + contentType = ContentType.parse("text/event-stream; charset=utf-8"), + status = HttpStatusCode.OK, + ) { + sse.frames.forEach { frame -> + append(frame) + flush() + delay(CHUNK_DELAY_MS) + } + } + } + + private fun statusCode(code: Int): HttpStatusCode = when (code) { + 200 -> HttpStatusCode.OK + 400 -> HttpStatusCode.BadRequest + 502 -> HttpStatusCode.BadGateway + 503 -> HttpStatusCode.ServiceUnavailable + else -> HttpStatusCode(code, "Error") + } + + // ---------- ядро (без сокета: юнит-тестится) ---------- + + /** Итог обработки /v1/chat/completions: JSON-тело с кодом или список SSE-фреймов. */ + internal sealed interface ChatOutcome { + data class Json(val code: Int, val body: String) : ChatOutcome + data class Sse(val frames: List) : ChatOutcome + } + + internal suspend fun handleChat(llm: LlmClient?, body: String): ChatOutcome { + val req = try { + json.decodeFromJsonElement(OaiRequest.serializer(), normalizeOpenAiElement(body)) + } catch (e: Exception) { + return ChatOutcome.Json(400, errorJson("некорректный JSON body", 400)) + } + val (system, history) = runCatching { splitMessages(req) }.getOrElse { e -> + return ChatOutcome.Json(400, errorJson(e.message ?: "некорректные messages", 400)) + } + if (history.isEmpty()) return ChatOutcome.Json(400, errorJson("messages не может быть пустым", 400)) + if (llm == null) { + return ChatOutcome.Json(503, errorJson("LLM недоступен: API-ключ не задан или модель не загружена", 503)) + } + val text = try { + llm.chat(history, system) + } catch (e: Exception) { + log("openai", "ошибка LLM: ${e.message}") + return ChatOutcome.Json(502, errorJson("ошибка LLM: ${e.message}", 502)) + } + log("openai", "запрос: stream=${req.stream}, msg=${req.messages.size} → ${text.length} симв") + + val id = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").take(12) + val created = System.currentTimeMillis() / 1000 + return if (req.stream) ChatOutcome.Sse(streamFrames(id, created, text)) + else ChatOutcome.Json(200, completionJson(id, created, text)) + } + + /** + * OpenAI wire → DTO koog: tool-сообщения несут `tool_call_id` (snake_case), + * а парсер koog ждёт `toolCallId` (camelCase, клиентская конвенция). + * Для tool-роли дописываем camelCase-копию (или пустую, если поля нет). + * Остальное — без изменений. + */ + private fun normalizeOpenAiElement(raw: String): JsonElement { + val root = json.decodeFromString(JsonElement.serializer(), raw) + val obj = root as? JsonObject ?: return root + val rawMsgs = obj["messages"] as? JsonArray ?: return root + val fixed = rawMsgs.map { el -> + val m = el as? JsonObject ?: return@map el + if (m["role"]?.jsonPrimitive?.contentOrNull == "tool" && !m.containsKey("toolCallId")) { + val tcid = m["tool_call_id"]?.jsonPrimitive?.contentOrNull ?: "" + JsonObject(m.toMutableMap().apply { put("toolCallId", JsonPrimitive(tcid)) }) + } else el + } + return JsonObject(obj.toMutableMap().apply { put("messages", JsonArray(fixed)) }) + } + + /** + * Мессиджи OpenAI → (system-промпт, история): system/developer склеиваются + * в system; user/assistant/tool (tool_call_id) → [ChatMessage]. + * Content у DTO koog: строка или массив parts — [ai.koog.prompt.executor.clients.openai.base.models.Content.text]. + */ + private fun splitMessages(req: OaiRequest): Pair> { + val systems = mutableListOf() + val history = mutableListOf() + for (m in req.messages) { + val content = m.content?.text().orEmpty() + when (m) { + is OpenAIMessage.System, is OpenAIMessage.Developer -> systems.add(content) + is OpenAIMessage.User -> history.add(ChatMessage.of(ChatRole.USER, content)) + is OpenAIMessage.Assistant -> history.add(ChatMessage.of(ChatRole.ASSISTANT, content)) + is OpenAIMessage.Tool -> + history.add(ChatMessage.of(ChatRole.TOOL, content, toolCallId = m.toolCallId)) + } + } + val system = systems.joinToString("\n").ifEmpty { null } + return system to history + } + + // ---------- константы (без сокета) ---------- + + /** Все IPv4/IPv6 адреса интерфейсов + 127.0.0.1 — для логов и тестов. */ + fun allIps(): List { + val ips = linkedSetOf("127.0.0.1") + try { + for (intf in java.net.NetworkInterface.getNetworkInterfaces()) { + if (!intf.isUp || intf.isLoopback) continue + for (addr in intf.inetAddresses) { + if (addr is java.net.Inet4Address) addr.hostAddress?.let(ips::add) + } + } + } catch (_: Exception) { + } + return ips.toList() + } + + private fun completionJson(id: String, created: Long, text: String): String = + "{" + + "\"id\":\"$id\"," + + "\"object\":\"chat.completion\"," + + "\"created\":$created," + + "\"model\":\"$MODEL_NAME\"," + + "\"choices\":[{" + + "\"index\":0," + + "\"message\":{\"role\":\"assistant\",\"content\":\"${jsonEscape(text)}\"}," + + "\"finish_reason\":\"stop\"" + + "}]," + + "\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0}" + + "}" + + /** Фреймы SSE-стрима: роль, по слову (слово + следующие пробелы), finish + [DONE]. */ + private fun streamFrames(id: String, created: Long, text: String): List { + val frames = mutableListOf() + frames.add(sseChunk(id, created, role = true)) + for (m in Regex("""\S+\s*""").findAll(text)) { + frames.add(sseChunk(id, created, delta = m.value)) + } + frames.add(sseChunk(id, created, finish = true)) + frames.add("data: [DONE]\n\n") + return frames + } + + private fun sseChunk(id: String, created: Long, role: Boolean = false, delta: String? = null, finish: Boolean = false): String { + val deltaJson = buildString { + if (role) append("\"role\":\"assistant\"") + if (delta != null) { + if (role) append(',') + append("\"content\":\"${jsonEscape(delta)}\"") + } + } + val finishReason = if (finish) "\"stop\"" else "null" + val jsonBody = "{" + + "\"id\":\"$id\"," + + "\"object\":\"chat.completion.chunk\"," + + "\"created\":$created," + + "\"model\":\"$MODEL_NAME\"," + + "\"choices\":[{" + + "\"index\":0," + + "\"delta\":{$deltaJson}," + + "\"finish_reason\":$finishReason" + + "}]" + + "}" + return "data: $jsonBody\n\n" + } + + private fun errorJson(message: String, code: Int): String = buildString { + val type = if (code in 400..499) "invalid_request_error" else "server_error" + append("{\"error\":{\"message\":\"").append(jsonEscape(message)).append("\",") + append("\"type\":\"").append(type).append("\",\"code\":null}}") + } + + private fun modelsJson(): String = + "{\"object\":\"list\",\"data\":[{" + + "\"id\":\"$MODEL_NAME\"," + + "\"object\":\"model\"," + + "\"created\":0," + + "\"owned_by\":\"viewmate\"" + + "}]" + + private fun helpJson(): String = + "{\"message\":\"Viewmate OpenAI-совместимый сервер\"," + + "\"endpoints\":[{" + + "\"method\":\"POST\",\"path\":\"/v1/chat/completions\"," + + "\"description\":\"Chat completion (stream=true|false)\"," + + "\"model\":\"$MODEL_NAME\"}]," + + "\"models\":[\"$MODEL_NAME\"],\"port\":${PhoneConfig.OPENAI_SERVER_PORT}}" + + private fun jsonEscape(s: String): String = + s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + // ---------- DTO запроса ---------- + + @Serializable + private data class OaiRequest( + val model: String? = null, + val messages: List = emptyList(), + val stream: Boolean = false, + ) +} 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 2980d8e..5259410 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 @@ -43,6 +43,7 @@ import pw.binom.viewmate.phone.agent.GemmaModelDownloader import pw.binom.viewmate.phone.agent.JellyfinMediaSource import pw.binom.viewmate.phone.agent.LocalLlmClient import pw.binom.viewmate.phone.agent.LocalLlmModelState +import pw.binom.viewmate.phone.agent.LlmClient import pw.binom.viewmate.phone.agent.LlmModelChoice import pw.binom.viewmate.phone.agent.LlmPrefs import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET @@ -127,6 +128,28 @@ class PhoneApp : Application() { /** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */ fun localLlmClient(): LocalLlmClient? = localLlm + /** + * LLM-клиент по текущему выбору [llmChoice] для OpenAI-HTTP-сервера + * ([OpenAiHttpServer]) — без тул-цикла ассистента (клиент OpenAI API сам + * управляет контекстом). Локальный клиент переиспользует [localLlm] + * (движок LiteRT один на процесс). null — серверный без ключа или локальная + * модель ещё не скачана. + */ + fun openAiLlm(): LlmClient? = synchronized(assistantLock) { + when (_llmChoice.value) { + LlmModelChoice.REMOTE -> + if (BuildConfig.LLM_API_KEY.isBlank()) null + else RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) + + LlmModelChoice.LOCAL -> + if (!localModelFile().isFile) null + else localLlm ?: LocalLlmClient( + modelFile = localModelFile(), + cacheDir = File(cacheDir, "litertlm"), + ).also { localLlm = it } + } + } + /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */ // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость — @@ -442,6 +465,7 @@ class PhoneApp : Application() { server.start() log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") DebugHttpServer.start(this) + OpenAiHttpServer.start(this) nsd.publish() server.hub.sttFactory = { ensureStt() } server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt index 46d6dc5..80e8bb5 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -12,6 +12,9 @@ object PhoneConfig { const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val SERVER_PORT = 8080 + /** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */ + const val OPENAI_SERVER_PORT = 8800 + /** * Выбор транспорта очки↔телефон (TASK-transport.md п.4): * `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений); 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 9cb3321..3415996 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 @@ -1,15 +1,12 @@ package pw.binom.viewmate.phone.agent -import com.google.ai.edge.litertlm.Backend -import com.google.ai.edge.litertlm.Content -import com.google.ai.edge.litertlm.Contents -import com.google.ai.edge.litertlm.Conversation -import com.google.ai.edge.litertlm.ConversationConfig -import com.google.ai.edge.litertlm.Engine -import com.google.ai.edge.litertlm.EngineConfig -import com.google.ai.edge.litertlm.ExperimentalApi -import com.google.ai.edge.litertlm.ExperimentalFlags -import com.google.ai.edge.litertlm.Message +import pw.binom.litert.LiteBackend +import pw.binom.litert.LiteConfig +import pw.binom.litert.LiteExperimental +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteMessage +import pw.binom.litert.LiteRequest +import pw.binom.litert.google.googleLiteLlm import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -75,8 +72,12 @@ object Gemma4E2B { * Ключ: cacheDir должен быть системным applicationContext.cacheDir — * GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров; * свой подкаталог в filesDir приводил к падению на delegate_kernel.cc. + * Экспериментальный флаг: без enableSpeculativeDecoding статический + * TopK-сэмплер на GPU требует OpenCL-драйвер, которого нет в Android + * app-namespace («Can not find OpenCL library»); на HONOR работает + * WebGPU-сэмплер, т.е. флаг включён. */ - fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig( + fun liteConfig(modelFile: File, cacheDir: File): LiteConfig = LiteConfig( modelPath = modelFile.absolutePath, // NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497) // в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на @@ -85,10 +86,11 @@ object Gemma4E2B { // vision-путь не трогает (буфер аллокируется только при картинке), поэтому // берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на // картинке — вернём vision на CPU точечно. - backend = Backend.GPU(), - visionBackend = Backend.GPU(), - audioBackend = Backend.CPU(), + backend = LiteBackend.GPU, + visionBackend = LiteBackend.GPU, + audioBackend = LiteBackend.CPU, cacheDir = cacheDir.absolutePath, + experimental = LiteExperimental(enableSpeculativeDecoding = true), ) } @@ -115,53 +117,53 @@ fun sha256OfFileMatches(file: File, expectedHex: String): Boolean = /** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */ class LocalLlmPrompt( val systemInstruction: String?, - val initialMessages: List, - val incoming: Message, + val initialMessages: List, + val incoming: LiteMessage, ) -/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */ -fun Message.text(): String = contents.contents.filterIsInstance() - .joinToString("") { it.text } - /** * Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция): - * - USER → [Message.user], ASSISTANT → [Message.model]; + * - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model]; * - TOOL (результат тула) — как user-сообщение в том же формате «Результат - * тула : <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет - * tool-роли в разговорной ленте — tool-результат идёт как user-мессадж); + * тула : <текст>», что шлёт [RemoteLlmClient] (консервативно: общая + * с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL — + * задел на будущее, когда шаблон модели будет уметь родные тулзы); * - SYSTEM-сообщения и параметр [system] складываются в ОДИН * systemInstruction (в LiteRT-LM один системный промпт на разговор); * - пустые content отбрасываются. * - * Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL: - * оно уходит в sendMessageAsync, остальное — история (initialMessages). + * Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL. + * В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель: + * каждый инференс — новый полный промпт, движок не несёт состояния между + * вызовами; будущие механизмы чистки/сжатия истории для движка — просто + * новый (короткий) полный промпт. * Если последние сообщения только модельные/история пуста — incoming = «Продолжай.» * (аналог fallback [RemoteLlmClient]). */ fun buildLocalLlmPrompt(messages: List, system: String? = null): LocalLlmPrompt { val systemParts = ArrayList() system?.takeIf { it.isNotBlank() }?.let { systemParts += it } - val mapped = ArrayList() + val mapped = ArrayList() for (msg in messages) { val text = msg.content when (msg.role) { ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text - ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text)) - ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text)) + ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text) + ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text) ChatRole.TOOL -> if (text.isNotBlank()) { - mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text")) + mapped += LiteMessage.user("Результат тула ${msg.toolCallId ?: "?"}: $text") } } } val last = messages.lastOrNull { it.content.isNotBlank() } - val incoming: Message - val initial: List + val incoming: LiteMessage + val initial: List if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) { initial = mapped.dropLast(1) incoming = mapped.last() } else { initial = mapped - incoming = Message.user(Contents.of("Продолжай.")) + incoming = LiteMessage.user("Продолжай.") } return LocalLlmPrompt( systemInstruction = systemParts.joinToString("\n").ifEmpty { null }, @@ -281,12 +283,15 @@ class GemmaModelDownloader( /** * Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве - * (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md). + * (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md), + * через KMP-библиотеку pw.binom.litert (универсальный контракт + * RAW-инференса: владелец истории — потребитель). * - * Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]), + * Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]), * переиспользуется — модель заново не грузится. Каждый чатовый вызов — - * новый Conversation (своей истории у LiteRT-LM нет — она в наших - * initialMessages). Ошибки — [IllegalStateException] с читаемым русским + * новый RAW-инференс: [LiteRequest] несёт полный промпт (системная + * инструкция + вся история + входящее), движок не хранит состояние между + * вызовами. Ошибки — [IllegalStateException] с читаемым русским * сообщением («локальная модель не скачана…») — [Assistant] покажет его * пользователю, падение приложения исключено. * @@ -298,41 +303,35 @@ class LocalLlmClient( private val cacheDir: File, ) : LlmClient { - private val engineLock = Any() + private val llmLock = Any() @Volatile - private var engine: Engine? = null + private var llm: LiteLlm? = null - /** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */ + /** Модель на месте (по имени файла; 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} симв)") - val engine = ensureEngine() + 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 conversation: Conversation = synchronized(engineLock) { - engine.createConversation( - ConversationConfig( - systemInstruction = prompt.systemInstruction?.let { Contents.of(it) }, - initialMessages = prompt.initialMessages, - ) - ) - } - log("llm", "chat: conversation создан") + log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}") + val request = LiteRequest( + systemInstruction = prompt.systemInstruction, + // RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт + messages = prompt.initialMessages + prompt.incoming, + ) + var text = "" + var chunks = 0 + var tokens = 0 + val t0 = System.currentTimeMillis() + var firstTokenAt = 0L try { - var text = "" - var chunks = 0 - var tokens = 0 - val t0 = System.currentTimeMillis() - var firstTokenAt = 0L withTimeout(120_000) { - // NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт - // emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом - // dex null — артефакт маски $default, не аргумент. - conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> + // либа сама закрывает беседу по завершении/отмене потока + llm.inferStream(request).collect { delta -> chunks++ - val chunkText = messageChunk.text() + val chunkText = delta.text text += chunkText if (chunkText.isNotEmpty()) { if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() @@ -350,44 +349,30 @@ class LocalLlmClient( return text.trim() } catch (e: CancellationException) { log("llm", "chat: отменено") - runCatching { conversation.cancelProcess() } throw e } catch (e: Exception) { 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 { - runCatching { conversation.close() } } } /** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */ fun close() { - val eng = synchronized(engineLock) { - val e = engine - engine = null - e + val local = synchronized(llmLock) { + val l = llm + llm = null + l } - runCatching { eng?.close() } - } - - /** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */ - @OptIn(ExperimentalApi::class) - private fun applyExperimentalFlags() { - // NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический - // TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер, - // которого нет в app-namespace ("Can not find OpenCL library"). Автор включил - // её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR - // работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть. - ExperimentalFlags.enableSpeculativeDecoding = true + runCatching { local?.close() } } /** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */ - private fun ensureEngine(): Engine { - val cached = engine + private fun ensureLlm(): LiteLlm { + val cached = llm if (cached != null) return cached - return synchronized(engineLock) { - val existing = engine + return synchronized(llmLock) { + val existing = llm if (existing != null) return existing if (!modelFile.isFile) { throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»") @@ -395,21 +380,16 @@ class LocalLlmClient( if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) { throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново") } - applyExperimentalFlags() // cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет // скомпилированные шейдеры в cacheDir; если каталога нет — движок // падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд // в кэш не пишет, поэтому работал и без mkdirs. cacheDir.mkdirs() - val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir)) - try { - created.initialize() - } catch (e: Exception) { - runCatching { created.close() } - throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e) - } - log("llm", "локальный движок инициализирован: ${modelFile.name}") - engine = created + // Экспериментальные флаги (speculative decoding → WebGPU-сэмплер) + // ушли в LiteConfig (см. [Gemma4E2B.liteConfig]). + val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir)) + log("llm", "локальный движок готов: ${modelFile.name}") + llm = created created } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt index c5285f7..58b9fa3 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt @@ -1,7 +1,7 @@ package pw.binom.viewmate.phone.agent -import com.google.ai.edge.litertlm.Contents -import com.google.ai.edge.litertlm.Message +import pw.binom.litert.LiteMessage +import pw.binom.litert.LiteRole import java.io.File import java.io.IOException import kotlin.io.path.createTempFile @@ -47,12 +47,12 @@ class LlmLocalTest { // системный промпт = SYSTEM-сообщения (системный параметр не задан) assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction) // входящее — последнее непустое USER - assertEquals("погоди, выключи", p.incoming.text()) + assertEquals("погоди, выключи", p.incoming.text) // история: user, assistant, tool-результат в «Результат тула…» формате assertEquals(3, p.initialMessages.size) - assertEquals("включи свет", p.initialMessages[0].text()) - assertEquals("Свет включён.", p.initialMessages[1].text()) - assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text()) + assertEquals("включи свет", p.initialMessages[0].text) + assertEquals("Свет включён.", p.initialMessages[1].text) + assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text) } @Test @@ -64,7 +64,7 @@ class LlmLocalTest { ) val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.") assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction) - assertEquals("привет", p.incoming.text()) + assertEquals("привет", p.incoming.text) assertTrue(p.initialMessages.isEmpty()) } @@ -77,10 +77,10 @@ class LlmLocalTest { ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"), ) val p = buildLocalLlmPrompt(history) - assertEquals("Результат тула c7: найдено 5", p.incoming.text()) + assertEquals("Результат тула c7: найдено 5", p.incoming.text) // assistant с пустым текстом в историю не попадает assertEquals(1, p.initialMessages.size) - assertEquals("что в каталоге?", p.initialMessages[0].text()) + assertEquals("что в каталоге?", p.initialMessages[0].text) } @Test @@ -90,7 +90,7 @@ class LlmLocalTest { ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"), ) val p = buildLocalLlmPrompt(history) - assertEquals("Продолжай.", p.incoming.text()) + assertEquals("Продолжай.", p.incoming.text) assertEquals(2, p.initialMessages.size) assertTrue(p.systemInstruction == null) } @@ -105,13 +105,15 @@ class LlmLocalTest { val p = buildLocalLlmPrompt(history) // пустое user-сообщение отброшено; входящее — «как дела» assertEquals(1, p.initialMessages.size) - assertEquals("как дела", p.incoming.text()) + assertEquals("как дела", p.incoming.text) } @Test - fun messageTextExtractsTextParts() { - val m = Message.user(Contents.of("привет")) - assertEquals("привет", m.text()) + fun liteMessageFactoriesMapRoles() { + assertEquals(LiteMessage(LiteRole.USER, "привет"), LiteMessage.user("привет")) + assertEquals(LiteMessage(LiteRole.MODEL, "здравствуйте"), LiteMessage.model("здравствуйте")) + assertEquals(LiteMessage(LiteRole.SYSTEM, "ты ассистент"), LiteMessage.system("ты ассистент")) + assertEquals(LiteMessage(LiteRole.TOOL, "результат"), LiteMessage.tool("результат")) } // ---------- SHA-256 ----------