diff --git a/TASK-llm-interface.md b/TASK-llm-interface.md new file mode 100644 index 0000000..75f8272 --- /dev/null +++ b/TASK-llm-interface.md @@ -0,0 +1,75 @@ +# Задача: интерфейс LLM + две реализации (Qwen удалённо / Gemma E2B локально) + переключатель + +Дизайн — TASK-local-llm.md. Заметка с разбором litertlm (скопирована в репо, читай её): `docs/litertlm-openassistant-notes.md`. Полная архитектура агента (тулсеты/скиллы) — TASK-agent-tools.md. Реализуй в `app-phone`. + +## Контекст + +- Сейчас `LlmClient` — класс с прямым OkHttp к `https://llm.binom.pw/v1/chat/completions` (Qwen3.8-27B-NVFP4). Файл: `app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt` (НЕ app-phone/agent/ — путь с src/main/kotlin!). Вызывается из `PhoneApp.ensureAssistant()` (app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt) — создаётся один раз и передаётся в `Assistant(chat = { history, system -> llm.chat(history, system) })`. +- Нужно: **интерфейс** + **две реализации** + **ручной глобальный переключатель** в UI телефона. В будущем всё должно работать оффлайн. +- Модель для локальной: **Gemma 4 E2B (2B)**, формат `.litertlm` (LiteRT LM, Google). Разбор — в `docs/litertlm-openassistant-notes.md` (скопирован в репо — читай его, не /root/notes/). + +## Что сделать + +### 1. Интерфейс LlmClient + +Превратить `LlmClient` в интерфейс (или добавить интерфейс-обёртку): + +```kotlin +interface LlmClient { + suspend fun chat(messages: List, system: String? = null): String +} +``` + +- Существующий класс → `RemoteLlmClient` (реализация интерфейса, код не менять по сути — только implements). +- Все использования (PhoneApp.ensureAssistant) — через интерфейс. + +### 2. Локальная реализация — `LocalLlmClient` (Gemma 4 E2B, LiteRT LM) + +По заметке `/root/notes/openassistant-lite-llm.md` (секция «Две нейронки + токенайзер», «Как повторить», «Скорость»): + +- **Зависимость**: `com.google.ai.edge.litertlm:litertlm-android:0.14.0` в app-phone/build.gradle.kts (репозиторий — google(), у нас в settings должен быть; проверить). + **ВАЖНО**: litertlm 0.14.0 требует **kotlinx-coroutines 1.11.0** (иначе NoSuchMethodError close$default на SendChannel — подтверждено автором Modern-Apps, см. его build.gradle.kts:47-48). Поднять версию kotlinx-coroutines в gradle/libs.versions.toml с 1.10.2 до 1.11.0 — проверить, что ничего не ломается (тесты после этого обязаны быть зелёными). +- **Модель**: файл `gemma-4-E2B-it.litertlm` (SHA256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`), ~0.8 ГБ. Хранить в `getExternalFilesDir(null)` (как у автора). **НЕ класть в APK** (большой) — загружается отдельно. +- **Загрузка**: если файла нет — скачать с зеркала (автор: `https://data.vayunmathur.com/models/`, но у нас доступнее HF: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/<файл>`), SHA256-проверка. Сделай через OkHttp (уже есть в проекте) + поток в файл, с прогрессом (лог). +- **Запуск** (из заметки): + - `Engine(EngineConfig(modelPath, backend = GPU(), visionBackend = GPU(), audioBackend = CPU(), cacheDir))` + - `ExperimentalFlags.enableSpeculativeDecoding = true` **ПЕРЕД** `engine.initialize()` (спекуляция у Gemma 4 встроена в архитектуру — MTP, флаг включает рантайм-путь) + - `ConversationConfig(systemInstruction, initialMessages, tools?, automaticToolCalling?)` + `sendMessageAsync` стриминг +- **Chat**: перевести `List` + system в формат litertlm (initialMessages — история, systemInstruction — system). Вернуть накопленный текст ответа. Стриминг можно агрегировать (не обязательно гнать по частям наружу — контракт `chat(): String`). +- **Обработка ошибок**: модель не загружена → понятное сообщение («локальная модель не скачана, скачайте в настройках»), инициализация упала → текст ошибки. НЕ падать. +- **Threading**: Engine litertlm требует аккуратности с потоками — смотри пример автора (InferenceService.kt). Скорее всего вызовы идут из корутины — оберни в withContext(Dispatchers.IO) и соблюдай требования движка. +- **Время жизни**: Engine создаётся один раз (как ассистент), переиспользуется; инициализация — лениво при первом вызове (модель может весить ~1-2 ГБ в RAM). + +### 3. Переключатель (ручной, глобальный) + +- **Состояние**: `LlmmodelChoice` = `REMOTE` | `LOCAL` (или enum с именами). Хранить в SharedPreferences (переживает рестарт). Global: влияет на весь ассистент, не на сессию. +- **Фабрика**: `PhoneApp.ensureAssistant()` выбирает реализацию по сохранённому выбору: REMOTE → `RemoteLlmClient`, LOCAL → `LocalLlmClient`. Переключение — пересоздать ассистента (или хотя бы llm-ссылку) при изменении выбора. +- **UI**: на экране «Информация об очках» (GlassesInfoScreen, там уже есть InfoRow) — или лучше отдельный блок в GlassesScreen — добавить переключатель «Модель ассистента: Qwen (сервер) / Gemma (локально)». Компонент: два варианта (радио-кнопки или сегментированная кнопка), плюс статус: для LOCAL — «модель скачана/не скачана», кнопка «Скачать модель» (с прогрессом), для REMOTE — «сервер». +- **Индикатор активной модели** — в чате (ChatScreen) мелкой подписью «модель: qwen/gemma» (не обязательно, но желательно — пользователь должен видеть, кто отвечает). + +### 4. Проверка/тесты + +- Юнит-тесты (JVM, без Android — что можно): + - Выбор реализации по настройке (фабрика/логика выбора — чистая часть). + - Формирование промпта для litertlm (маппинг ChatMessage → initialMessages) — если вынести в чистую функцию. + - SHA256-проверка файла (чистая функция). +- Сборка: `./gradlew :app-phone:testDebugUnitTest` и `:app-phone:assembleDebug` — зелёные (все старые 142 теста остаются зелёными). + +## Ограничения + +- Код только Kotlin +- НЕ трогать механики тулсетов/скиллов (AgentToolkit/Skills/Toolsets), Assistant.kt — менять только точку создания LlmClient +- НЕ ломать существующие экраны +- НЕ коммитить + +## Проверка на железе (после твоей части) + +1. Сборка + установка на телефон (142, adb). +2. Загрузка модели (нужен доступ к HF — проверь, скачай на машину, залей через adb, если HF с телефона недоступен). +3. Переключение REMOTE ↔ LOCAL, общение с ассистентом на каждой. +4. Оффлайн-тест: `svc wifi disable` → локальная модель отвечает, удалённая — понятная ошибка. +5. Замер скорости локальной (ток/с из логов). + +## Ответ + +Опиши: какие файлы создал/изменил, как устроен интерфейс, как инициализируется Engine, где переключатель, что осталось проверить на железе (особенно: работает ли litertlm на нашем устройстве, скачивается ли модель). diff --git a/TASK-media-tools.md b/TASK-media-tools.md new file mode 100644 index 0000000..bd5a233 --- /dev/null +++ b/TASK-media-tools.md @@ -0,0 +1,65 @@ +# Задача: медиа-тулы для тулсета media (агент «Порфирий») + +Механика тулсетов/скиллов УЖЕ готова (AgentToolkit, Skills, Toolsets — см. TASK-agent-tools.md, закоммичено c5cbc89). Твоя задача — **реализовать сам тулсет media с исполнителем** на базе Jellyfin. Чистую механику НЕ трогать. + +## Контекст (что уже есть) + +- `AgentToolkit` (app-phone/.../agent/AgentToolkit.kt): ядро 5 тул + диспач + подмена. Регистрация тулсета: `ToolsetRegistry.register(Toolset(...), executor)`. +- `Toolset`/`ToolSignature`/`ToolsetExecutor` (Toolsets.kt): сигнатуры с префиксом `media_`, аргументы строками (`query`, `id`, `seek_ms?`). +- `PhoneApp.kt` уже создаёт `AgentToolkit(skills=..., toolsets=ToolsetRegistry(), showText=...)` — тулсеты пустые, тулы не зарегистрированы. +- `JellyfinClient` (lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt): + - `suspend fun search(userId: String, query: String): List` + - `suspend fun item(userId: String, itemId: String): JellyfinItem?` + - `suspend fun allItems(userId: String, types: List): List` + - `fun posterUrl(itemId: String, maxWidth: Int): String` +- `JellyfinItem` (JellyfinModels.kt): Id, Name, Type, Overview, ImageTags, SeriesName, SeasonId, IndexNumber, ParentIndexNumber, RunTimeTicks. +- В PhoneApp: `jellyfin: JellyfinClient`, `chatDao`, `server.hub` (broadcast), `actions` (воспроизведение), `state`. + +## Что сделать + +### 1. Тулсет media (зарегистрировать в PhoneApp) + +Тулы (имена БЕЗ префикса — префикс `media_` добавляется механизмом): + +- `search` — поиск фильмов/сериалов по названию. Аргументы: `query` (обязательный), `limit?` (опциональный, дефолт 5). + - Возврат: список строк «id: Название (Год) — Тип» (до limit), либо «ничего не найдено по запросу ...». +- `info` — детали элемента по id. Аргументы: `id` (обязательный). + - Возврат: Название, Тип, Год, Обзор (Overview, обрезать до ~300 символов), длительность (RunTimeTicks/10_000_000 сек), есть ли постер. +- `play` — запуск воспроизведения на очках. Аргументы: `id` (обязательный), `seek_ms?` (опциональный). + - Проверки: очки подключены (hub.connected > 0), видео скачано на очки (локальный путь file:////video.mkv существует на очках — через hub/каталог), звук готов на телефоне (audioReady). + - Воспроизведение — через существующий механизм DetailsScreen (actions.watch(item.Id, audioIndex, localVideoPath, phoneAudioReady)) — вынеси/переиспользуй логику, не дублируй криво. + - Возврат: «запущено: <Название>» или понятная ошибка с причиной («очки не подключены», «видео не скачано на очки — скачай вручную», «звук не готов»). + +### 2. Юзер-ID для Jellyfin + +Jellyfin требует userId. Посмотри, как DetailsScreen/CatalogScreen получают userId (вероятно `app.jellyfin.users()` первый или конфиг). Используй тот же механизм, закешируй. + +### 3. Скилл media (файл skills/media.md) + +Формат (как в Skills.kt): +``` +--- +name: media +description: поиск фильмов, детали, запуск/управление просмотром. +--- +Текст скилла: объясни модели, что для работы с медиа вызвать enable_toolset|media, и опиши тулы search/info/play с аргументами. +``` + +### 4. Тесты (JVM, без Android — по возможности) + +Механику тестировать НЕ надо (уже покрыта). Но исполнитель media_search можно покрыть, если JellyfinClient абстрагируется (интерфейс/fake). Если JellyfinClient — конкретный класс с Ktor, сделай минимальный интерфейс `MediaSource` (search/item) и обёртку — тогда executor тестируется на фейке. Не усложняй сверх меры: если это требует большого рефакторинга, опиши, что осталось непокрытым. + +## Ограничения + +- Код только Kotlin +- НЕ трогать AgentToolkit/Skills/Toolsets (механика) — только регистрация тулсета и его исполнитель +- НЕ ломать существующие экраны (DetailsScreen/CatalogScreen) +- Все новые файлы — в app-phone (агент живёт на телефоне) +- Сборка: `./gradlew :app-phone:assembleDebug` — зелёная; `./gradlew :app-phone:testDebugUnitTest` — зелёный (все старые тесты остаются зелёными) +- НЕ коммитить + +## Проверка + +1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (старые + новые) +2. `./gradlew :app-phone:assembleDebug` — собирается +3. Опиши в ответе: какие файлы создал/изменил, как зарегистрирован тулсет, как получается userId, что осталось непокрыто тестами. diff --git a/TASK-opencode.md b/TASK-opencode.md new file mode 100644 index 0000000..0445293 --- /dev/null +++ b/TASK-opencode.md @@ -0,0 +1,55 @@ +# Задача: реализовать механику скиллов и тулсетов агента «Порфирий» + +Прочитай ТЗ: TASK-agent-tools.md (в корне репо). Реализуй ТОЛЬКО механику, БЕЗ привязки к медиа-данным (медиа-тулы будут позже, отдельно). + +## Объём работ + +### 1. Механика скиллов +- Абстракция `Skill`: name, description (триггер, одна строка), body (текст) +- `SkillRepository`: читает скиллы из папки `files/skills/` (YAML frontmatter: `---\nname: ...\ndescription: ...\n---\n` + markdown body) +- `SkillRegistry`: индекс (имя + триггер) для промпта; `readSkill(name)` возвращает полное содержимое +- Скиллов реальных пока НЕТ (механика работает на пустом каталоге или тестовых скиллах в юнит-тестах) + +### 2. Механика тулсетов +- `Toolset`: name, описание набора (для каталога), список тул (имя с префиксом набора + описание + сигнатура аргументов) +- `ToolsetRegistry`: активность НА СЕССИЮ (Map сессия→(набор→deadline)), хипп, таймаут 10 минут с последнего вызова тулы набора, проверка при сборке промпта (никаких фоновых таймеров) +- `enable_toolset|имя`: идемпотентно (повторный = продлить), неактивный вызов НЕ пишется в историю (системная тула-невидимка) +- `disable_toolset|имя`: снять, идемпотентно («нет активных наборов» — не ошибка) +- Неизвестное имя набора — «набора X нет, доступны: ...» + +### 3. Сборщик промпта (три состояния) +- Состояние 1 (набор не активен): ядро + каталог тулсетов + индекс скиллов +- Состояние 2 (активация): enable вызывает пересборку, вызов в историю НЕ пишется +- Состояние 3 (активен): ядро + описания тул набора + «Активные тулсеты: имя» + скилл, связанный с набором, УБРАН из промпта +- Промпт пересобирается на каждый запрос + +### 4. Бесшовная подмена вызова +- Вызов тулы набора при НЕактивном наборе (например media_search): НЕ выполнять, подменить как будто вызван enable_toolset|media, ответить «Тулсет media активирован. Реестр: ... Поиск ещё НЕ выполнялся. Для поиска вызови media_search с аргументом query.» +- Если аргументы вызова совпадают со схемой из реестра — можно выполнить сразу + +### 5. Обработчик вызовов +- Префиксная маршрутизация: имя тулы с префиксом набора (media_*) → набор +- Ядро-тулы: get_current_time (есть), show_text (есть ShowText), read_skill, enable_toolset, disable_toolset +- Все тулы идемпотентные, прощающие, с понятными ошибками +- Валидация аргументов по реестру + +### 6. Юнит-тесты (JVM, без Android) — ОБЯЗАТЕЛЬНО +Покрыть механику полностью: +- сборка промпта: 3 состояния +- подмена вызова при неактивном наборе (включая «поиск ещё не выполнен») +- идемпотентность enable/disable +- таймаут (инжектируемые часы — не ждать реальные 10 минут) +- активность на сессию (переключение сессий) +- префиксная маршрутизация, валидация аргументов +- read_skill: чтение из files/skills, парсинг frontmatter + +## Ограничения +- Код только Kotlin (как в проекте) +- НЕ трогать существующую логику ассистента без необходимости — встраиваться аккуратно +- НЕ делать реальные медиа-тулы (search по каталогу и т.п.) — это отдельный этап +- Юнит-тесты должны быть зелёными: ./gradlew :app-phone:testDebugUnitTest +- Не коммитить без подтверждения + +## Проверка +1. ./gradlew :app-phone:testDebugUnitTest — зелёные +2. ./gradlew :app-phone:assembleDebug — собирается diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 93ec3cf..c2c16ec 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -98,6 +98,9 @@ dependencies { implementation(libs.koog.openai.client) implementation(libs.koog.http.client.okhttp) + // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU) + implementation(libs.litertlm.android) + // Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6") 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 be5528f..302d85e 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 @@ -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(llmPrefs.current()) + val llmChoice: StateFlow = _llmChoice.asStateFlow() + + /** Состояние локальной модели Gemma 4 E2B для UI настроек. */ + private val _localLlmState = MutableStateFlow(initialLocalLlmState()) + val localLlmState: StateFlow = _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)) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt index a501333..b576f21 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt @@ -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, 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-текст «Результат тула : » (в Koog не было tool-роли). */ - suspend fun chat(messages: List, system: String? = null): String { + override suspend fun chat(messages: List, system: String?): String { val dto = ArrayList() if (system != null) dto += ChatMessageDto("system", system) for (msg in messages) { diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt index f76b837..d21345a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmDebug.kt @@ -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( diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmPrefs.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmPrefs.kt new file mode 100644 index 0000000..797b61a --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmPrefs.kt @@ -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" + } +} 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 new file mode 100644 index 0000000..2e39f1e --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LocalLlm.kt @@ -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, + val incoming: Message, +) + +/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */ +fun Message.text(): String = contents.contents.filterIsInstance() + .joinToString("") { it.text } + +/** + * Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция): + * - USER → [Message.user], ASSISTANT → [Message.model]; + * - TOOL (результат тула) — как user-сообщение в том же формате «Результат + * тула : <текст>», что шлёт [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, system: String? = null): LocalLlmPrompt { + val systemParts = ArrayList() + system?.takeIf { it.isNotBlank() }?.let { systemParts += it } + val mapped = ArrayList() + 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 + 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 = 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, 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 + } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt index a7e7672..0b757b0 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Models.kt @@ -44,7 +44,29 @@ data class ChatMessage( val toolCalls: List, val attachments: List, val createdAt: Long, -) +) { + companion object { + /** + * Фабрика для сообщений до записи в БД (тесты, сборка промпта): + * id/sessionId/createdAt — 0, пустые toolCalls/attachments. + */ + fun of( + role: ChatRole, + content: String, + toolCallId: String? = null, + toolCalls: List = 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 } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt index b0c0b12..1d834d5 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt @@ -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)) // Лента сообщений diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt index 62b3b85..99c1b30 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt @@ -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), + ) +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt new file mode 100644 index 0000000..c5285f7 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/LlmLocalTest.kt @@ -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 { + 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 { + runBlocking { dl.download { } } + } + assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message) + assertFalse(target.exists()) + } +} diff --git a/docs/litertlm-openassistant-notes.md b/docs/litertlm-openassistant-notes.md new file mode 100644 index 0000000..80d157e --- /dev/null +++ b/docs/litertlm-openassistant-notes.md @@ -0,0 +1,158 @@ +# OpenAssistant (vayun-mathur/Modern-Apps) — локальные нейронки на Android + +Источник: https://github.com/vayun-mathur/Modern-Apps/tree/main/openassistant +Клон: ~/WORK/modern-apps/ (depth 1). Разбор 2026-08-10. + +## Текстовые альтернативы E2B в формате .litertlm (проверено 2026-08-10, полка litert-community) +Вопрос: есть ли ТЕКСТОВЫЕ модели заметно умнее E2B (2B) за те же/чуть большие токены, тот же формат. + +| Модель | Парам. | Контекст | Размер | Декод на 8 Gen 3 | Оценка | +|---|---|---|---|---|---| +| Gemma 4 E2B (текущая) | 2B | 32K | ~0.8 ГБ | 20-35 т/с (спекуляция вкл) | база сравнения | +| Qwen3-8B (mixed int4) | 8B | 2048 (!) | 4.66 ГБ | ~10-12 т/с (оценка, телеф. бенча нет) | заметно умнее, но контекст урезан вдрызг | +| Phi-4-mini-instruct | 3.8B | 4096 | 3.9 ГБ | 10.39 т/с GPU (S24 Ultra, 8 Gen 3, динамик int8) | умнее, в 2 раза медленнее | +| Ministral-3-3B-Reasoning | 3.3B | 4096 | ~2.2 ГБ | нет телеф. бенча | reasoning, текст, но медленный по природе | +| DeepSeek-R1-Distill-Qwen-1.5B | 1.5B | 4096 | 1.83 ГБ | 26.35 т/с CPU (S24 Ultra) | не умнее, другой класс (reasoning) | +| Gemma2-2B-IT | 2B | — | — | — | доступ ограничен (restricted) | +| Gemma3-4B-IT | 4B | — | — | — | мультимодальная, не подходит по критерию | + +ВЫВОД: за "умнее + текст" платишь скоростью и контекстом. Phi-4-mini — единственный с честным замером на 8 Gen 3 (10.39 т/с — вдвое медленнее 20-25). Qwen3-8B — умнее всех, но 2048 контекст и ~10 т/с. E2B остаётся оптимумом для "незаметной" скорости (20+ т/с). + + +## Две нейронки + токенайзер + +### 1. Чат-LLM: Gemma 4 E2B (2B) — файл gemma-4-E2B-it.litertlm +- Формат .litertlm (LiteRT LM, Google; бывш. AI Edge / MediaPipe LLM Inference) +- Библиотека: com.google.ai.edge.litertlm:litertlm-android:0.14.0 +- Запуск: Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir)) + engine.initialize() +- Speculative decoding: ExperimentalFlags.enableSpeculativeDecoding = true +- Мультимодальный ввод: Content.Text / Content.ImageFile / Content.AudioFile (голос — WavRecorder, обрабатывается на CPU) +- Источник: huggingface.co/litert-community/gemma-4-E2B-it-litert-lm +- SHA256: 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c +- На диске: gemma4-2b.litertlm в getExternalFilesDir(null) + +### 2. Эмбеддер SigLIP2-base (patch16, 224) — семантический поиск по фото +- Служит фото-приложению через ResultReceiver (IPC): text/image/info-запросы +- ONNX Runtime: com.microsoft.onnxruntime:onnxruntime-android:1.27.0 +- Два файла: vision_model_fp16.onnx (картинки), text_model_int8.onnx (текст) +- Вектора 768-мерные (dim читается из модели), L2-нормированные, косинус +- Источник: huggingface.co/onnx-community/siglip2-base-patch16-224-ONNX +- SHA256 vision: a1959f7bd3993a607e48839f6d01e25b876fe76afda301b028b78eef68aabd95 +- SHA256 text: 3a0603d3a00c05a80a6ded4743c16aaac7b1e62cdcc7e362e7ce418659b96400 +- ВАЖНО: из выходов брать pooler_output (ранг-2), НЕ last_hidden_state (ранг-3), иначе вектора бессмысленны +- Препроцессинг картинки: ресайз прямо в 224x224 (без center-crop), (px-127.5)/127.5, NCHW RGB +- Сессии ONNX однопоточные (setIntraOpNumThreads(1)) — бережёт батарею + +### 3. Токенайзер SentencePiece (Gemma tokenizer.model, ~4MB, 256k vocab) +- Файл: siglip2_tokenizer.model (SHA256 61a7b147390c64585d6c3543dd6fc636906c9af3865a5548f27f31aee1d4c8e2) +- Нужен ТОЛЬКО текстовой башне SigLIP2 (litertlm не отдаёт token id наружу) +- Реализация без protobuf-зависимости: ручной парсинг ModelProto (pieces = field 1; piece=1 string, score=2 float, type=3 enum; индекс == token id) +- Кодирование: Viterbi Unigram сегментация + byte fallback (<0xNN>, Gemma шлёт все 256 байтовых писов) +- Препроцессинг текста: lowercase → NFKC → схлопнуть пробелы → dummy-prefix пробел → ▁ (U+2581) +- SEQ_LEN = 64 (падинг padId=0, обрезка) +- Риск: совпадение с transformers Siglip2Processor — первое, что проверять при повторе + +## Как это работает (архитектура) + +- Foreground-сервис InferenceService, 3 очереди: standard (чат), intent (JSON-извлечение), embedding (SigLIP2 — отдельный consumer, не ждёт LLM) +- Модели качаются ТОЛЬКО с зеркала https://data.vayunmathur.com/models/ (никакого HF-фолбэка, supply-chain митигация), SHA-256 проверка, Content-Length + ETag +- Чат: история из Room-БД → ConversationConfig(systemInstruction, initialMessages, tools, automaticToolCalling=true) → sendMessageAsync стриминг +- Инструменты (AssistantToolSet): add_to_memory / get_memories / remove_memory и др., automaticToolCalling=true +- Intent-режим: system prompt "data extraction engine", стриминг, tryExtractLargestJson + валидация по JSON Schema, досрочный HALT через CancellationException("HALT"), таймаут 45с, очередь-дроп по 45с +- Версионирование: cleanupStaleSiglipModels по SiglipEmbedder.MODEL_VERSION; legacy gemma4.litertlm / gemma4-4b.litertlm удаляются при старте +- Сборка: noCompress += "onnx"; packaging pickFirsts: libLiteRtTopKOpenClSampler.so, libc++_shared.so + +## Ключевой вывод (что ценно для нас) + +Главный урок не в архитектуре, а в скорости: автор довёл локальную нейронку на телефоне до >=20-25 ток/с — пользователь не видит задержки, ассистент отвечает "на скидку" (вживую, без заметных пауз). Это рецепт быстрого запуска нейронки на устройстве: +- Модель 2B (Gemma 4 E2B) в оптимизированном формате LiteRT LM — компактная, но живая +- GPU-бэкенд для text/vision (OpenCL), CPU только для аудио +- Speculative decoding включён (ExperimentalFlags) — дёшево ускоряет генерацию +- Одна сессия движка, переиспользуется; очереди разнесены, эмбеддинги не блокируют чат +- Токенайзер ручной (без protobuf), эмбеддер на ONNX — но это обвязка, не узкое место + +Т.е. заявка на повтор: 2B-модель + LiteRT LM + GPU + speculative decoding = быстрый локальный ассистент. + +## Ответы на вопросы (2026-08-10) + +### Что за формат .litertlm и можно ли заменить модель +- .litertlm — контейнер LiteRT-LM (Google; бывш. MediaPipe LLM Inference / AI Edge): внутри квантованная модель (смесь 2/4/8 бит, text-only вес ~0.8GB), эмбеддинги (1.12GB, memory-mapped), токенайзер, плюс движок даёт KV-cache менеджмент, промпт-темплейт, function calling +- ЗАМЕНИТЬ МОЖНО, и это просто: готовые .litertlm в litert-community на HF (десятки): Qwen3 0.6B/1.7B/8B, Qwen2.5 0.5B/1.5B, TinyLlama-1.1B, Phi-4-mini, SmolLM-135M, DeepSeek-R1-Distill-Qwen-1.5B (reasoning), Gemma3-1B/4B, FunctionGemma-270M (function calling), Nanbeige-3B, Ministral-3-3B, Mage-VL (VLM), FastVLM-0.5B. Свои модели — через LiteRT Torch Generative API (ai-edge-litert, Python) +- В openassistant замена = поменять URL+SHA256 в ModelUrls и файл. SigLIP2-токенайзер от LLM не зависит +- Ограничения при замене: (а) speculative decoding вшит в сборку модели — у Gemma 4 E2B есть, у других не факт; (б) Content.ImageFile/AudioFile работают только у мультимодальных сборок; (в) tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех +- Для скорости на слабом железе: Qwen3-0.6B / SmolLM-135M / Qwen2.5-0.5B (быстрее, но глупее); компромисс: Qwen3-1.7B, Gemma3-1B; русский текст у Qwen обычно лучше + +### Мультимодальность Gemma 4 E2B +- ДА: текст + картинки + аудио в одном сообщении. В коде: Content.Text / Content.ImageFile / Content.AudioFile; text/vision на GPU, audio на CPU +- Из README: картинки/аудио класть ПЕРЕД текстом в промпте; переменное разрешение картинок через visual token budget (токенов на картинку); vision/audio части грузятся по требованию +- В openassistant intent-режим шлёт imagePaths + userText одним Messages.user — живой пример смешанного ввода + +### Контекст +- Полная Gemma 4 — до 256K токенов (model card). On-device litertlm-сборка: "supports up to 32k context length" — 32K токенов +- Бенчмарки litert-community: 1024 prefill + 256 decode, контекст 2048; S26 Ultra GPU: decode 52 ток/с (baseline), 66-92 ток/с со speculative decoding; prefill 3808 ток/с; TTFT 0.3с +- Приложение лимита не задаёт (ConversationConfig без maxTokens) — контекстом рулит движок (KV-cache менеджмент); история из Room идёт в initialMessages, упор в 32k движок разруливает сам +- 20-25 ток/с автора — нижняя граница "незаметно", на флагмане официально 46-92 + +### Скорость на Snapdragon 8 Gen 3 (12 ГБ RAM) +- УСТРОЙСТВО АВТОРА (подтверждено 2026-08-10): Honor Magic V3, Snapdragon 8 Gen 3, 12 ГБ RAM. (Сначала назвал Gen 2 — ложный след, снят с протокола.) +- Официальная карточка litert-community НЕ даёт 8 Gen 3 — бенчмарки только на S26 Ultra (чип 2026, новее): GPU decode 52.1 ток/с baseline, 66.5-91.7 со speculative decoding; prefill 3808 ток/с; TTFT 0.3с; CPU: decode 46.9, prefill 557, TTFT 1.8с. Память: GPU-бэкенд 676 МБ, CPU 1733 МБ +- 8 Gen 3 (2023, Adreno 750, напр. S24 Ultra US): официальных цифр нет; сторонние замеры (MindStudio): E2B 20-35 ток/с, E4B 12-20 ток/с. Прим.: у MindStudio Pixel 9 приписан к 8 Gen 3 ошибочно (там Tensor G4); настоящий 8 Gen 3 — S24 Ultra (US) и другие +- Память 12 ГБ — с большим запасом: модель на GPU ~676-800 МБ RSS (text-only), эмбеддинги 1.12 ГБ memory-mapped (не в RSS), vision/audio по требованию. Ограничение не в RAM, а в скорости GPU/пропускной способности +- Вывод: заявленные автором 20-25 ток/с — реалистичная нижняя/средняя граница вилки для 8 Gen 3; со speculative decoding можно ждать заметно больше (порядка 50+ на флагмане, по аналогии с S26 Ultra) + +### Спекулятивное декодирование (MTP) на Snapdragon 8 Gen 3 +- Что это: LLM обычно выдаёт 1 токен за проход сети. Спекулятивное декодирование: маленький черновик-предсказатель набрасывает сразу N следующих токенов, большая модель проверяет их ОДНИМ проходом — если угадал, получил N токенов ценой одного прохода. Ускорение до ~3x без потери качества +- У Gemma 4 это НЕ внешняя надстройка, а встроенная архитектура — Multi-Token Prediction (MTP): черновик-головы сидят прямо в чекпоинте модели (ai.google.dev/gemma/docs/mtp). Спекуляция у Gemma 4 работает автоматически при поддержке рантайма; флаг лишь включает рантайм-путь +- В openassistant флаг ПОДТВЕРЖДЁН в коде: InferenceService.kt:486 — ExperimentalFlags.enableSpeculativeDecoding = true перед engine.initialize() +- ВЫВОД: 20-25 ток/с автора НЕ означают, что спекуляция выключена. Наоборот — она включена. У автора Honor Magic V3 = Snapdragon 8 Gen 3 (Adreno 750): вилка со спекуляцией ~20-35 ток/с (MindStudio), без неё ~10-15 (вдвое меньше). Его 20-25 — ровно середина ожидаемого С включённой спекуляцией, ближе к нижней границе (зависит от термальных лимитов складного корпуса) +- Как проверить на живом телефоне: поставить enableSpeculativeDecoding = false, замерить — падение вдвое = она работала +- 8 Gen 3 (2023, Adreno 750): сторонние замеры E2B 20-35 ток/с; на 8 Gen 2 (2022, Adreno 740) было бы ниже на ~15-20% + +## Как повторить (чеклист) + +1. Скачать 4 файла с HF (см. SHA256 выше), разложить под зеркало /models/ +2. Android: litertlm-android 0.14.0 + onnxruntime-android 1.27.0 (+ kotlinx-coroutines 1.11.0 — иначе close$default на SendChannel) +3. Backend: GPU для text/vision, CPU для audio +4. Из SigLIP2 ONNX читать pooler_output, не last_hidden_state + +## Бонус: что ещё в монорепо Modern-Apps +findfamily (UWB-локатор), keyboard (хангыль-композитор), maps, games (Stockfish через JitPack ncnn), библиотеки: downloadservice, room, image. + +## Raspberry Pi 5: да, работает + +Вопрос: потянет ли Gemma 4 E2B Raspberry Pi 5? Ответ: да, и это официально. + +Формат и рантайм: +- LiteRT-LM теперь официально кросс-платформенный: Android, iOS, Web, Desktop, IoT (Raspberry Pi). Есть официальный CLI (uvx-установка), который гоняет .litertlm прямо на Pi — без конвертации. GitHub: google-ai-edge/LiteRT-LM. +- Google сам пишет «Try Gemma4-E4B with MTP on Linux, macOS, Windows or Raspberry Pi with the LiteRT-LM CLI». + +Реальные замеры на RPi 5 (8 ГБ, 4×Cortex-A76 @ 2.4 ГГц, без GPU-акселератора): +1. Ollama (GGUF): первый токен ~3-4 сек, генерация ~8-12 t/s. Модель ~1.5 ГБ. +2. Стоковый llama.cpp (Q4): ~6.6 t/s. +3. mkturkcan/mote (оптимизированный llama.cpp + MTP-спекуляция + A76-ядра + KleidiAI): 11.2 t/s на обычном тексте, до 16 на шаблонном, до 20.4 в turbo на повторах (логи/CSV). Устанавливается одной командой, ничего не собирается на Pi. +4. Официальный LiteRT-LM CLI: цифры не подтверждены, но движок тот же — ожидаемо в районе 6-12 t/s. + +Важно про контекст на Pi: +- Модель заявляет 128K контекста, но KV-кэш на 128K не влезает в 8 ГБ. На RPi 5 (8 ГБ) реально 4K-8K токенов, дальше — своп и обвал скорости. На 16 ГБ — комфортно ~32K. +- Мультимодальность (текст+картинки+видео) на Pi работает, но медленнее телефона. + +Вывод: на RPi 5 E2B даёт ~8-12 t/s (сток) или ~11-20 t/s (оптимизированный mote). Это в 2-3 раза медленнее телефона (20-25 t/s), но для офлайн-ассистента, пакетной обработки и домашних экспериментов — достаточно. Воспринимается как «медленно, но читаемо», а не как «зависло». + +## Батарея и скорость обработки картинок (Honor Magic V3, 8 Gen 3, 12 ГБ) + +### Батарея +- Точных ватт на Android под GPU-нагрузкой НЕТ ни у кого: Battery Manager API Android ненадёжен под нагрузкой, iOS API не отдаёт (arxiv 2603.23640, "LLM Inference at the Edge", бенчмарк на S24 Ultra = тот же 8 Gen 3). +- Ориентиры из той статьи: S24 Ultra idle ~0.8 Вт, max ~12 Вт (система целиком). Qwen 1.5B через MLC-LLM: 9.93 ток/с sustained, префилл 25 сек (OpenCL-оверхед MLC), термальный фейл на 6-й итерации подряд: GPU freq упала до 231 МГц, 78.3°C — жёсткий флор от термогубернатора, а не плавный DVFS как у iPhone. +- Энергия на токен на edge-платформах: ~270-300 мДж/токен (Hailo-10H 1.87 Вт / 6.9 ток/с; RTX 4050 ноут 34 Вт / 131.7 ток/с). +- Honor Magic V3: батарея 5150 мАч ≈ 19.9 Вт·ч. +- Прикидка: Gemma 4 E2B на 8 Gen 3 ~22 ток/с при ~4-5 Вт системы → 0.2 Дж/токен ≈ 56 мкВт·ч/токен. Непрерывная генерация ≈ 4-4.5 часа на всю батарею (20-25%/час). Сценарий ассистента (5-10 мин генерации в час) → 1.5-4%/час — почти незаметно. +- Термальный капкан 8 Gen 3: непрерывная нагрузка без пауз → троттлинг, скорость падает до ~2 раз. Magic V3 тонкий — отвод тепла хуже, троттлинг раньше. Паузы между запросами обязательны (раз в несколько минут — ок). +- LiteRT-LM оптимизирован под Adreno лучше MLC-LLM (это гугловский движок), поэтому 20-25 ток/с у автора — уже с учётом реального троттлинга. + +### Картинки (видео-поток с камеры) +- Gemma 4: переменное разрешение через токен-бюджет: 70/140/280/560/1120 токенов на картинку. Google прямо советует для кадров видео НИЗКИЙ бюджет (70) ради скорости. Механика: 9 патчей на токен (3x3 среднее). +- Каждый кадр = вижн-энкодер + префилл токенов бюджета. Порядок: 0.3-1.5 сек на кадр на 8 Gen 3 (оценка, точного бенчмарка нет). +- 30 fps НЕВОЗМОЖНО: 70 токенов/кадр × 30 = 2100 ток/с префилла — префилл на телефоне ~200-500 ток/с. Реалистично: кадр раз в 2-5 сек, бюджет 70-140. +- Контекст 32K: при 140 ток/кадр ≈ 230 кадров в окне, при 70 ≈ 450. Кадр раз в 2 сек → 8-15 минут непрерывного окна → нужен скользящий буфер кадров. +- Для "где я иду/еду": модель получает кадр + вопрос ("где я?"), отвечает за 1-3 сек. Рабочий вариант — периодический анализ, а не поток. Сколько картинок за сообщение принимает on-device сборка — проверять на практике (в доке: reasoning across multiple images). +- Код автора: Content.ImageFile(path) шлёт файлы как есть, бэкенд GPU для текста+зрения (InferenceService.kt:493-494), аудио CPU. Приложение лимит картинок не задаёт. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cea26f..819a942 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.4.10" kotlinx-serialization = "1.8.1" -kotlinx-coroutines = "1.10.2" +kotlinx-coroutines = "1.11.0" ktor = "3.3.0" android = "8.9.2" compose-plugin = "1.8.0" @@ -10,6 +10,7 @@ androidx-activity-compose = "1.10.1" exoplayer = "2.17.1" media3 = "1.6.1" koog = "1.1.1" +litertlm-android = "0.14.0" [libraries] kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -23,6 +24,10 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне. +# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята) +litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" } + # Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) # Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client; # отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.