LLM: интерфейс + две реализации (Qwen удалённо / Gemma 4 E2B локально) + переключатель
- LlmClient интерфейс; RemoteLlmClient (OkHttp, llm.binom.pw) и LocalLlmClient (LiteRT-LM, Gemma 4 E2B) - LocalLlm: Gemma4E2B (SHA-256, GPU/CPU-бэкенды), buildLocalLlmPrompt (SYSTEM→systemInstruction, TOOL→user), GemmaModelDownloader (стриминг, прогресс, фолбэк источников), спекулятивное декодирование - LlmPrefs (персистентность выбора), PhoneApp: фабрика по llmChoice, пересоздание ассистента, скачивание/отмена - UI: GlassesInfoScreen — выбор сервер/локальная + прогресс; ChatScreen — индикатор активной модели - Тесты: +12 (LlmLocalTest) — phone 154 зелёных
This commit is contained in:
@@ -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<ChatMessage>, 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<ChatMessage>` + 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 на нашем устройстве, скачивается ли модель).
|
||||
@@ -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<JellyfinItem>`
|
||||
- `suspend fun item(userId: String, itemId: String): JellyfinItem?`
|
||||
- `suspend fun allItems(userId: String, types: List<String>): List<JellyfinItem>`
|
||||
- `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://<glassesMediaDir>/<id>/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, что осталось непокрыто тестами.
|
||||
@@ -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 — собирается
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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<LlmModelChoice>(llmPrefs.current())
|
||||
val llmChoice: StateFlow<LlmModelChoice> = _llmChoice.asStateFlow()
|
||||
|
||||
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
|
||||
private val _localLlmState = MutableStateFlow<LocalLlmModelState>(initialLocalLlmState())
|
||||
val localLlmState: StateFlow<LocalLlmModelState> = _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))
|
||||
|
||||
@@ -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<ChatMessage>, 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-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
val dto = ArrayList<ChatMessageDto>()
|
||||
if (system != null) dto += ChatMessageDto("system", system)
|
||||
for (msg in messages) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Message>,
|
||||
val incoming: Message,
|
||||
)
|
||||
|
||||
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
|
||||
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
|
||||
.joinToString("") { it.text }
|
||||
|
||||
/**
|
||||
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||
* - USER → [Message.user], ASSISTANT → [Message.model];
|
||||
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||
* тула <id>: <текст>», что шлёт [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<ChatMessage>, system: String? = null): LocalLlmPrompt {
|
||||
val systemParts = ArrayList<String>()
|
||||
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
|
||||
val mapped = ArrayList<Message>()
|
||||
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<Message>
|
||||
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<String> = 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<ChatMessage>, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,29 @@ data class ChatMessage(
|
||||
val toolCalls: List<ToolCall>,
|
||||
val attachments: List<Attachment>,
|
||||
val createdAt: Long,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
|
||||
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
|
||||
*/
|
||||
fun of(
|
||||
role: ChatRole,
|
||||
content: String,
|
||||
toolCallId: String? = null,
|
||||
toolCalls: List<ToolCall> = 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 }
|
||||
|
||||
@@ -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))
|
||||
|
||||
// Лента сообщений
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<IllegalStateException> {
|
||||
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<IOException> {
|
||||
runBlocking { dl.download { } }
|
||||
}
|
||||
assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message)
|
||||
assertFalse(target.exists())
|
||||
}
|
||||
}
|
||||
@@ -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. Приложение лимит картинок не задаёт.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user