LLM: интерфейс + две реализации (Qwen удалённо / Gemma 4 E2B локально) + переключатель

- LlmClient интерфейс; RemoteLlmClient (OkHttp, llm.binom.pw) и LocalLlmClient (LiteRT-LM, Gemma 4 E2B)
- LocalLlm: Gemma4E2B (SHA-256, GPU/CPU-бэкенды), buildLocalLlmPrompt (SYSTEM→systemInstruction, TOOL→user), GemmaModelDownloader (стриминг, прогресс, фолбэк источников), спекулятивное декодирование
- LlmPrefs (персистентность выбора), PhoneApp: фабрика по llmChoice, пересоздание ассистента, скачивание/отмена
- UI: GlassesInfoScreen — выбор сервер/локальная + прогресс; ChatScreen — индикатор активной модели
- Тесты: +12 (LlmLocalTest) — phone 154 зелёных
This commit is contained in:
2026-08-23 02:52:22 +03:00
parent 2d6ca1e097
commit 7527cf8848
15 changed files with 1192 additions and 19 deletions
@@ -0,0 +1,167 @@
package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Contents
import com.google.ai.edge.litertlm.Message
import java.io.File
import java.io.IOException
import kotlin.io.path.createTempFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
/** Тесты LLM-интерфейса без модели/сети: парсинг выбора, маппинг промпта, SHA-256, фабрика. */
class LlmLocalTest {
// ---------- LlmModelChoice.parse ----------
@Test
fun choiceParseDefaultsToRemote() {
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(null))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(""))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("unknown"))
}
@Test
fun choiceParseByNameInsensitive() {
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("LOCAL"))
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("local"))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("remote"))
}
// ---------- buildLocalLlmPrompt ----------
private fun chatHistory() = listOf(
ChatMessage.of(ChatRole.SYSTEM, "Ты Порфирий — голосовой ассистент."),
ChatMessage.of(ChatRole.USER, "включи свет"),
ChatMessage.of(ChatRole.ASSISTANT, "Свет включён."),
ChatMessage.of(ChatRole.TOOL, "выполнено", toolCallId = "c42"),
ChatMessage.of(ChatRole.USER, "погоди, выключи"),
)
@Test
fun promptMapsRolesToLiteRtLm() {
val p = buildLocalLlmPrompt(chatHistory())
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
// входящее — последнее непустое USER
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())
}
@Test
fun promptFoldsSystemParamAndSystemMessages() {
val history = listOf(
ChatMessage.of(ChatRole.SYSTEM, "контекст A"),
ChatMessage.of(ChatRole.SYSTEM, "контекст B"),
ChatMessage.of(ChatRole.USER, "привет"),
)
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
assertEquals("привет", p.incoming.text())
assertTrue(p.initialMessages.isEmpty())
}
@Test
fun promptToolLastIsIncoming() {
// последняя непустая — TOOL (результат): входящим идёт именно он
val history = listOf(
ChatMessage.of(ChatRole.USER, "что в каталоге?"),
ChatMessage.of(ChatRole.ASSISTANT, "", toolCalls = listOf(ToolCall("c7", "media_search", "{}"))),
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
)
val p = buildLocalLlmPrompt(history)
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
// assistant с пустым текстом в историю не попадает
assertEquals(1, p.initialMessages.size)
assertEquals("что в каталоге?", p.initialMessages[0].text())
}
@Test
fun promptLastIsModelMessageFallsBackToContinue() {
val history = listOf(
ChatMessage.of(ChatRole.USER, "привет"),
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
)
val p = buildLocalLlmPrompt(history)
assertEquals("Продолжай.", p.incoming.text())
assertEquals(2, p.initialMessages.size)
assertTrue(p.systemInstruction == null)
}
@Test
fun promptSkipsEmptyContents() {
val history = listOf(
ChatMessage.of(ChatRole.USER, ""),
ChatMessage.of(ChatRole.ASSISTANT, "привет"),
ChatMessage.of(ChatRole.USER, "как дела"),
)
val p = buildLocalLlmPrompt(history)
// пустое user-сообщение отброшено; входящее — «как дела»
assertEquals(1, p.initialMessages.size)
assertEquals("как дела", p.incoming.text())
}
@Test
fun messageTextExtractsTextParts() {
val m = Message.user(Contents.of("привет"))
assertEquals("привет", m.text())
}
// ---------- SHA-256 ----------
@Test
fun sha256OfFileMatchesKnownContent() {
val file = createTempFile("sha-test").toFile()
try {
file.writeBytes("hello world".toByteArray())
val expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
assertEquals(expected, sha256HexOfFile(file))
assertTrue(sha256OfFileMatches(file, expected))
assertTrue(sha256OfFileMatches(file, expected.uppercase()))
assertFalse(sha256OfFileMatches(file, "deadbeef"))
} finally {
file.delete()
}
}
@Test
fun sha256OfFileMatchesFalseOnMissingFile() {
assertFalse(sha256OfFileMatches(File("/root/WORK/view-mate/definitely-not-here-llm"), ""))
}
// ---------- LocalLlmClient ----------
@Test
fun localClientWithoutModelFailsReadably() {
val missing = File(createTempFile("model").toFile().parentFile, "missing.litertlm")
val client = LocalLlmClient(modelFile = missing, cacheDir = File(missing.parentFile, "cache"))
assertFalse(client.modelDownloaded())
runBlocking {
val e = assertFailsWith<IllegalStateException> {
client.chat(listOf(ChatMessage.of(ChatRole.USER, "привет")))
}
assertTrue(e.message!!.contains("не скачана"), e.message)
}
client.close()
}
// ---------- GemmaModelDownloader ----------
@Test
fun downloaderAllSourcesFailThrows() {
val target = File(createTempFile("model-dl").toFile().parentFile, "model.litertlm")
val dl = GemmaModelDownloader(target, urls = listOf("http://127.0.0.1:1/model.litertlm"))
val e = assertFailsWith<IOException> {
runBlocking { dl.download { } }
}
assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message)
assertFalse(target.exists())
}
}