Приложение добавляет OpenAI-совместимый HTTP-сервер и KMP-обёртку LiteRT
This commit is contained in:
@@ -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,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
log("llm", "chat: conversation создан")
|
|
||||||
try {
|
|
||||||
var text = ""
|
var text = ""
|
||||||
var chunks = 0
|
var chunks = 0
|
||||||
var tokens = 0
|
var tokens = 0
|
||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var firstTokenAt = 0L
|
var firstTokenAt = 0L
|
||||||
|
try {
|
||||||
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 ----------
|
||||||
|
|||||||
Reference in New Issue
Block a user