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
@@ -5,7 +5,9 @@ import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,10 +30,16 @@ import pw.binom.viewmate.phone.agent.AgentToolkit
import pw.binom.viewmate.phone.agent.Assistant
import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.Gemma4E2B
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
import pw.binom.viewmate.phone.agent.LlmClient
import pw.binom.viewmate.phone.agent.LocalLlmClient
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LlmPrefs
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
import pw.binom.viewmate.phone.agent.MediaToolExecutor
import pw.binom.viewmate.phone.agent.RemoteLlmClient
import pw.binom.viewmate.phone.agent.SkillRegistry
import pw.binom.viewmate.phone.agent.SkillRepository
import pw.binom.viewmate.phone.agent.ToolsetRegistry
@@ -98,13 +106,92 @@ class PhoneApp : Application() {
private var assistantLock = Any()
private var assistant: Assistant? = null
private var toolkit: AgentToolkit? = null
/** Модель, на которой создан текущий [assistant] — смена выбора пересоздаёт ассистента. */
private var assistantChoice: LlmModelChoice? = null
/** Активный локальный LLM-клиент (движок закрываем при смене выбора). */
private var localLlm: LocalLlmClient? = null
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
val llmPrefs = LlmPrefs(this)
private val _llmChoice = MutableStateFlow<LlmModelChoice>(llmPrefs.current())
val llmChoice: StateFlow<LlmModelChoice> = _llmChoice.asStateFlow()
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
private val _localLlmState = MutableStateFlow<LocalLlmModelState>(initialLocalLlmState())
val localLlmState: StateFlow<LocalLlmModelState> = _localLlmState.asStateFlow()
private var localLlmDownloadJob: Job? = null
private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
fun setLlmModelChoice(choice: LlmModelChoice) {
if (_llmChoice.value == choice) return
llmPrefs.set(choice)
_llmChoice.value = choice
synchronized(assistantLock) { assistant = null }
log("llm", "выбрана модель: ${choice.name}")
}
/**
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op.
*/
fun downloadLocalLlmModel() {
val current = localLlmDownloadJob
if (current != null && current.isActive) return
localLlmDownloadJob = scope.launch {
val target = localModelFile()
_localLlmState.value = LocalLlmModelState.Downloading(0)
try {
GemmaModelDownloader(target).download { percent ->
_localLlmState.value = LocalLlmModelState.Downloading(percent)
}
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
log("llm", "не удалось скачать модель: ${e.message}")
}
}
}
/** Остановить скачивание локальной модели (частичный файл удаляется). */
fun cancelLocalLlmDownload() {
localLlmDownloadJob?.cancel()
}
/**
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
* LiteRT-LM; при смене выбора ассистент создаётся заново.
*/
private fun ensureAssistant(): Assistant? {
synchronized(assistantLock) {
assistant?.let { return it }
if (BuildConfig.LLM_API_KEY.isBlank()) return null
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
val choice = _llmChoice.value
if (assistant != null) {
if (assistantChoice == choice) return assistant
assistant = null
}
if (choice == LlmModelChoice.REMOTE && BuildConfig.LLM_API_KEY.isBlank()) {
assistantChoice = choice
return null
}
runCatching { localLlm?.close() }
localLlm = null
val llm = when (choice) {
LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
LlmModelChoice.LOCAL -> {
val cacheDir = File(filesDir, "litertlm-cache")
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
}
}
val toolsets = ToolsetRegistry()
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
val tk = toolkit ?: AgentToolkit(
@@ -116,7 +203,10 @@ class PhoneApp : Application() {
chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) },
toolkit = tk,
).also { assistant = it }
).also {
assistant = it
assistantChoice = choice
}
}
}
@@ -172,7 +262,7 @@ class PhoneApp : Application() {
log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant()
if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена")
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
continue
}
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
@@ -11,29 +11,47 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
*
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
* («Ассистент недоступен: …»), сам клиент не падает тихо.
*/
interface LlmClient {
/**
* [messages] — история диалога (последнее сообщение — текущее обращение),
* [system] — системный промпт тулкита/настроек или null (не обязателен).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
}
/**
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
* https://llm.binom.pw/v1/chat/completions.
*
* Коog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
* content на длинных историях (после tool-вызовов) — см. заметку
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
* модель Qwen3.8-27B-NVFP4 отвечает стабильно, тул-цикл работает.
* модель отвечает стабильно, тул-цикл работает.
*
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
* [http] инжектируется (дефолт — свой клиент с таймаутами).
*/
class LlmClient(
class RemoteLlmClient(
private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw",
private val model: String,
) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = OkHttpClient.Builder()
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
.build(),
) : LlmClient {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatMessageDto(
@@ -67,7 +85,7 @@ class LlmClient(
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
val dto = ArrayList<ChatMessageDto>()
if (system != null) dto += ChatMessageDto("system", system)
for (msg in messages) {
@@ -15,7 +15,7 @@ object LlmDebug {
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
return
}
val client = LlmClient(apiKey = apiKey, model = model)
val client = RemoteLlmClient(apiKey = apiKey, model = model)
try {
val reply = runBlockingSafe {
client.chat(
@@ -0,0 +1,28 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import android.content.SharedPreferences
/**
* Персистентный выбор модели LLM ассистента ([LlmModelChoice]) —
* SharedPreferences «llm-choice». Дефолт (пусто/неизвестно) — [LlmModelChoice.REMOTE].
*
* Чистая логика парсинга — [LlmModelChoice.parse] (JVM-тестируемая);
* этот класс только обёртка над SharedPreferences.
*/
class LlmPrefs(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun current(): LlmModelChoice = LlmModelChoice.parse(prefs.getString(KEY_MODEL_CHOICE, null))
fun set(choice: LlmModelChoice) {
prefs.edit().putString(KEY_MODEL_CHOICE, choice.name).apply()
}
private companion object {
const val PREFS_NAME = "llm-choice"
const val KEY_MODEL_CHOICE = "model_choice"
}
}
@@ -0,0 +1,353 @@
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
/**
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
* переключается UI-блоком в настройках экрана «Телефон»:
* - [REMOTE] — сервер llm.binom.pw ([RemoteLlmClient]: OpenAI-совместимый HTTP);
* - [LOCAL] — Gemma 4 E2B на телефоне ([LocalLlmClient], LiteRT-LM, GPU/CPU).
*/
enum class LlmModelChoice {
REMOTE, LOCAL;
companion object {
/** Значение из настроек; пустое/неизвестное имя → [REMOTE] (дефолт). */
fun parse(name: String?): LlmModelChoice =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: REMOTE
}
}
/**
* Gemma 4 E2B (LiteRT-LM): локальный LLM на телефоне.
* Модель — файл [FILE_NAME] в getExternalFilesDir(null) телефона;
* докачивается внутри приложения (см. [GemmaModelDownloader]).
* API-ноты: docs/litertlm-openassistant-notes.md в репо.
*/
object Gemma4E2B {
const val FILE_NAME = "gemma-4-E2B-it.litertlm"
const val SHA256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c"
/** Размер файла для UI (2 588 147 712 байта). */
const val SIZE_HUMAN = "≈2.5 ГБ"
/** Источники скачивания по приоритету: HuggingFace (открытый), затем зеркало. */
val DOWNLOAD_URLS = listOf(
"https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm",
"https://data.vayunmathur.com/models/gemma-4-E2B-it.litertlm",
)
/**
* Конфиг движка: GPU-бэкенд (Adreno/Mali), каудирование CPU, кэш в
* [cacheDir] (cacheDir — не tmpDir, см. заметку про /data/local/tmp).
*/
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
modelPath = modelFile.absolutePath,
backend = Backend.GPU(),
visionBackend = Backend.GPU(),
audioBackend = Backend.CPU(),
cacheDir = cacheDir.absolutePath,
)
}
// ---------- Чистые вспомогательные (JVM-тестируемые) ----------
/** hex SHA-256 содержимого файла потоково (по 64 КБ — файл ~2.5 ГБ). */
fun sha256HexOfFile(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(64 * 1024)
while (true) {
val readSize = input.read(buf)
if (readSize < 0) break
digest.update(buf, 0, readSize)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/** true, если [file] существует и его SHA-256 совпадает с [expectedHex]. */
fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
file.isFile && sha256HexOfFile(file).equals(expectedHex, ignoreCase = true)
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
class LocalLlmPrompt(
val systemInstruction: String?,
val initialMessages: List<Message>,
val incoming: Message,
)
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
.joinToString("") { it.text }
/**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [Message.user], ASSISTANT → [Message.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
* - пустые content отбрасываются.
*
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
* Если последние сообщения только модельные/история пуста — 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>()
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.TOOL -> if (text.isNotBlank()) {
mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
}
}
}
val last = messages.lastOrNull { it.content.isNotBlank() }
val incoming: Message
val initial: List<Message>
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("Продолжай."))
}
return LocalLlmPrompt(
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
initialMessages = initial,
incoming = incoming,
)
}
// ---------- Скачивание модели ----------
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
sealed interface LocalLlmModelState {
/** Модель не скачана (или ещё не скачивалась). */
object Idle : LocalLlmModelState
/** Скачивание в пути; [percent] — 0..99. */
data class Downloading(val percent: Int) : LocalLlmModelState
/** Модель скачана и проверена (SHA-256 совпал при скачивании). */
object Ready : LocalLlmModelState
/** Ошибка скачивания: [message]. */
data class Error(val message: String) : LocalLlmModelState
}
/**
* Скачивание Gemma 4 E2B ([Gemma4E2B.DOWNLOAD_URLS] по порядку):
* OkHttp-стриминг в файл .part, прогресс в процентах через колбэк,
* SHA-256-контроль после загрузки (несовпал — удалить и взять следующий
* исходник), после успешного — перенос на [target]. Прогресс — из IO-потока
* (колбэк должен быть потоковезбёчным; в PhoneApp — запись в StateFlow).
*/
class GemmaModelDownloader(
private val target: File,
private val urls: List<String> = Gemma4E2B.DOWNLOAD_URLS,
private val expectedSha256: String = Gemma4E2B.SHA256,
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build(),
) {
/**
* Скачать модель в [target]. Бросает [IOException] «Модель не скачалась…»
* (все источники исчерпаны / контрольная сумма). [onProgressPercent] —
* 0..99 во время скачивания (вызывается из IO-потока).
*/
suspend fun download(onProgressPercent: (Int) -> Unit) {
target.parentFile?.mkdirs()
val partial = File(target.parentFile, target.name + ".part")
var lastError: String? = null
for (url in urls) {
var ok: Boolean
try {
ok = downloadFrom(url, partial, onProgressPercent)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ok = false
lastError = e.message ?: e.toString()
}
log("llm", "скачивание модели из $url: ${if (ok) "ок" else "не удалось ($lastError)"}")
if (ok) {
log("llm", "модель скачана: ${target.length()} байт, SHA-256 совпадает")
return
}
partial.delete()
}
partial.delete()
throw IOException("Модель не скачалась (все источники): $lastError — повторите позже")
}
private suspend fun downloadFrom(
url: String,
partial: File,
onProgressPercent: (Int) -> Unit,
): Boolean = withContext(Dispatchers.IO) {
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) {
log("llm", "HTTP ${response.code} с $url")
return@withContext false
}
val body = response.body ?: return@withContext false
val totalBytes = body.contentLength()
var received = 0L
var lastPercent = -1
partial.outputStream().use { out ->
body.byteStream().use { input ->
val buf = ByteArray(256 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
received += n
val percent = if (totalBytes > 0) ((received * 100) / totalBytes).toInt()
else ((received / (4 * 1024 * 1024)) * 25).toInt().coerceAtMost(99)
if (percent > lastPercent) {
lastPercent = percent
log("llm", "модель: скачано ${received / (1024 * 1024)} МБ" +
(if (totalBytes > 0) " ($percent%)" else ""))
onProgressPercent(percent)
}
}
}
}
if (!sha256OfFileMatches(partial, expectedSha256)) {
log("llm", "SHA-256 модели не совпал — перезаписываем")
partial.delete()
return@withContext false
}
partial.renameTo(target)
true
}
}
}
// ---------- Клиент ----------
/**
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
*
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено.
*
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
*/
class LocalLlmClient(
private val modelFile: File,
private val cacheDir: File,
) : LlmClient {
private val engineLock = Any()
@Volatile
private var engine: Engine? = null
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
val engine = ensureEngine()
val prompt = buildLocalLlmPrompt(messages, system)
val conversation: Conversation = synchronized(engineLock) {
engine.createConversation(
ConversationConfig(
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
initialMessages = prompt.initialMessages,
)
)
}
try {
var text = ""
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
text += messageChunk.text()
}
return text.trim()
} catch (e: CancellationException) {
runCatching { conversation.cancelProcess() }
throw e
} finally {
runCatching { conversation.close() }
}
}
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() {
val eng = synchronized(engineLock) {
val e = engine
engine = null
e
}
runCatching { eng?.close() }
}
/** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */
@OptIn(ExperimentalApi::class)
private fun applyExperimentalFlags() {
ExperimentalFlags.enableSpeculativeDecoding = true
}
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
private fun ensureEngine(): Engine {
val cached = engine
if (cached != null) return cached
return synchronized(engineLock) {
val existing = engine
if (existing != null) return existing
if (!modelFile.isFile) {
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
}
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
}
applyExperimentalFlags()
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
created
}
}
}
@@ -44,7 +44,29 @@ data class ChatMessage(
val toolCalls: List<ToolCall>,
val attachments: List<Attachment>,
val createdAt: Long,
)
) {
companion object {
/**
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
*/
fun of(
role: ChatRole,
content: String,
toolCallId: String? = null,
toolCalls: List<ToolCall> = emptyList(),
) = ChatMessage(
id = 0L,
sessionId = 0L,
role = role,
content = content,
toolCallId = toolCallId,
toolCalls = toolCalls,
attachments = emptyList(),
createdAt = 0L,
)
}
}
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
@@ -41,6 +41,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.Session
/**
@@ -151,6 +152,16 @@ fun ChatScreen() {
}
}
Spacer(Modifier.height(2.dp))
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState()
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.height(4.dp))
// Лента сообщений
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -24,6 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.Gemma4E2B
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import kotlin.math.roundToLong
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
}
}
}
Spacer(modifier = Modifier.height(12.dp))
LlmModelSection()
}
}
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
)
}
}
/**
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
*/
@Composable
private fun LlmModelSection() {
val app = PhoneApp.instance
val choice by app.llmChoice.collectAsState()
val llmState by app.localLlmState.collectAsState()
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("Модель LLM ассистента")
StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
LlmChoiceChip(
label = "сервер",
selected = choice == LlmModelChoice.REMOTE,
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
)
Spacer(Modifier.width(8.dp))
LlmChoiceChip(
label = "локальная",
selected = choice == LlmModelChoice.LOCAL,
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
)
}
Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) {
when (val s = llmState) {
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
}
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProgressBar(
percent = s.percent,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)",
color = PhoneGreen,
fontSize = 11.sp,
)
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ошибка скачивания: ${s.message}",
color = PhoneRed,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
}
} else {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
}
}
}
}
/** Чип переключателя выбора модели (активный — акцентный фон). */
@Composable
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(if (selected) PhoneAccent else PhoneSurface)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (selected) PhoneBackground else PhoneTextDim,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
@Composable
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
Text(
text = label,
color = PhoneAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@@ -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())
}
}