From 75496dd35d20b06f6a66b24816cee0df69de9096 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 23 Aug 2026 04:38:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20LLM:=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC=D0=B0=D1=83=D1=82=20300=D1=81=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20LOCAL=20(=D0=B8=D0=BD=D0=B8=D1=86=D0=B8=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20~17=D1=81=20+=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F),=20CPU-=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BF=D0=B5=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D1=8F=D1=86=D0=B8=D0=B8=20(MTP=20=D0=B2=D0=B8=D1=81=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BD=D0=B0=20CPU,=20GPU-=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D1=82=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82?= =?UTF-8?q?),=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20chat()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверено на железе (HONOR FCP-N49): Gemma 4 E2B отвечает «Здравствуйте! Чем я могу вам помочь с вашими очками Viewmate?» (15 чанков, 61 символ, ~19с генерации). --- .../pw/binom/viewmate/phone/PhoneApp.kt | 17 +++++-- .../pw/binom/viewmate/phone/agent/LocalLlm.kt | 47 +++++++++++++++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 302d85e..7bcce62 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -112,13 +112,16 @@ class PhoneApp : Application() { private var localLlm: LocalLlmClient? = null /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */ - val llmPrefs = LlmPrefs(this) - private val _llmChoice = MutableStateFlow(llmPrefs.current()) - val llmChoice: StateFlow = _llmChoice.asStateFlow() + // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и + // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость — + // тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте). + val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) } + private val _llmChoice: MutableStateFlow by lazy { MutableStateFlow(llmPrefs.current()) } + val llmChoice: StateFlow by lazy { _llmChoice.asStateFlow() } /** Состояние локальной модели Gemma 4 E2B для UI настроек. */ - private val _localLlmState = MutableStateFlow(initialLocalLlmState()) - val localLlmState: StateFlow = _localLlmState.asStateFlow() + private val _localLlmState: MutableStateFlow by lazy { MutableStateFlow(initialLocalLlmState()) } + val localLlmState: StateFlow by lazy { _localLlmState.asStateFlow() } private var localLlmDownloadJob: Job? = null private fun initialLocalLlmState(): LocalLlmModelState = @@ -203,6 +206,10 @@ class PhoneApp : Application() { chatDao = chatDao, chat = { history, system -> llm.chat(history, system) }, toolkit = tk, + // Локальной модели нужно время: инициализация движка ~17с на + // CPU + генерация. Серверная укладывается в 30с, локальной + // даём 5 минут (иначе withTimeoutOrNull режет генерацию). + timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L, ).also { assistant = it assistantChoice = choice diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt index 2e39f1e..2d19643 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt @@ -13,6 +13,7 @@ import com.google.ai.edge.litertlm.Message import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.OkHttpClient import okhttp3.Request import java.io.File @@ -22,6 +23,17 @@ import java.util.concurrent.TimeUnit private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message) +/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */ +private fun sanitize(msg: String?): String { + if (msg == null) return "null" + val sb = StringBuilder() + for (ch in msg.take(200)) { + val c = ch.code + if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch) + } + return sb.toString() +} + /** * Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp), * переключается UI-блоком в настройках экрана «Телефон»: @@ -60,11 +72,15 @@ object Gemma4E2B { /** * Конфиг движка: GPU-бэкенд (Adreno/Mali), каудирование CPU, кэш в * [cacheDir] (cacheDir — не tmpDir, см. заметку про /data/local/tmp). + * NOTE 2026-08-23: на Honor Magic V3 (FCP-N49) GPU-бэкенд litertlm 0.14.0 + * падает в ml_drift-делегате (delegate_kernel.cc:925) — движок не создаётся. + * Пробуем CPU (decode ~46 ток/с на 8 Gen 3 — приемлемо); вернуть GPU, + * когда починится делегат (или обновится litertlm). */ fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig( modelPath = modelFile.absolutePath, - backend = Backend.GPU(), - visionBackend = Backend.GPU(), + backend = Backend.CPU(), + visionBackend = Backend.CPU(), audioBackend = Backend.CPU(), cacheDir = cacheDir.absolutePath, ) @@ -284,8 +300,11 @@ class LocalLlmClient( fun modelDownloaded(): Boolean = modelFile.isFile override suspend fun chat(messages: List, system: String?): String { + log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)") val engine = ensureEngine() + 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( @@ -294,15 +313,30 @@ class LocalLlmClient( ) ) } + log("llm", "chat: conversation создан") try { var text = "" - conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> - text += messageChunk.text() + var chunks = 0 + withTimeout(120_000) { + conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> + chunks++ + val chunkText = messageChunk.text() + text += chunkText + if (chunks <= 3 || chunks % 20 == 0) { + log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}") + } + } } + log("llm", "chat: готово, чанков=$chunks, текст=${text.length} симв") 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() } } @@ -321,7 +355,10 @@ class LocalLlmClient( /** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */ @OptIn(ExperimentalApi::class) private fun applyExperimentalFlags() { - ExperimentalFlags.enableSpeculativeDecoding = true + // NOTE 2026-08-23: на CPU-бэкенде спекуляция (MTP) виснет — движок + // инициализируется, но sendMessageAsync не выдаёт токены. GPU-делегат + // на этом устройстве падает (см. engineConfig). Пока — без спекуляции. + // ExperimentalFlags.enableSpeculativeDecoding = true } /** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */