Приложение добавляет OpenAI-совместимый HTTP-сервер и KMP-обёртку LiteRT
This commit is contained in:
@@ -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 ----------
|
||||
|
||||
Reference in New Issue
Block a user