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()