diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml index ffef1be..fb74be6 100644 --- a/app-phone/src/main/AndroidManifest.xml +++ b/app-phone/src/main/AndroidManifest.xml @@ -27,6 +27,15 @@ android:allowBackup="true" android:usesCleartextTraffic="true"> + + + + + diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt new file mode 100644 index 0000000..e4a2e5c --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt @@ -0,0 +1,148 @@ +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.LocalLlmClient +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.ServerSocket + +/** + * Отладочный HTTP-сервер в приложении (только для разработки). + * Позволяет дёргать ассистента и переключать модель без UI: + * + * GET /status — выбор модели, готовность движка, размер файла + * POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI) + * POST /model {"choice":"local"} — переключить модель (local|remote), персист + * + * Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`. + */ +object DebugHttpServer { + + private const val PORT = 8787 + private const val TAG = "httpd" + + @Volatile + private var running = false + + fun start(app: PhoneApp) { + if (running) return + running = true + Thread { + try { + val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1")) + log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)") + while (running) { + try { + val socket = server.accept() + Thread { + try { + handle(socket, app) + } catch (e: Exception) { + log(TAG, "обработка запроса: ${e.message}") + } finally { + runCatching { socket.close() } + } + }.apply { isDaemon = true }.start() + } catch (e: Exception) { + if (running) log(TAG, "accept: ${e.message}") + } + } + } catch (e: Exception) { + log(TAG, "не удалось запустить: ${e.message}") + } + }.apply { isDaemon = true; name = "viewmate-httpd" }.start() + } + + private fun handle(socket: java.net.Socket, app: PhoneApp) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) + val requestLine = reader.readLine() ?: return + val parts = requestLine.split(" ") + if (parts.size < 2) return + val method = parts[0] + val path = parts[1] + + // Тело (до Content-Length) + var contentLength = 0 + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + if (line.startsWith("Content-Length:", true)) { + contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0 + } + } + val body = if (contentLength > 0) { + val buf = CharArray(contentLength) + var read = 0 + while (read < contentLength) { + val n = reader.read(buf, read, contentLength - read) + if (n < 0) break + read += n + } + String(buf, 0, read) + } else "" + + val response: String + val code: Int + when { + method == "GET" && path.startsWith("/status") -> { + val choice = app.llmChoice.value + val local = app.localLlmClient() + val file = app.localModelFile() + response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}""" + code = 200 + } + method == "POST" && path.startsWith("/chat") -> { + val text = jsonField(body, "text") ?: "" + if (text.isBlank()) { + response = """{"error":"пустой text"}""" + code = 400 + } else { + app.sendToAssistant(text) + response = """{"ok":true,"queued":"${text.take(60)}"}""" + code = 200 + } + } + method == "POST" && path.startsWith("/model") -> { + val choice = jsonField(body, "choice") + if (choice == "local" || choice == "remote") { + app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE) + response = """{"ok":true,"choice":"$choice"}""" + code = 200 + } else { + response = """{"error":"choice должен быть local|remote"}""" + code = 400 + } + } + else -> { + response = """{"error":"нет такого пути: $method $path"}""" + code = 404 + } + } + + val out = socket.getOutputStream() + val payload = response.toByteArray(Charsets.UTF_8) + out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8)) + out.write(payload) + out.flush() + } + + private fun localStatus(local: LocalLlmClient?, file: java.io.File): String { + if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}""" + return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}""" + } + + private fun jsonField(body: String, key: String): String? { + // {"text":"..."} — примитивный парсер, без кавычек внутри значения + val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"") + return regex.find(body)?.groupValues?.get(1) + } + + private fun reason(code: Int): String = when (code) { + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + else -> "Error" + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt index 93d7601..d88f6cd 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt @@ -1,6 +1,18 @@ package pw.binom.viewmate.phone -/** Единый лог телефона: простой println (работает и в unit-тестах). */ +import android.util.Log + +/** + * Единый лог телефона. + * На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах + * (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println), + * чтобы не падать. + */ fun log(tag: String, message: String) { - println("[view-mate] $tag $message") + val full = "[view-mate] $tag $message" + runCatching { + Log.d("view-mate-$tag", full) + }.onFailure { + println(full) + } } 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 7bcce62..0da379e 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 @@ -111,6 +111,9 @@ class PhoneApp : Application() { /** Активный локальный LLM-клиент (движок закрываем при смене выбора). */ private var localLlm: LocalLlmClient? = null + /** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */ + fun localLlmClient(): LocalLlmClient? = localLlm + /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */ // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость — @@ -191,7 +194,10 @@ class PhoneApp : Application() { val llm = when (choice) { LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) LlmModelChoice.LOCAL -> { - val cacheDir = File(filesDir, "litertlm-cache") + // cacheDir — подкаталог cache/litertlm: в 11:46 именно с ним + // (без спекуляции) движок инициализировался за 12с; с корнем + // cache/ initialize() виснет. GPU-делегату нужен СВОЙ каталог. + val cacheDir = File(cacheDir, "litertlm") LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it } } } @@ -395,6 +401,7 @@ class PhoneApp : Application() { server.start() log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") + DebugHttpServer.start(this) nsd.publish() server.hub.sttFactory = { ensureStt() } scope.launch { assistantLoop() } 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 2d19643..9cb3321 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 @@ -70,17 +70,23 @@ 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). + * Конфиг движка — точь-в-точь как в референсе vayunmathur/Modern-Apps + * (InferenceService.kt): GPU для основного и vision, CPU для аудио. + * Ключ: cacheDir должен быть системным applicationContext.cacheDir — + * GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров; + * свой подкаталог в filesDir приводил к падению на delegate_kernel.cc. */ fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig( modelPath = modelFile.absolutePath, - backend = Backend.CPU(), - visionBackend = Backend.CPU(), + // NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497) + // в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на + // этом HONOR гоняет весь движок (включая vision-сабаграф) на WebGPU; ранее + // мы режут vision на CPU из-за 128 МБ-лимита буфера, но чистый текст-чат + // vision-путь не трогает (буфер аллокируется только при картинке), поэтому + // берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на + // картинке — вернём vision на CPU точечно. + backend = Backend.GPU(), + visionBackend = Backend.GPU(), audioBackend = Backend.CPU(), cacheDir = cacheDir.absolutePath, ) @@ -317,17 +323,30 @@ class LocalLlmClient( try { var text = "" var chunks = 0 + var tokens = 0 + val t0 = System.currentTimeMillis() + var firstTokenAt = 0L withTimeout(120_000) { + // NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт + // emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом + // dex null — артефакт маски $default, не аргумент. conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> chunks++ val chunkText = messageChunk.text() text += chunkText + if (chunkText.isNotEmpty()) { + if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() + tokens += 1 + } if (chunks <= 3 || chunks % 20 == 0) { log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}") } } } - log("llm", "chat: готово, чанков=$chunks, текст=${text.length} симв") + val totalMs = System.currentTimeMillis() - t0 + val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs + val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0 + log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)") return text.trim() } catch (e: CancellationException) { log("llm", "chat: отменено") @@ -352,13 +371,15 @@ class LocalLlmClient( runCatching { eng?.close() } } - /** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */ + /** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */ @OptIn(ExperimentalApi::class) private fun applyExperimentalFlags() { - // NOTE 2026-08-23: на CPU-бэкенде спекуляция (MTP) виснет — движок - // инициализируется, но sendMessageAsync не выдаёт токены. GPU-делегат - // на этом устройстве падает (см. engineConfig). Пока — без спекуляции. - // ExperimentalFlags.enableSpeculativeDecoding = true + // 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-потока). */ @@ -375,6 +396,11 @@ class LocalLlmClient( throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново") } applyExperimentalFlags() + // cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет + // скомпилированные шейдеры в cacheDir; если каталога нет — движок + // падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд + // в кэш не пишет, поэтому работал и без mkdirs. + cacheDir.mkdirs() val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir)) try { created.initialize()