Приложение добавляет OpenAI-совместимый HTTP-сервер и KMP-обёртку LiteRT

This commit is contained in:
2026-09-01 18:45:25 +03:00
parent e18b82642f
commit 4587e22b50
7 changed files with 431 additions and 110 deletions
+5 -2
View File
@@ -101,8 +101,11 @@ dependencies {
implementation(libs.koog.openai.client) implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp) implementation(libs.koog.http.client.okhttp)
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU) // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU):
implementation(libs.litertlm.android) // KMP-обёртка pw.binom.litert (универсальный контракт RAW-инференса + google-бэкенд)
implementation(libs.litert.google)
// универсальный контракт (LiteLlm/LiteRequest…) — импортируется напрямую
implementation(libs.litert.api)
// Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack // Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6") implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
@@ -1,6 +1,5 @@
package pw.binom.viewmate.phone 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.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmClient import pw.binom.viewmate.phone.agent.LocalLlmClient
import java.io.BufferedReader import java.io.BufferedReader
@@ -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<String>) : 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<String?, List<ChatMessage>> {
val systems = mutableListOf<String>()
val history = mutableListOf<ChatMessage>()
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<String> {
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<String> {
val frames = mutableListOf<String>()
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<OpenAIMessage> = emptyList(),
val stream: Boolean = false,
)
}
@@ -43,6 +43,7 @@ import pw.binom.viewmate.phone.agent.GemmaModelDownloader
import pw.binom.viewmate.phone.agent.JellyfinMediaSource import pw.binom.viewmate.phone.agent.JellyfinMediaSource
import pw.binom.viewmate.phone.agent.LocalLlmClient import pw.binom.viewmate.phone.agent.LocalLlmClient
import pw.binom.viewmate.phone.agent.LocalLlmModelState 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.LlmModelChoice
import pw.binom.viewmate.phone.agent.LlmPrefs import pw.binom.viewmate.phone.agent.LlmPrefs
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
@@ -127,6 +128,28 @@ class PhoneApp : Application() {
/** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */ /** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
fun localLlmClient(): LocalLlmClient? = localLlm 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]). */ /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость — // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
@@ -442,6 +465,7 @@ class PhoneApp : Application() {
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this) DebugHttpServer.start(this)
OpenAiHttpServer.start(this)
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() } server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } } server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
@@ -12,6 +12,9 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080 const val SERVER_PORT = 8080
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
const val OPENAI_SERVER_PORT = 8800
/** /**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4): * Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений); * `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
@@ -1,15 +1,12 @@
package pw.binom.viewmate.phone.agent package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Backend import pw.binom.litert.LiteBackend
import com.google.ai.edge.litertlm.Content import pw.binom.litert.LiteConfig
import com.google.ai.edge.litertlm.Contents import pw.binom.litert.LiteExperimental
import com.google.ai.edge.litertlm.Conversation import pw.binom.litert.LiteLlm
import com.google.ai.edge.litertlm.ConversationConfig import pw.binom.litert.LiteMessage
import com.google.ai.edge.litertlm.Engine import pw.binom.litert.LiteRequest
import com.google.ai.edge.litertlm.EngineConfig import pw.binom.litert.google.googleLiteLlm
import com.google.ai.edge.litertlm.ExperimentalApi
import com.google.ai.edge.litertlm.ExperimentalFlags
import com.google.ai.edge.litertlm.Message
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -75,8 +72,12 @@ object Gemma4E2B {
* Ключ: cacheDir должен быть системным applicationContext.cacheDir — * Ключ: cacheDir должен быть системным applicationContext.cacheDir —
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров; * GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc. * свой подкаталог в 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, modelPath = modelFile.absolutePath,
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497) // NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на // в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
@@ -85,10 +86,11 @@ object Gemma4E2B {
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому // vision-путь не трогает (буфер аллокируется только при картинке), поэтому
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на // берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
// картинке — вернём vision на CPU точечно. // картинке — вернём vision на CPU точечно.
backend = Backend.GPU(), backend = LiteBackend.GPU,
visionBackend = Backend.GPU(), visionBackend = LiteBackend.GPU,
audioBackend = Backend.CPU(), audioBackend = LiteBackend.CPU,
cacheDir = cacheDir.absolutePath, cacheDir = cacheDir.absolutePath,
experimental = LiteExperimental(enableSpeculativeDecoding = true),
) )
} }
@@ -115,53 +117,53 @@ fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */ /** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
class LocalLlmPrompt( class LocalLlmPrompt(
val systemInstruction: String?, val systemInstruction: String?,
val initialMessages: List<Message>, val initialMessages: List<LiteMessage>,
val incoming: Message, val incoming: LiteMessage,
) )
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
.joinToString("") { it.text }
/** /**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция): * Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [Message.user], ASSISTANT → [Message.model]; * - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат * - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет * тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж); * с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН * - SYSTEM-сообщения и параметр [system] складываются в ОДИН
* systemInstruction (в LiteRT-LM один системный промпт на разговор); * systemInstruction (в LiteRT-LM один системный промпт на разговор);
* - пустые content отбрасываются. * - пустые content отбрасываются.
* *
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL: * Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL.
* оно уходит в sendMessageAsync, остальное — история (initialMessages). * В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель:
* каждый инференс — новый полный промпт, движок не несёт состояния между
* вызовами; будущие механизмы чистки/сжатия истории для движка — просто
* новый (короткий) полный промпт.
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.» * Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
* (аналог fallback [RemoteLlmClient]). * (аналог fallback [RemoteLlmClient]).
*/ */
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt { fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
val systemParts = ArrayList<String>() val systemParts = ArrayList<String>()
system?.takeIf { it.isNotBlank() }?.let { systemParts += it } system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
val mapped = ArrayList<Message>() val mapped = ArrayList<LiteMessage>()
for (msg in messages) { for (msg in messages) {
val text = msg.content val text = msg.content
when (msg.role) { when (msg.role) {
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text)) ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text)
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text)) ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text)
ChatRole.TOOL -> if (text.isNotBlank()) { 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 last = messages.lastOrNull { it.content.isNotBlank() }
val incoming: Message val incoming: LiteMessage
val initial: List<Message> val initial: List<LiteMessage>
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) { if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
initial = mapped.dropLast(1) initial = mapped.dropLast(1)
incoming = mapped.last() incoming = mapped.last()
} else { } else {
initial = mapped initial = mapped
incoming = Message.user(Contents.of("Продолжай.")) incoming = LiteMessage.user("Продолжай.")
} }
return LocalLlmPrompt( return LocalLlmPrompt(
systemInstruction = systemParts.joinToString("\n").ifEmpty { null }, systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
@@ -281,12 +283,15 @@ class GemmaModelDownloader(
/** /**
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве * Локальный 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 нет — она в наших * новый RAW-инференс: [LiteRequest] несёт полный промпт (системная
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским * инструкция + вся история + входящее), движок не хранит состояние между
* вызовами. Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его * сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено. * пользователю, падение приложения исключено.
* *
@@ -298,41 +303,35 @@ class LocalLlmClient(
private val cacheDir: File, private val cacheDir: File,
) : LlmClient { ) : LlmClient {
private val engineLock = Any() private val llmLock = Any()
@Volatile @Volatile
private var engine: Engine? = null private var llm: LiteLlm? = null
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */ /** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
fun modelDownloaded(): Boolean = modelFile.isFile fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String { override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)") log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
val engine = ensureEngine() val llm = ensureLlm()
log("llm", "chat: движок готов, строю промпт") log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system) val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}") log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
val conversation: Conversation = synchronized(engineLock) { val request = LiteRequest(
engine.createConversation( systemInstruction = prompt.systemInstruction,
ConversationConfig( // RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) }, messages = prompt.initialMessages + prompt.incoming,
initialMessages = prompt.initialMessages, )
) var text = ""
) var chunks = 0
} var tokens = 0
log("llm", "chat: conversation создан") val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
try { try {
var text = ""
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
withTimeout(120_000) { withTimeout(120_000) {
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт // либа сама закрывает беседу по завершении/отмене потока
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом llm.inferStream(request).collect { delta ->
// dex null — артефакт маски $default, не аргумент.
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
chunks++ chunks++
val chunkText = messageChunk.text() val chunkText = delta.text
text += chunkText text += chunkText
if (chunkText.isNotEmpty()) { if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
@@ -350,44 +349,30 @@ class LocalLlmClient(
return text.trim() return text.trim()
} catch (e: CancellationException) { } catch (e: CancellationException) {
log("llm", "chat: отменено") log("llm", "chat: отменено")
runCatching { conversation.cancelProcess() }
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}") log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}") log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e) throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally {
runCatching { conversation.close() }
} }
} }
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */ /** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() { fun close() {
val eng = synchronized(engineLock) { val local = synchronized(llmLock) {
val e = engine val l = llm
engine = null llm = null
e l
} }
runCatching { eng?.close() } runCatching { local?.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
} }
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */ /** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
private fun ensureEngine(): Engine { private fun ensureLlm(): LiteLlm {
val cached = engine val cached = llm
if (cached != null) return cached if (cached != null) return cached
return synchronized(engineLock) { return synchronized(llmLock) {
val existing = engine val existing = llm
if (existing != null) return existing if (existing != null) return existing
if (!modelFile.isFile) { if (!modelFile.isFile) {
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»") throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
@@ -395,21 +380,16 @@ class LocalLlmClient(
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) { if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново") throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
} }
applyExperimentalFlags()
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет // cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
// скомпилированные шейдеры в cacheDir; если каталога нет — движок // скомпилированные шейдеры в cacheDir; если каталога нет — движок
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд // падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
// в кэш не пишет, поэтому работал и без mkdirs. // в кэш не пишет, поэтому работал и без mkdirs.
cacheDir.mkdirs() cacheDir.mkdirs()
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir)) // Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
try { // ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
created.initialize() val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
} catch (e: Exception) { log("llm", "локальный движок готов: ${modelFile.name}")
runCatching { created.close() } llm = created
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
}
log("llm", "локальный движок инициализирован: ${modelFile.name}")
engine = created
created created
} }
} }
@@ -1,7 +1,7 @@
package pw.binom.viewmate.phone.agent package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Contents import pw.binom.litert.LiteMessage
import com.google.ai.edge.litertlm.Message import pw.binom.litert.LiteRole
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.io.path.createTempFile import kotlin.io.path.createTempFile
@@ -47,12 +47,12 @@ class LlmLocalTest {
// системный промпт = SYSTEM-сообщения (системный параметр не задан) // системный промпт = SYSTEM-сообщения (системный параметр не задан)
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction) assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
// входящее — последнее непустое USER // входящее — последнее непустое USER
assertEquals("погоди, выключи", p.incoming.text()) assertEquals("погоди, выключи", p.incoming.text)
// история: user, assistant, tool-результат в «Результат тула…» формате // история: user, assistant, tool-результат в «Результат тула…» формате
assertEquals(3, p.initialMessages.size) assertEquals(3, p.initialMessages.size)
assertEquals("включи свет", p.initialMessages[0].text()) assertEquals("включи свет", p.initialMessages[0].text)
assertEquals("Свет включён.", p.initialMessages[1].text()) assertEquals("Свет включён.", p.initialMessages[1].text)
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text()) assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text)
} }
@Test @Test
@@ -64,7 +64,7 @@ class LlmLocalTest {
) )
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.") val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction) assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
assertEquals("привет", p.incoming.text()) assertEquals("привет", p.incoming.text)
assertTrue(p.initialMessages.isEmpty()) assertTrue(p.initialMessages.isEmpty())
} }
@@ -77,10 +77,10 @@ class LlmLocalTest {
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"), ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
) )
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
assertEquals("Результат тула c7: найдено 5", p.incoming.text()) assertEquals("Результат тула c7: найдено 5", p.incoming.text)
// assistant с пустым текстом в историю не попадает // assistant с пустым текстом в историю не попадает
assertEquals(1, p.initialMessages.size) assertEquals(1, p.initialMessages.size)
assertEquals("что в каталоге?", p.initialMessages[0].text()) assertEquals("что в каталоге?", p.initialMessages[0].text)
} }
@Test @Test
@@ -90,7 +90,7 @@ class LlmLocalTest {
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"), ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
) )
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
assertEquals("Продолжай.", p.incoming.text()) assertEquals("Продолжай.", p.incoming.text)
assertEquals(2, p.initialMessages.size) assertEquals(2, p.initialMessages.size)
assertTrue(p.systemInstruction == null) assertTrue(p.systemInstruction == null)
} }
@@ -105,13 +105,15 @@ class LlmLocalTest {
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
// пустое user-сообщение отброшено; входящее — «как дела» // пустое user-сообщение отброшено; входящее — «как дела»
assertEquals(1, p.initialMessages.size) assertEquals(1, p.initialMessages.size)
assertEquals("как дела", p.incoming.text()) assertEquals("как дела", p.incoming.text)
} }
@Test @Test
fun messageTextExtractsTextParts() { fun liteMessageFactoriesMapRoles() {
val m = Message.user(Contents.of("привет")) assertEquals(LiteMessage(LiteRole.USER, "привет"), LiteMessage.user("привет"))
assertEquals("привет", m.text()) assertEquals(LiteMessage(LiteRole.MODEL, "здравствуйте"), LiteMessage.model("здравствуйте"))
assertEquals(LiteMessage(LiteRole.SYSTEM, "ты ассистент"), LiteMessage.system("ты ассистент"))
assertEquals(LiteMessage(LiteRole.TOOL, "результат"), LiteMessage.tool("результат"))
} }
// ---------- SHA-256 ---------- // ---------- SHA-256 ----------