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-потока). */