Приложение добавляет 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.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")
@@ -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
@@ -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.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) } }
@@ -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], поведение без изменений);
@@ -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<Message>,
val incoming: Message,
val initialMessages: List<LiteMessage>,
val incoming: LiteMessage,
)
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
.joinToString("") { it.text }
/**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [Message.user], ASSISTANT → [Message.model];
* - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
* тула <id>: <текст>», что шлёт [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<ChatMessage>, system: String? = null): LocalLlmPrompt {
val systemParts = ArrayList<String>()
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
val mapped = ArrayList<Message>()
val mapped = ArrayList<LiteMessage>()
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<Message>
val incoming: LiteMessage
val initial: List<LiteMessage>
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<ChatMessage>, 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
}
}
@@ -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 ----------