Compare commits

10 Commits

Author SHA1 Message Date
subochev d585ddf576 Локальная LLM: конфиг автора + uses-native-library в <application>
- AndroidManifest: три uses-native-library (libOpenCL/libvndksupport/libcdsprpc)
  ВНУТРИ <application> — aapt2 8.9.2 отбрасывает тег на уровне <manifest>
- LocalLlm: enableSpeculativeDecoding=true, GPU/GPU/CPU бэкенды (как у автора)
- DebugHttpServer: HTTP-отладка ассистента без UI
- Log/PhoneApp: мелкие правки

Подтверждено: движок поднимается, локальный чат отвечает на HONOR AXGL024B05001337
2026-08-23 17:25:55 +03:00
subochev 75496dd35d Фикс локальной LLM: таймаут 300с для LOCAL (инициализация движка ~17с + генерация), CPU-бэкенд без спекуляции (MTP виснет на CPU, GPU-делегат падает), диагностика chat()
Проверено на железе (HONOR FCP-N49): Gemma 4 E2B отвечает
«Здравствуйте! Чем я могу вам помочь с вашими очками Viewmate?»
(15 чанков, 61 символ, ~19с генерации).
2026-08-23 04:38:02 +03:00
subochev 7527cf8848 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 зелёных
2026-08-23 02:52:22 +03:00
subochev 2d6ca1e097 План: локальная модель на телефоне (Gemma 4 E2B, LiteRT LM) — в очереди, ждёт отмашки
- Источник: vayun-mathur/Modern-Apps openassistant (клон ~/WORK/modern-apps/)
- Интерфейс LLM + 2 реализации: Qwen3.8 (сеть) и Gemma E2B (локально, оффлайн)
- Разбор скорости: GPU-бэкенд + MTP-спекулятивный декодинг, 20-35 ток/с на 8 Gen 3
- Реализация НЕ начата — по правилу пользователя «без отмашки не начинай»
2026-08-23 02:00:21 +03:00
subochev f75e672819 Очки: жесты и режимы общения с ассистентом (по docs/gestures-and-modes.md)
- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
2026-08-23 01:55:24 +03:00
subochev f36d04adc1 Доки: жесты и режимы общения с ассистентом (design) + TASK 2026-08-23 01:08:38 +03:00
subochev 28d0f9b68d Агент: медиа-тулсет (search/info/play) на Jellyfin + скилл media
- MEDIA_TOOLSET + MediaToolExecutor: search (поиск с лимитом), info (детали), play (проверки+запуск)
- JellyfinMediaSource: обёртка JellyfinClient (search/item) с защитой userId
- agentPlayMedia: проверки (очки на связи, видео скачано на очки, звук на телефоне) → watch с позицией
- PhoneActions.watch(startPositionMs), JellyfinItem.ProductionYear
- скилл app-phone/skills/media.md (frontmatter: name/description)
- MediaToolsTest: 22 кейса — всего 142 теста зелёные
2026-08-22 21:42:53 +03:00
subochev c5cbc893ac Агент: механика скиллов и тулсетов (ядро 5 тул, подмена, невидимые enable/disable)
По ТЗ TASK-agent-tools.md:
- Skills/SkillRepository/SkillRegistry: frontmatter-скиллы из files/skills, индекс для промпта, ленивое read_skill
- Toolsets/ToolsetRegistry: активность на сессию в хиппе, таймаут 10 мин (инжект часов), префиксная маршрутизация, идемпотентные enable/disable
- AgentToolkit: сборка промпта (3 состояния), диспач, бесшовная подмена вызова на активацию, валидация аргументов
- Assistant: цикл тулов с невидимыми вызовами (MAX_LLM_CALLS=8, MAX_TOOL_CALLS=3)
- Тесты: 18 новых (AgentToolkitTest 17 + SkillsTest 6 + ToolsetRegistryTest 9) — всего 120, зелёные
2026-08-22 21:13:29 +03:00
subochev 22369670cb ТЗ: агент — скиллы + тулсеты (TASK-agent-tools.md)
Архитектура (договорено 2026-08-22):
- скиллы (знания) ≠ тулсеты (руки)
- ядро 5 тул: время, show_text, read_skill, enable/disable_toolset
- префиксы тул по набору (media_*)
- активность на сессию, в хиппе, таймаут 10 мин
- enable/disable невидимы (не в истории), скилл исчезает при активном наборе
- бесшовная подмена вызова на активацию
- история не вычищается
2026-08-22 20:15:48 +03:00
subochev bbf7a020d4 Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с
- PlaybackService (foreground, WAKE_LOCK) против морозов HONOR
- Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID)
- Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с
- LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях)
- Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText
- Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
2026-08-22 16:31:04 +03:00
61 changed files with 5915 additions and 451 deletions
+3
View File
@@ -0,0 +1,3 @@
# >>> cortexkit:magic-context
magic-context/
# <<< cortexkit:magic-context
+147
View File
@@ -0,0 +1,147 @@
# Агент «Порфирий» на телефоне: скиллы + тулсеты — архитектура (договорено 2026-08-22)
> Зафиксировано по итогам обсуждения. Это и память, и ТЗ для разработки (opencode).
> Модель сейчас: Qwen3.8-27B-NVFP4 через llm.binom.pw. План: позже перейти на
> Gemma (маленькая, быстрая, on-device) — архитектура должна это пережить без переделки.
---
## 1. Ключевое разделение: СКИЛЛЫ ≠ ТУЛСЕТЫ
- **Скиллы** = знания. «Как пользоваться», рецепты, шаги, питфоллы.
Пассивные, лежат в файлах, читаются по требованию через `read_skill`.
Отвечают на вопрос «КАК?».
- **Тулсеты** = доступные руки. «Какие функции сейчас можно дёргать».
Активные, живут в промпте, меняются динамически. Отвечают на «ЧТО МОЖНО?».
- Пересечение одно: скилл говорит «включи вот этот набор» — дальше механика делает своё.
## 2. Ядро (всегда в промпте, 5 тул, компактно)
1. `get_current_time` — время/дата/пояс, ISO 8601 (уже есть)
2. `show_text` — показать текст на очках (уже есть, ShowText)
3. `read_skill|имя` — вернуть полное содержимое скилла в историю
4. `enable_toolset|имя` — активировать набор тул
5. `disable_toolset|имя` — снять набор тул
Все тулы — **идемпотентные и прощающие**:
- повторный enable — не ошибка, «уже активен, продлеваю таймаут»
- disable без активного — «нет активных наборов», не ошибка
- неизвестное имя набора — «набора X нет, доступны: media, ...»
## 3. Структура промпта (пересобирается на КАЖДЫЙ запрос)
```
1. ЯДРО (всегда): 5 тул с описаниями
2. КАТАЛОГ ТУЛСЕТОВ (всегда, компактно, по строке на набор):
media — управление медиа и воспроизведением (search/info/play)
web — работа в вебе (когда появится)
3. АКТИВНЫЕ ТУЛСЕТЫ (динамически, только активные):
media: активен (+ описания тул media_search, media_info, media_play...)
4. ИНДЕКС СКИЛЛОВ (всегда, имя + триггер, по строке на скилл)
5. Содержимое скиллов — НЕ в промпте, читается через read_skill в историю
```
## 4. Префиксы тул — по имени набора
Все тулы набора имеют префикс: `media_search`, `media_info`, `media_play`.
Префикс = маршрутизация: система видит `media_*` → знает набор, сигнатуры, может
проверить аргументы без реестров-в-сторону.
## 5. Активность тулсета — на СЕССИЮ, в хиппе, по таймауту
- Хранится в памяти (Map): `сессия → (набор → deadline)`
- **Не персистится**: перезапуск приложения = всё сброшено (это правильно:
перезапустил — значит, начал заново; 10-минутные наборы переживать рестарт не должны)
- Таймаут: **10 минут с последнего вызова тулы набора** (enable продлевает)
- Проверка при сборке промпта: `now > deadline` → набора нет, запись подчистить
- Никаких фоновых таймеров — только проверка при сборке промпта
## 6. Три состояния промпта
**Состояние 1 — набор не активен, тема не медиа:**
```
ядро + каталог тулсетов + индекс скиллов
(скилл media в индексе: «для фильмов вызови enable_toolset|media»)
```
**Состояние 2 — нейронка решила активировать (enable_toolset):**
- Вызов enable **НЕ пишется в историю** — система активирует набор в реестре,
пересобирает промпт → следующий запрос уже в состоянии 3
- Это «невидимая системная тула»: её вызовы — часть механики, не диалога
**Состояние 3 — набор активен:**
```
ядро + описания тул media_* + «Активные тулсеты: media» + скилл media УБРАН
```
- Скилл media **исчезает из индекса/промпта**, пока набор активен — иначе
нейронка видит «надо вызвать enable» и соблазняется дёрнуть его повторно.
Искушение удаляется, а не запрещается.
- Секция «Активные тулсеты» нужна: объясняет нейронке, что тулы media_*
легитимны и их можно звать (снимает паралич выбора). 5 токенов.
**Деактивация:** нейронка вызвала disable → выкидываем вызов из истории,
снимаем набор, пересобираем промпт. Симметрично enable.
## 7. Бесшовная подмена вызова на активацию (защита от тупки)
Нейронка вызвала `media_search` при НЕактивном наборе (по привычке/призраку
из истории):
- Система НЕ выполняет вслепую и НЕ ругается
- Подменяет вызов: как будто нейронка вызвала `enable_toolset|media`
- Отвечает: «Тулсет media активирован. Реестр: media_search(query), media_info(id)...
Поиск ещё НЕ выполнялся. Для поиска вызови media_search с аргументом query.»
- Ключевое условие: явно сказать, что вызов ещё не выполнен — иначе нейронка
решит, что тула сработала, и выдумает ответ (галлюцинация)
- Нейронка видит чистую историю: запрос → активация → реестр → search. Никаких швов.
Дополнительная страховка: если аргументы вызова совпадают со схемой из реестра —
можно выполнить сразу, без второго круга.
## 8. История диалога — НЕ вычищается
- Результаты тулов остаются в истории (это память диалога: «а что там с Ларой Крофт?»)
- Призраки тулов из прошлого нейтрализуются НЕ чисткой, а:
а) бесшовной подменой (см. п.7) — модель получит реестр и повторит правильно
б) секцией активных тулсетов — «сейчас активен media, зови его тулы»
- Вычистка истории = амнезия («что ты мне показывал про Лару Крофт?» — не знаю) — НЕ ДЕЛАЕМ
## 9. Скиллы — файловая структура
- Папка `skills/` (на телефоне, внутренние файлы приложения)
- Формат: `name + описание-триггер в одну строку + тело (шаги, команды, питфоллы)`
- `SkillRepository` — объединяет источники (встроенные assets + пользовательские)
- `SkillRegistry` — индекс (имя + триггер), идёт в промпт
- Полное содержимое — только через `read_skill|имя` (лениво)
Скилл media (пример содержания):
```
Триггер: поиск фильмов, детали, запуск/управление просмотром.
Вызови enable_toolset|media, дождись ответа — в нём реестр тул:
media_search — поиск по названию (аргумент query)
media_info — детали фильма (аргумент id)
media_play — запуск/управление (аргументы...)
```
## 10. Требования к тулам набора (для маленькой модели)
- Описания ЗЛЫЕ И КОРОТКИЕ: имя + одна строка + аргументы в одну строку
- Активный набор ≈ 400 токенов, не гора
- Валидация аргументов на исполнителе: невалидные → «аргументы неверны, вот схема»
## 11. Тестирование
- **Чистая логика — JVM-юнит-тесты** (без Android):
сборка промпта (3 состояния), подмена вызова, таймауты, идемпотентность,
каталог/реестр, префиксная маршрутизация
- **На железе** (142, adb): полный цикл — вопрос → read_skill → enable → search → ответ;
переключение сессий (активность на сессию); таймаут 10 мин (ускорить для теста);
подмена вызова при неактивном наборе; двойной enable/disable
## 12. Известные решения (не пересматривать)
- Протокол вызова: `TOOL_CALL: имя|аргументы` (строка, не JSON — маленькая модель)
- Активность тулсета: на сессию, в хиппе, 10 мин от последнего вызова
- enable/disable — невидимые тулы (вызовы не в истории)
- Скилл исчезает из промпта при активном наборе
- История не вычищается
- Подмена вызова на активацию — с явной пометкой «вызов ещё не выполнен»
+47
View File
@@ -0,0 +1,47 @@
# Задача: жесты и режимы общения с ассистентом на очках (design в docs/gestures-and-modes.md)
Прочитай `docs/gestures-and-modes.md` — там согласованная с пользователем матрица жестов и все решения. Реализуй в `app-glasses`. Механику тулсетов/ассистента (app-phone) НЕ трогать.
## Что сделать
### 1. Переразметка жестов по матрице (MainActivity.kt, collectTempleActions)
| Состояние | Click | DoubleClick | TripleClick | Свайпы/SlideContinuous |
|---|---|---|---|---|
| Кино (listening=false, assistantText пуст) | TOGGLE (как сейчас) | игнор | вызов ассистента (как сейчас) | ±30с (как сейчас) |
| Прослушивание (listening=true) | отправить (как сейчас) | отмена, остаёмся в диалоге (как сейчас) | игнор | игнор |
| Ответ виден (listening=false, assistantText непуст) | **новая фраза**: стереть assistantText, открыть микрофон, listening=true | **выход из режима**: закрыть оверлей, стереть assistantText, фильм на паузе (НЕ трогать play/pause — пауза уже стоит с момента входа) | игнор | **скролл ответа** (плавный SlideContinuous, запасной — дискретные свайпы) |
Важно: состояние «ответ виден» определяется `!listening && assistantText.isNotBlank()`.
### 2. Скролл ответа (плавный)
- SlideContinuous: накапливать `delta` (float) → смещать scrollState текста ответа. Направление: SlideForward = вниз по тексту (к концу), SlideBackward = вверх (к началу) — уточнить знак дельты на железе.
- Ограничить скролл границами текста (0..maxScroll).
- Запасной вариант: SlideForward/SlideBackward — скролл на страницу (например, ±200px или высоту видимой области), если SlideContinuous окажется шумным.
- Текст ответа в оверлее должен быть в scrollable-контейнере (Column + verticalScroll(rememberScrollState()) или LazyColumn — на твой выбор, но скролл обязателен).
### 3. Индикатор «думаю...»
- Состояние thinking: true с момента отправки фразы (Click при прослушивании / SttPhrase ушёл) до прихода ShowText.
- В оверлее: если thinking и assistantText пуст — показывать «думаю...» (вместо «слушаю...» после того как фраза отправлена).
- Как определить: пока listening=true — «слушаю...»/текст фразы; когда listening стал false, а ShowText ещё не пришёл — «думаю...».
### 4. Выход из режима
DoubleClick при «ответ виден»: закрыть оверлей (listening=false, sttText="", assistantText=""), НЕ трогать movieController (фильм остаётся на паузе — он уже на паузе с момента входа в режим).
## Ограничения
- Код только Kotlin
- НЕ трогать app-phone (ассистент, тулсеты, скиллы)
- НЕ трогать MovieController/playback-логику
- Тесты: юнит-тесты на чистую логику разметки жестов (новое состояние «ответ виден», переходы, скролл-дельта → смещение) — в духе GestureCommandsTest. Если логика в MainActivity (не чистая) — вынеси чистую часть (решение по жесту из состояния) в отдельный файл/функцию для тестируемости.
- Сборка: `./gradlew :app-glasses:assembleDebug` — зелёная; `./gradlew :app-glasses:testDebugUnitTest` — зелёный (старые + новые)
- НЕ коммитить
## Проверка
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные
2. `./gradlew :app-glasses:assembleDebug` — собирается
3. Опиши в ответе: какие файлы создал/изменил, как определил состояние «ответ виден», как работает скролл (SlideContinuous или запасной), что осталось проверить на железе.
+75
View File
@@ -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 на нашем устройстве, скачивается ли модель).
+46
View File
@@ -0,0 +1,46 @@
# Локальная модель на телефоне (Gemma 4 E2B) — ПЛАН (в очереди, НЕ реализовано)
> Статус: ЗАФИКСИРОВАНО 2026-08-23 ночью. Реализация НЕ начата — ждёт отмашки пользователя.
> Пользователь: «как закончишь с тем, что мы уже напланировали, нужно будет написать интерфейс и две реализации: одна ведёт на наш qwen 3.8, а вторая — вот эта вот модель. В будущем в идеале у нас всё должно мочь работать в офф-лайн режиме».
## Источник (что за модель и как она быстрая)
- Репозиторий: **vayun-mathur/Modern-Apps** → `openassistant` (клон: `~/WORK/modern-apps/`)
- Модель: **Gemma 4 E2B (2B)** в формате `.litertlm` (LiteRT LM, Google; бывш. MediaPipe LLM Inference / AI Edge)
- Файл: `gemma-4-E2B-it.litertlm` (SHA256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`), ~0.8 ГБ, контекст 32K
- Источник модели: `huggingface.co/litert-community/gemma-4-E2B-it-litert-lm`
- Скорость на Honor Magic V3 (Snapdragon 8 Gen 3, Adreno 750, 12 ГБ): **20-35 ток/с** (MindStudio, спекуляция вкл), официально на S26 Ultra до 66-92 ток/с
- Секрет скорости: GPU-бэкенд (OpenCL) для text + **встроенный MTP-спекулятивный декодинг** (Multi-Token Prediction у Gemma 4 — черновик-головы в чекпоинте), флаг `ExperimentalFlags.enableSpeculativeDecoding = true`
- Замена модели проста: готовые `.litertlm` на HF (Qwen3 0.6B/1.7B/8B, Qwen2.5, Phi-4-mini, Gemma3-1B/4B, FunctionGemma-270M и др.) — поменять URL+SHA256
- Полный разбор: `/root/notes/openassistant-lite-llm.md` (2026-08-10)
## Задача (когда будет отмашка)
**Интерфейс LLM + две реализации** в app-phone (агент «Порфирий»):
1. **Абстракция**: `interface LlmClient` (или sealed): метод `chat(history, system): String` — текущий контракт (см. `LlmClient.kt`, прямой OkHttp к `https://llm.binom.pw/v1/chat/completions`).
2. **Реализация 1 — удалённая (уже есть)**: Qwen3.8-27B-NVFP4 через llm.binom.pw (прямой OkHttp, TOOL-результаты → user-сообщения). Переоформить под интерфейс.
3. **Реализация 2 — локальная**: Gemma 4 E2B через LiteRT LM:
- Библиотека: `com.google.ai.edge.litertlm:litertlm-android:0.14.0`
- `Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir))` + `engine.initialize()`
- `ExperimentalFlags.enableSpeculativeDecoding = true` (ПЕРЕД initialize)
- Модель на диске: `getExternalFilesDir(null)/gemma4-2b.litertlm` (скачать с зеркала, SHA256 проверка — как у автора, без HF-фолбэка)
- `ConversationConfig(systemInstruction, initialMessages, tools?, automaticToolCalling?)` + `sendMessageAsync` стриминг
- Формат .litertlm: внутри квантованная модель, эмбеддинги (memory-mapped), токенайзер, KV-cache менеджмент, function calling
4. **Оффлайн-режим**: переключатель/автовыбор — сеть есть → Qwen3.8, нет → Gemma E2B (или наоборот, решить с пользователем). Идеал: «всё может работать оффлайн».
## Риски/нюансы (из заметки)
- Спекуляция вшита в сборку модели — у Gemma 4 E2B есть, у других не факт; tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех
- Русский текст у Qwen обычно лучше — для локальной можно рассмотреть Qwen3-1.7B (компромисс скорость/русский)
- 8 Gen 3 термальный капкан: непрерывная генерация → троттлинг до 2 раз; паузы между запросами обязательны
- Батарея: ~4-4.5 часа непрерывной генерации (20-25%/час); сценарий ассистента 5-10 мин/час → 1.5-4%/час — незаметно
- Память 12 ГБ хватает: модель на GPU ~676-800 МБ RSS, эмбеддинги 1.12 ГБ memory-mapped
- Android-интеграция sherpa-onnx (STT) уже работает — только LLM-часть заменить
## Проверка
- Юнит-тесты: интерфейс, выбор реализации, конфиг
- На железе: полный цикл (голос → STT → локальная LLM → ответ) при выключенном интернете (airplane mode / svc wifi disable)
- Замер скорости: ток/с, TTFT на живом Honor Magic V3
- Сравнение ответов Qwen3.8 vs Gemma E2B на одинаковых запросах
+65
View File
@@ -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, что осталось непокрыто тестами.
+55
View File
@@ -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 — собирается
+103
View File
@@ -0,0 +1,103 @@
# Абстракция транспорта связи очки↔телефон (view-mate)
## Проблема
Сейчас связь очки↔телефон жёстко завязана на **WiFi + WebSocket**:
- очки: `GlassesWsClient` (Ktor WS-клиент) внутри `HostConnection`, discovery = mDNS (`MdnsClient`) + скан /24 (`PhoneScan`)
- телефон: `GlassesServer` (Ktor CIO embeddedServer, WS на 0.0.0.0:8080/ws/glasses) + `GlassesHub` (сессии = `DefaultWebSocketServerSession`)
WiFi на реальном железе нестабилен (пользователь: «wifi в рамках view-mate — неверное решение, блютус на много стабильнее»). Нужно сделать транспорт **абстрактным**, чтобы реализацию можно было подставить: **WifiTransport** (текущий WS) и **BluetoothTransport** (новый, RFCOMM/BluetoothSocket).
## Цель
Выделить интерфейс транспорта, одинаковый для обеих сторон, и две реализации:
1. **WifiTransport** — обёртка над существующим кодом (без изменения логики)
2. **BluetoothTransport** — новая реализация на Android BluetoothSocket (RFCOMM)
Протокол сообщений (HostToGlasses/GlassesToHost, kotlinx.serialization JSON) — **НЕ трогать** (пересылаются как JSON-строки, что WS, что BT-сокет).
## Текущая структура (не менять без нужды, только рефакторинг)
**lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/**
- `net/GlassesWsClient.kt` — WS-клиент очков: `suspend connect(onHostMessage, onConnected, onDisconnected)` (бесконечный цикл приёма с реконнектом 3с), `suspend send(msg: GlassesToHost)`, `close()`
- `phone/GlassesSender.kt` — интерфейс телефона: `interface GlassesSender { suspend fun send(msg: HostToGlasses) }`
- `protocol/` — HostToGlasses, GlassesToHost, Json (protocolJson), MediaCommand и т.д.
**app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/**
- `HostConnection.kt` — обвязка: discovery (MdnsClient → PhoneScan), connectLoop, реконнект, Hello, статус-цикл 5с (buildStatus + PlaybackPosition), обработка HostToGlasses (handleHostMessage), reconnectResume-автоматика
- `MdnsClient.kt`, `PhoneScan.kt` — WiFi-discovery
**app-phone/src/main/kotlin/pw/binom/viewmate/phone/**
- `GlassesServer.kt` — `GlassesHub` (ConcurrentHashMap<DefaultWebSocketServerSession, Unit>, connected/gesturesReceived/batteryPercent, broadcast, обработка входящих: Hello→Welcome, жесты, позиция, GlassesOff→пауза) + `GlassesServer` (embeddedServer CIO + glassesServerModule + sender-адаптер GlassesSender поверх hub.broadcast)
- `NsdPublisher.kt` — mDNS-публикация
## Задача
### 1. Интерфейсы транспорта (lib-core, commonMain)
Создать `core/net/GlassesTransport.kt` (или аналогичный файл):
```kotlin
/** Транспорт связи очки↔телефон. Реализации: WiFi (WS), Bluetooth (RFCOMM). */
interface GlassesTransport {
val name: String // "wifi" / "bluetooth"
suspend fun connect(
onMessage: suspend (String) -> Unit, // входящее JSON-сообщение
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
suspend fun send(json: String)
fun close()
}
```
Сообщения передавать **JSON-строками** (декодирование остаётся на стороне вызывающего — в HostConnection и GlassesHub, как сейчас). Это держит транспорт полностью независимым от протокола.
Для телефона — интерфейс приёма подключений:
```kotlin
/** Приёмник подключений (телефон). */
interface GlassesServerTransport {
val name: String
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: suspend (connId: String) -> Unit,
onDisconnected: suspend (connId: String) -> Unit,
)
suspend fun send(connId: String, json: String)
fun close()
}
```
(connId — идентификатор сессии; для WiFi можно использовать адрес/хэш, для BT — MAC-адрес устройства.)
### 2. WifiTransport (рефакторинг существующего)
- **Очки**: `WifiGlassesTransport` — обёртка над `GlassesWsClient`: connect пробрасывает колбэки, но строки (не декодированные объекты). Либо изменить `GlassesWsClient` на работу со строками — на твоё усмотрение, главное чтобы HostConnection перешёл на интерфейс.
- **Телефон**: `WifiServerTransport` — обёртка над Ktor-сервером: существующий `glassesServerModule`/`GlassesHub` переводится на интерфейс (сессии — connId-строки вместо DefaultWebSocketServerSession).
### 3. BluetoothTransport (новая реализация)
**Очки (клиент):**
- `BtGlassesTransport` — подключается к телефону через BluetoothSocket (RFCOMM, SPP): `BluetoothAdapter.getDefaultAdapter()`, найти сервис/устройство (bonded devices + поиск), `createRfcommSocketToServiceRecord(SPP_UUID)`, connect, потоки: читать строки (по разделителю/длине), писать строки.
- Discovery: bonded-устройства телефона (первый подходящий) или поиск (startDiscovery) — достаточно начать с bonded.
- Пермишены: BLUETOOTH, BLUETOOTH_ADMIN (до Android 12), BLUETOOTH_CONNECT/BLUETOOTH_SCAN (Android 12+, runtime-запрос).
**Телефон (сервер):**
- `BtServerTransport` — BluetoothServerSocket (RFCOMM, SPP, тот же UUID), accept-цикл, потоки чтения/записи на каждое подключение, connId = MAC устройства.
- Пермишены аналогично + FOREGROUND_SERVICE_CONNECTED_DEVICE (если нужно).
### 4. Соединение (фабрика/выбор)
- В `HostConnection` и `GlassesServer` — параметр транспорта (интерфейс), по умолчанию WifiTransport (чтобы не сломать текущее поведение). Выбор: константа/конфиг/аргумент конструктора. **По умолчанию — WiFi**, Bluetooth включается флагом (пользователь будет переключать вручную после проверки).
- Discovery (MdnsClient/PhoneScan/NsdPublisher) — оставить как часть WifiTransport; для BT — свой (bonded).
### 5. Сборка и тесты
- Существующие тесты (GlassesServerTest, HostToGlassesTest, GlassesToHostTest, PhoneActionsTest и др.) должны остаться зелёными — поведение WiFi не меняется.
- Добавить юнит-тесты на чистую логику (если есть что тестировать без Android: например, сериализация строк, выбор транспорта).
## Ограничения
- **НЕ менять протокол** (HostToGlasses/GlassesToHost/Json) — только способ доставки.
- НЕ ломать существующий WiFi-путь: он остаётся дефолтным и должен работать как раньше.
- Kotlin, стиль проекта (детали в соседних файлах).
- После рефакторинга — собрать `:app-phone:assembleDebug` и `:app-glasses:assembleDebug`, прогнать тесты, показать результат.
+116 -90
View File
@@ -1,106 +1,132 @@
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
# Задача: телефон не должен засыпать во время просмотра (foreground service)
## Баг 1: ConnectionScreen игнорирует масштаб
## Симптом (пользователь, 21.08)
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
сразу после старта масштаб 1».
«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
телефоне прекращается звук, очки тоже перестают играть.»
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
offset к центру). А `ConnectionScreen()` в
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
но на не-видео экране он не виден.
## Корень (диагноз подтверждён)
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ
foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity
(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов
WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая
политика) замораживает/убивает процесс:
```kotlin
fun ConnectionScreen() {
val connection = GlassesApp.instance.hostConnection
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
// масштаб через layout — как в GlassesMovieScreen
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = offsetX.px)
.background(Color.Black),
) {
... существующий контент ...
}
}
}
## Решение
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
```xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
Внутри `<application>` — сервис:
```xml
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
```
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
там есть `Side.LEFT/RIGHT` через CompositionLocal).
## Баг 2: скролл каталога теряется при навигации
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
**Фикс:** добавить в `CatalogState`:
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
В `CatalogScreen` composable:
```kotlin
val gridState = rememberLazyGridState()
// восстанавливаем позицию при смене пути/загрузке
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// сохраняем позицию при скролле
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
class PlaybackService : Service() {
companion object {
const val CHANNEL_ID = "viewmate_playback"
const val NOTIFICATION_ID = 1
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
fun start(context: Context) {
ContextCompat.startForegroundService(
context, Intent(context, PlaybackService::class.java)
)
}
fun stop(context: Context) {
context.stopService(Intent(context, PlaybackService::class.java))
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
// канал уведомлений (Android 8+)
val channel = NotificationChannel(
CHANNEL_ID, "ViewMate playback",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("ViewMate")
.setContentText("Звук на телефоне при просмотре на очках")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// держим CPU и WiFi живыми при выключенном экране
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
?.apply { acquire() }
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
START_NOT_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
```
и `LazyVerticalGrid(state = gridState, ...)`.
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
каждый уровень сохраняет свою позицию.
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
`androidx.compose.runtime.snapshotFlow`.
- **Старт — при каждом старте аудио** (3 места):
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
(строка ~127) и второй play (строка ~215)
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
приложение живо) — НО только если пользователь не против постоянного
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
уведомление — постоянный индикатор «ViewMate работает». Это проще и
покрывает и watch, и autostartAudio, и STT-сессии.
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
## Критерии приёмки
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
приложения-компаньона — так работает, например, в паре с очками).
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
### 4. Проверка
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
3. На железе (через 142): установить APK, запустить, начать просмотр,
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
проверить: звук играет, видео на очках играет (позиция растёт в логах),
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
+7
View File
@@ -9,6 +9,13 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<application
android:name=".GlassesApp"
@@ -0,0 +1,189 @@
package pw.binom.viewmate.glasses
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.net.BtTransportConstants
import pw.binom.viewmate.core.net.GlassesTransport
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
*/
class BtGlassesTransport(
private val context: Context,
) : GlassesTransport {
companion object {
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
@SuppressLint("MissingPermission")
private fun hasBtPermissions(ctx: Context): Boolean =
runtimePermissions().all {
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
}
}
override val name = "bluetooth"
@Volatile
private var closed = false
@Volatile
private var socket: BluetoothSocket? = null
private val outLock = Any()
private var out: OutputStream? = null
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
@SuppressLint("MissingPermission")
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
while (!closed) {
var connected = false
if (socket == null) {
if (!ensureSocket()) {
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
delay(BtTransportConstants.RETRY_DELAY_MS)
continue
}
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
}
try {
val sock = socket!!
withContext(Dispatchers.IO) { sock.connect() }
connected = true
out = sock.outputStream
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
onConnected()
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
readLines(sock, onMessage)
if (!closed) {
log("bt", "SPP-соединение оборвано")
onDisconnected()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
if (!closed && connected) runCatching { onDisconnected() }
}
runCatching { out?.close() }
out = null
runCatching { socket?.close() }
socket = null
if (closed) break
delay(BtTransportConstants.RETRY_DELAY_MS)
}
}
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
@SuppressLint("MissingPermission")
private fun ensureSocket(): Boolean {
val mac = GlassesConfig.btRemoteMac()
if (mac.isNullOrBlank()) {
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
return false
}
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
log("bt", "BT-радио не найдено — SPP недоступен")
return false
}
if (!adapter.isEnabled) {
log("bt", "Bluetooth выключен — включите в настройках Android")
return false
}
if (!hasBtPermissions(context)) {
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
return false
}
val dev = try {
adapter.getRemoteDevice(mac)
} catch (e: Exception) {
log("bt", "устройство не найдено: ${e.message}")
null
}
if (dev == null) return false
val s = try {
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
} catch (e: Exception) {
log("bt", "не удалось создать SPP-сокет: ${e.message}")
null
}
if (s == null) return false
socket = s
return true
}
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
val reader = BufferedReader(InputStreamReader(s.inputStream))
while (!closed) {
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
if (line.isNotBlank()) onMessage(line)
}
runCatching { reader.close() }
}
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
@SuppressLint("MissingPermission")
override suspend fun send(json: String) {
val o = out ?: return
runCatching {
synchronized(outLock) {
o.write(json.toByteArray(Charsets.UTF_8))
o.write(BtTransportConstants.NEWLINE.code)
o.flush()
}
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
}
override fun close() {
closed = true
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
}
}
@@ -11,6 +11,19 @@ object GlassesConfig {
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
// находится авто-обнаружением по mDNS (_viewmate._tcp).
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
/**
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
* ([BtGlassesTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
fun btRemoteMac(): String? = BT_REMOTE_MAC
}
/**
@@ -45,6 +58,20 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
*/
val thinking = MutableStateFlow(false)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
@@ -12,12 +12,15 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.net.GlassesTransport
import pw.binom.viewmate.core.net.WifiGlassesTransport
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
@@ -37,13 +40,15 @@ import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
/**
* Обвязка над [GlassesWsClient] для UI очков:
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
* Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
*/
class HostConnection(
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
@@ -56,11 +61,14 @@ class HostConnection(
private val discovery = MdnsClient(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
@Volatile
private var client: GlassesWsClient? = null
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
@Volatile
private var transport: GlassesTransport? = null
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
@Volatile
private var lastUrl: String? = null
@@ -81,10 +89,10 @@ class HostConnection(
scope.launch {
while (isActive) {
delay(5_000)
client?.send(buildStatus())
sendToHost(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send(
sendToHost(
PlaybackPosition(
positionMs = snapshot.positionMs,
playing = snapshot.playing,
@@ -96,36 +104,80 @@ class HostConnection(
}
}
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
private suspend fun connectLoop() {
if (useBt) connectLoopBt() else connectLoopWifi()
}
/**
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
* адресом (переиспользование последнего найденного); при его отсутствии —
* повторный mDNS-цикл.
*/
private suspend fun connectLoop() {
private suspend fun connectLoopWifi() {
val url = lastUrl ?: discoverPhoneUrl()
if (url.isEmpty()) {
log("app", "телефон не найден — повтор поиска через 3с")
delay(3_000)
connectLoop()
connectLoopWifi()
return
}
lastUrl = url
log("app", "подключаюсь к $url")
log("app", "подключаюсь к $url (WiFi)")
val c = GlassesWsClient(url)
client = c
c.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
val t = WifiGlassesTransport(url)
transport = t
t.connect(
onMessage = { text -> handleHostMessageText(text) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
_status.value = "подключено: ${t.name} $url"
addMessage("соединение установлено")
discovery.stop()
c.send(Hello(appVersion = APP_VERSION))
sendToHost(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
}
},
onDisconnected = {
_connected.value = false
_status.value = "нет соединения (реконнект через 3с)"
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
reconnectResume.onConnectionLost(wasPlaying)
if (wasPlaying) addMessage("связь потеряна — пауза")
},
)
}
/**
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
*/
private suspend fun connectLoopBt() {
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
if (mac.isEmpty()) {
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
delay(3_000)
connectLoopBt()
return
}
lastUrl = mac
log("app", "подключаюсь к $mac (Bluetooth SPP)")
val t = BtGlassesTransport(appContext)
transport = t
t.connect(
onMessage = { text -> handleHostMessageText(text) },
onConnected = {
_connected.value = true
_status.value = "подключено: bluetooth $mac"
addMessage("соединение установлено")
sendToHost(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
@@ -145,7 +197,7 @@ class HostConnection(
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
*/
private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
@@ -171,18 +223,29 @@ class HostConnection(
return ""
}
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
private suspend fun handleHostMessageText(text: String) {
val msg = runCatching {
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
}.onFailure {
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
}.getOrNull() ?: return
handleHostMessage(msg)
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) {
client?.send(msg)
val t = transport ?: return
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
}
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() {
val c = client ?: return
if (transport == null) return
scope.launch {
val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
}
}
@@ -270,6 +333,8 @@ class HostConnection(
is ShowText -> {
addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text
}
@@ -288,6 +353,7 @@ class HostConnection(
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = false
}
is Shutdown -> {
@@ -306,7 +372,7 @@ class HostConnection(
}
fun stop() {
client?.close()
transport?.close()
discovery.stop()
scope.cancel()
}
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle
@@ -18,7 +19,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -36,7 +36,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -51,10 +50,14 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.GestureDecision
import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.gestures.assistantMode
import pw.binom.viewmate.glasses.gestures.gestureDecision
import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
@@ -79,9 +82,9 @@ class MainActivity : BaseEventActivity() {
private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick()
"click" -> handleClick()
"double_click" -> handleDoubleClick()
"triple_click" -> onTempleAction(TempleAction.TripleClick())
"click" -> onTempleAction(TempleAction.Click())
"double_click" -> onTempleAction(TempleAction.DoubleClick())
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
}
}
@@ -116,6 +119,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
requestBtPermissions()
collectTempleActions()
disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -135,6 +139,9 @@ class MainActivity : BaseEventActivity() {
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
}
}
@@ -188,78 +195,137 @@ class MainActivity : BaseEventActivity() {
}, null)
}
/**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun requestBtPermissions() {
if (!GlassesConfig.USE_BLUETOOTH) return
val granted = BtGlassesTransport.runtimePermissions().all {
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
if (granted) return
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
}
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
private fun collectTempleActions() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action ->
when (action) {
is TempleAction.TripleClick -> handleTripleClick()
is TempleAction.Click -> handleClick()
is TempleAction.DoubleClick -> handleDoubleClick()
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
val cmd = templeActionToCommand(action)
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
if (cmd != null) {
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
}
else -> log("gesture", "жест (резерв): ${actionName(action)}")
}
onTempleAction(action)
}
}
}
}
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
private fun handleTripleClick() {
if (GlassesApp.instance.listening.value) {
/**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
*/
private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance
val state = AssistantState(
listening = app.listening.value,
thinking = app.thinking.value,
sttText = app.sttText.value,
assistantText = app.assistantText.value,
)
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
private fun startAssistantMode() {
val app = GlassesApp.instance
if (app.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)")
if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = ""
GlassesApp.instance.assistantText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
sttMic.start(filesDir) { app.listening.value }
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
private fun handleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
/** Click (кино): плей/пауза — как сегодня. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
private fun handleDoubleClick() {
if (GlassesApp.instance.listening.value) {
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу")
sttMic.stop()
GlassesApp.instance.thinking.value = true
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
}
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
GlassesApp.instance.thinking.value = false
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
return
}
log("gesture", "жест (резерв): DoubleClick")
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() {
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
sttMic.stop()
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/**
@@ -399,41 +465,7 @@ fun ConnectionScreen() {
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
)
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
// (assistantText) — ответ читается до новой сессии.
val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState()
val assistantText by GlassesApp.instance.assistantText.collectAsState()
if (listening || assistantText.isNotBlank()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f)),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
if (assistantText.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
}
}
}
@@ -0,0 +1,151 @@
package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand
/**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* @NONE — кино: ассистент не вызван.
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
* @ANSWER — ответ виден: listening=false и assistantText непуст.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */
val listening: Boolean = false,
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */
val sttText: String = "",
/** Ответ ассистента (ShowText). */
val assistantText: String = "",
)
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.NONE
}
/**
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
* Матрица (docs/gestures-and-modes.md):
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
sealed interface GestureDecision {
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
data object Ignore : GestureDecision
/** TripleClick (кино): пауза фильма + открыть микрофон. */
data object CallAssistant : GestureDecision
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
data object TogglePlayback : GestureDecision
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
data object SendPhrase : GestureDecision
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
data object CancelDictation : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
data class ScrollAnswer(val delta: Float) : GestureDecision
}
/**
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
*
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) {
is TempleAction.Click -> GestureDecision.TogglePlayback
is TempleAction.TripleClick -> GestureDecision.CallAssistant
else ->
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
?: GestureDecision.Ignore
}
AssistantMode.LISTENING -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
else -> GestureDecision.Ignore
}
AssistantMode.THINKING -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
AssistantMode.ANSWER -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore
}
}
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank()
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null).
*/
fun overlayHeader(state: AssistantState): String? = when {
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." }
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
* Чистая функция — unit-тесты без Android.
*/
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
(currentOffset + delta).toInt().coerceIn(0, maxScroll)
@@ -0,0 +1,131 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.overlayHeader
import pw.binom.viewmate.glasses.gestures.overlayVisible
import pw.binom.viewmate.glasses.gestures.scrollOffset
/**
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay() {
val app = GlassesApp.instance
val listening by app.listening.collectAsState()
val thinking by app.thinking.collectAsState()
val sttText by app.sttText.collectAsState()
val assistantText by app.assistantText.collectAsState()
val state = AssistantState(listening, thinking, sttText, assistantText)
if (!overlayVisible(state)) return
overlayBox(app, state)
}
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
@Composable
private fun overlayBox(app: GlassesApp, state: AssistantState) {
Binocular(scale = false) {
val scale by app.scale.collectAsState()
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black.copy(alpha = 0.72f)),
) {
AssistantOverlayContent(state)
}
}
}
@Composable
private fun AssistantOverlayContent(state: AssistantState) {
val app = GlassesApp.instance
val hasAnswer = state.assistantText.isNotBlank()
val header = overlayHeader(state)
val scrollState = rememberScrollState()
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
LaunchedEffect(state.assistantText) {
scrollState.scrollTo(0)
}
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center,
) {
if (!header.isNullOrBlank()) {
Text(
text = header,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
}
if (hasAnswer) {
Spacer(modifier = Modifier.height(12.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(scrollState),
) {
Text(
text = state.assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
@@ -0,0 +1,255 @@
package pw.binom.viewmate.glasses.gestures
import android.view.MotionEvent
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.touch.TempleAction.Click
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import pw.binom.viewmate.core.MediaCommand
/**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
*/
class AssistantGesturesTest {
private val movie = AssistantState()
private val listening = AssistantState(listening = true)
private val thinking = AssistantState(thinking = true)
private val answer = AssistantState(assistantText = "ответ")
// ————— режим по состоянию —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningWinsOverEverything() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
}
@Test
fun thinkingOnlyWhenNoAnswer() {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
// ————— кино ( NONE) —————
@Test
fun movieMatrix() {
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
gestureDecision(slideForward(), movie),
)
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
gestureDecision(slideBackwardAction(), movie),
)
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
}
@Test
fun movieSeekStepIsParameter() {
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
}
// ————— слушаю —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
}
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
@Test
fun thinkingMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
}
// ————— «ответ виден» —————
@Test
fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
@Test
fun answerPageDeltaIsParameter() {
assertEquals(
GestureDecision.ScrollAnswer(80f),
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
)
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@Test
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true)
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
s = s.copy(thinking = false, assistantText = "ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "")
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
@Test
fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision)
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInListeningThinkingAndAnswer() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)),
)
}
@Test
fun listeningHeaderFallsBackToListening() {
assertEquals("слушаю...", overlayHeader(listening))
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
}
@Test
fun answerHeaderIsLastPhraseOrNull() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
assertNull(overlayHeader(answer))
assertNull(overlayHeader(movie))
}
// ————— скролл ответа —————
@Test
fun scrollOffsetAppliesDelta() {
assertEquals(342, scrollOffset(132, 210f, 10_000))
}
@Test
fun scrollOffsetClampsAboveMax() {
assertEquals(1000, scrollOffset(800, 1000f, 1000))
}
@Test
fun scrollOffsetClampsBelowZero() {
assertEquals(0, scrollOffset(100, -500f, 1000))
}
/**
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
*/
@Test
fun scrollOffsetTruncatesFractionalDelta() {
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
}
@Test
fun scrollOffsetWithZeroMax() {
assertEquals(0, scrollOffset(5, 100f, 0))
}
/**
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
* Как в GestureCommandsTest.
*/
private fun fakeMotionEvent(): MotionEvent {
val unsafeClass = Class.forName("sun.misc.Unsafe")
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
theUnsafe.isAccessible = true
val unsafe = theUnsafe.get(null)
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
}
private fun unsafeFlingArgs(): FlingArgs {
val me1 = fakeMotionEvent()
val me2 = fakeMotionEvent()
return FlingArgs(me1, me2, 1f, 1f)
}
private fun slideForward() = SlideForward(unsafeFlingArgs())
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
}
+3
View File
@@ -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")
+28
View File
@@ -0,0 +1,28 @@
---
name: media
description: поиск фильмов и сериалов, детали, запуск и управление просмотром на очках.
---
Скилл: медиа (Jellyfin-каталог, просмотр на очках).
Шаги:
1. Для работы с медиа сначала активируй набор: TOOL_CALL: enable_toolset|media
— дождись ответа: в нём реестр тул (media_search, media_info, media_play) и пометка,
что сам поиск ещё НЕ выполнялся.
2. Поиск: TOOL_CALL: media_search|<название> [limit=<кол-во, по умолчанию 5>]
— результат: строки «id: Название (Год) — Тип». Покажи пользователю найденное
и спроси, что включить (или включи прямо, если выбор ясный).
3. Детали: TOOL_CALL: media_info|<id> — название, тип, год, описание, длительность, постер.
4. Запуск: TOOL_CALL: media_play|<id> [seek_ms=<позиция в мс>]
— видео должно быть скачано на очки, звук — на телефон (это проверка исполнителя,
а не твоя: если не готово — в ответе будет точная причина и команда заказа).
Питфоллы:
- media_* вызывается ТОЛЬКО когда тулсет активен: если набор не активен, сначала
enable_toolset|media — иначе система подменит вызов на активацию и скажет,
что поиск ещё не выполнялся.
- enable_toolset|media повторять не нужно: повторный вызов — продление таймаута,
не ошибка. Таймаут активности 10 минут с последнего вызова.
- Если media_play вернул «видео не скачано / звук не готов / очки не подключены»
— сообщи пользователю как есть (это фактическое состояние, не сбой нейросети)
и предложи дождаться или скачать вручную.
- Ответы тул фактические: не додумывай фильмов, которых нет в результате media_search.
+25
View File
@@ -7,7 +7,18 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".PhoneApp"
@@ -16,6 +27,15 @@
android:allowBackup="true"
android:usesCleartextTraffic="true">
<!-- NOTE 2026-08-23 (porfiry): точь-в-точь как у автора — ВНУТРИ <application>
(AndroidManifest.xml автора, строки 59-61: три uses-native-library).
Системные libOpenCL.so (Adreno) + libcdsprpc.so (Qualcomm-расчёты)
видны из app-namespace; без них LiteRT-LM падает на
"Can not find OpenCL library on this device". -->
<uses-native-library android:name="libvndksupport.so" android:required="false"/>
<uses-native-library android:name="libOpenCL.so" android:required="false"/>
<uses-native-library android:name="libcdsprpc.so" android:required="false"/>
<activity
android:name=".MainActivity"
android:exported="true">
@@ -24,5 +44,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,200 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.net.BtTransportConstants
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
* (дефолт — [WifiServerTransport]).
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
*/
class BtServerTransport(
private val context: Context? = null,
) : GlassesServerTransport {
companion object {
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
private fun hasBtPermissions(ctx: Context): Boolean =
runtimePermissions().all {
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
}
}
override val name = "bluetooth"
@Volatile
private var closed = false
@Volatile
private var serverSocket: BluetoothServerSocket? = null
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
private val writers = ConcurrentHashMap<String, OutputStream>()
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
while (!closed) {
if (serverSocket == null) {
serverSocket = openServerSocket()
if (serverSocket == null) {
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
delay(BtTransportConstants.RETRY_DELAY_MS)
continue
}
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
}
try {
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
if (closed) {
runCatching { s.close() }
break
}
val remote: BluetoothDevice = s.remoteDevice
val connId = remote.address
sockets[connId] = s
writers[connId] = s.outputStream
log("bt", "очки подключились: $connId")
onConnected(connId)
readerJobs[connId] = readerScope.launch {
readLines(s, connId, onMessage, onDisconnected)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
runCatching { serverSocket?.close() }
serverSocket = null
delay(BtTransportConstants.RETRY_DELAY_MS)
}
}
cleanupConnections()
readerScope.cancel()
}
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
@SuppressLint("MissingPermission")
private fun openServerSocket(): BluetoothServerSocket? {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
log("bt", "BT-радио не найдено — SPP недоступен")
return null
}
if (!adapter.isEnabled) {
log("bt", "Bluetooth выключен — включите в настройках Android")
return null
}
val ctx = context
if (ctx != null && !hasBtPermissions(ctx)) {
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
return null
}
return runCatching {
adapter.listenUsingRfcommWithServiceRecord(
BtTransportConstants.SERVICE_NAME,
UUID.fromString(BtTransportConstants.SPP_UUID),
)
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
}
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
private suspend fun readLines(
s: BluetoothSocket,
connId: String,
onMessage: suspend (connId: String, json: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
val reader = BufferedReader(InputStreamReader(s.inputStream))
try {
while (!closed) {
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
if (line.isNotBlank()) onMessage(connId, line)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "read-поток $connId оборван: ${e.message}")
} finally {
runCatching { reader.close() }
runCatching { s.close() }
sockets.remove(connId)
writers.remove(connId)
readerJobs.remove(connId)
}
if (!closed) {
log("bt", "очки отключились: $connId")
onDisconnected(connId)
}
}
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
override suspend fun send(connId: String, json: String) {
val out = writers[connId] ?: return
runCatching {
synchronized(out) {
out.write(json.toByteArray(Charsets.UTF_8))
out.write(BtTransportConstants.NEWLINE.code)
out.flush()
}
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
}
override fun close() {
closed = true
runCatching { serverSocket?.close() }
serverSocket = null
cleanupConnections()
readerScope.cancel()
}
private fun cleanupConnections() {
readerJobs.values.forEach { it.cancel() }
readerJobs.clear()
sockets.values.forEach { runCatching { it.close() } }
sockets.clear()
writers.clear()
}
}
@@ -0,0 +1,148 @@
package pw.binom.viewmate.phone
import com.google.ai.edge.litertlm.Backend
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmClient
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetAddress
import java.net.ServerSocket
/**
* Отладочный HTTP-сервер в приложении (только для разработки).
* Позволяет дёргать ассистента и переключать модель без UI:
*
* GET /status — выбор модели, готовность движка, размер файла
* POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI)
* POST /model {"choice":"local"} — переключить модель (local|remote), персист
*
* Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`.
*/
object DebugHttpServer {
private const val PORT = 8787
private const val TAG = "httpd"
@Volatile
private var running = false
fun start(app: PhoneApp) {
if (running) return
running = true
Thread {
try {
val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)")
while (running) {
try {
val socket = server.accept()
Thread {
try {
handle(socket, app)
} catch (e: Exception) {
log(TAG, "обработка запроса: ${e.message}")
} finally {
runCatching { socket.close() }
}
}.apply { isDaemon = true }.start()
} catch (e: Exception) {
if (running) log(TAG, "accept: ${e.message}")
}
}
} catch (e: Exception) {
log(TAG, "не удалось запустить: ${e.message}")
}
}.apply { isDaemon = true; name = "viewmate-httpd" }.start()
}
private fun handle(socket: java.net.Socket, app: PhoneApp) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val requestLine = reader.readLine() ?: return
val parts = requestLine.split(" ")
if (parts.size < 2) return
val method = parts[0]
val path = parts[1]
// Тело (до Content-Length)
var contentLength = 0
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true)) {
contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
}
}
val body = if (contentLength > 0) {
val buf = CharArray(contentLength)
var read = 0
while (read < contentLength) {
val n = reader.read(buf, read, contentLength - read)
if (n < 0) break
read += n
}
String(buf, 0, read)
} else ""
val response: String
val code: Int
when {
method == "GET" && path.startsWith("/status") -> {
val choice = app.llmChoice.value
val local = app.localLlmClient()
val file = app.localModelFile()
response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}"""
code = 200
}
method == "POST" && path.startsWith("/chat") -> {
val text = jsonField(body, "text") ?: ""
if (text.isBlank()) {
response = """{"error":"пустой text"}"""
code = 400
} else {
app.sendToAssistant(text)
response = """{"ok":true,"queued":"${text.take(60)}"}"""
code = 200
}
}
method == "POST" && path.startsWith("/model") -> {
val choice = jsonField(body, "choice")
if (choice == "local" || choice == "remote") {
app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE)
response = """{"ok":true,"choice":"$choice"}"""
code = 200
} else {
response = """{"error":"choice должен быть local|remote"}"""
code = 400
}
}
else -> {
response = """{"error":"нет такого пути: $method $path"}"""
code = 404
}
}
val out = socket.getOutputStream()
val payload = response.toByteArray(Charsets.UTF_8)
out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8))
out.write(payload)
out.flush()
}
private fun localStatus(local: LocalLlmClient?, file: java.io.File): String {
if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}"""
return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}"""
}
private fun jsonField(body: String, key: String): String? {
// {"text":"..."} — примитивный парсер, без кавычек внутри значения
val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"")
return regex.find(body)?.groupValues?.get(1)
}
private fun reason(code: Int): String = when (code) {
200 -> "OK"
400 -> "Bad Request"
404 -> "Not Found"
else -> "Error"
}
}
@@ -1,23 +1,18 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadProgress
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
* DownloadProgress → карта скачивания на очки).
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
*/
class GlassesHub(
private val state: PhoneState,
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
private val transport: GlassesServerTransport,
) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
/** Сессии: connId → Unit. */
private val sessions = ConcurrentHashMap<String, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow()
@@ -110,32 +109,29 @@ class GlassesHub(
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
fun add(connId: String) {
sessions[connId] = Unit
_connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}")
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
}
fun remove(session: DefaultWebSocketServerSession) {
sessions.remove(session)
fun remove(connId: String) {
sessions.remove(connId)
_connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}")
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
}
/** Отправить сообщение всем подключённым очкам. */
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) {
try {
session.send(text)
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
for (connId in sessions.keys) {
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
suspend fun handle(connId: String, text: String) {
val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) {
@@ -144,8 +140,8 @@ class GlassesHub(
}
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
}
is Gesture -> {
@@ -199,80 +195,67 @@ class GlassesHub(
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(session, SttCancel())
send(connId, SttCancel())
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
send(connId, SttDone(full))
stt?.reset()
}
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
private suspend fun send(connId: String, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
}
}
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
fun Application.glassesServerModule(hub: GlassesHub) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
hub.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
hub.handle(this, text)
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}
}
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
}
/**
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
* (SPP). Поведение по умолчанию (WiFi) не меняется.
*/
class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT,
private val state: PhoneState = PhoneState(),
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
useBluetooth: Boolean = false,
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
private val btContext: Context? = null,
) {
val hub = GlassesHub(state)
/** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
val transport: GlassesServerTransport =
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
val hub = GlassesHub(state, transport)
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
val sender: GlassesSender = object : GlassesSender {
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
}
private var server: EmbeddedServer<*, *>? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var acceptJob: Job? = null
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
fun start() {
if (server != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesServerModule(hub)
if (acceptJob != null) return
acceptJob = scope.launch {
transport.accept(
onMessage = { connId, json -> hub.handle(connId, json) },
onConnected = { connId -> hub.add(connId) },
onDisconnected = { connId -> hub.remove(connId) },
)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
}
fun stop() {
server?.stop(1_000, 3_000)
server = null
transport.close()
scope.cancel()
}
}
@@ -1,6 +1,18 @@
package pw.binom.viewmate.phone
/** Единый лог телефона: простой println (работает и в unit-тестах). */
import android.util.Log
/**
* Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
* чтобы не падать.
*/
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
val full = "[view-mate] $tag $message"
runCatching {
Log.d("view-mate-$tag", full)
}.onFailure {
println(full)
}
}
@@ -1,5 +1,7 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp
@@ -15,6 +18,7 @@ import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestBtPermissions()
setContent {
ViewMateApp()
}
@@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
*/
private fun requestBtPermissions() {
if (!PhoneConfig.USE_BLUETOOTH) return
val needed = BtServerTransport.runtimePermissions()
val missing = needed.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
requestPermissions(missing.toTypedArray(), 1009)
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
}
}
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...")
@@ -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
@@ -24,10 +26,23 @@ import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase
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.LlmClient
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.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
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
@@ -39,8 +54,13 @@ class PhoneApp : Application() {
val state: PhoneState = PhoneState()
/** WS-сервер для очков. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
val server: GlassesServer = GlassesServer(
port = PhoneConfig.SERVER_PORT,
state = state,
useBluetooth = PhoneConfig.USE_BLUETOOTH,
btContext = this,
)
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
@@ -85,16 +105,165 @@ 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-клиент (для DebugHttpServer / статуса). */
fun localLlmClient(): LocalLlmClient? = localLlm
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
// тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте).
val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) }
private val _llmChoice: MutableStateFlow<LlmModelChoice> by lazy { MutableStateFlow(llmPrefs.current()) }
val llmChoice: StateFlow<LlmModelChoice> by lazy { _llmChoice.asStateFlow() }
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
private val _localLlmState: MutableStateFlow<LocalLlmModelState> by lazy { MutableStateFlow(initialLocalLlmState()) }
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _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)
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
.also { assistant = it }
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 -> {
// cacheDir — подкаталог cache/litertlm: в 11:46 именно с ним
// (без спекуляции) движок инициализировался за 12с; с корнем
// cache/ initialize() виснет. GPU-делегату нужен СВОЙ каталог.
val cacheDir = File(cacheDir, "litertlm")
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
}
}
val toolsets = ToolsetRegistry()
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
val tk = toolkit ?: AgentToolkit(
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
toolsets = toolsets,
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
).also { toolkit = it }
return Assistant(
chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) },
toolkit = tk,
// Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L,
).also {
assistant = it
assistantChoice = choice
}
}
}
/** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */
private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor(
media = JellyfinMediaSource(jellyfin) { userId.value },
player = { id, seekMs -> agentPlayMedia(id, seekMs) },
)
/**
* Тул ассистента media_play: проверки (очки на связи, видео скачано на очки,
* звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen)
* + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели.
*/
private suspend fun agentPlayMedia(id: String, seekMs: Long): String {
val hub = server.hub
if (hub.connected.value == 0) return "Очки не подключены."
val mediaDir = hub.mediaDir.value
if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже."
if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон."
if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон."
val acts = actions ?: return "Телефон ещё не готов."
val audioIndex = state.currentAudioIndex
log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс")
val result = acts.watch(
itemId = id,
audioIndex = audioIndex,
localVideoPath = "file://$mediaDir/$id/video.mkv",
phoneAudioReady = true,
startPositionMs = seekMs,
)
if (!result.contains("включил")) return result
// Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим.
if (audioSync.isActive) audioSync.stop()
val audio = resolveAudioSourceDetailed(
mirrorFiles = mirror.mirrorByItem(id)?.files,
audioIndex = audioIndex,
localAudioFile = { downloadManager.audioFile(id, it) },
)
if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}"
audioSync.play(audio.source, seekMs.coerceAtLeast(0L))
return "Включено: «${state.currentItem?.Name ?: id}»"
}
/**
@@ -103,13 +272,21 @@ class PhoneApp : Application() {
*/
private suspend fun assistantLoop() {
for (phrase in assistantChannel) {
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))
val answer = runCatching { assistant.process(phrase) }
val answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
}
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer))
@@ -117,6 +294,36 @@ class PhoneApp : Application() {
}
}
/**
* Отправка текста в ассистента из чата на телефоне (не с очков).
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
*/
fun sendToAssistant(text: String) {
scope.launch { assistantChannel.send(text) }
}
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
private val _activeSessionId = MutableStateFlow<Long?>(null)
val activeSessionId: StateFlow<Long?> = _activeSessionId
/** Создать новую сессию и сделать её активной. */
fun newChatSession(): Long {
val id = chatDao.createSession()
_activeSessionId.value = id
return id
}
/** Переключить активную сессию. null — вернуться к «последней». */
fun selectChatSession(id: Long?) {
_activeSessionId.value = id
}
/** Сбросить (очистить сообщения) активной или последней сессии. */
fun resetChatSession() {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
chatDao.clearSession(id)
}
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
@@ -189,8 +396,12 @@ class PhoneApp : Application() {
super.onCreate()
instance = this
PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this)
nsd.publish()
server.hub.sttFactory = { ensureStt() }
scope.launch { assistantLoop() }
@@ -264,6 +475,7 @@ class PhoneApp : Application() {
}
override fun onTerminate() {
PlaybackService.stop(this)
nsd.unpublish()
sttStreamer?.close()
server.stop()
@@ -12,6 +12,13 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
/**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
@@ -0,0 +1,75 @@
package pw.binom.viewmate.phone
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
*/
class PlaybackService : Service() {
companion object {
const val CHANNEL_ID = "viewmate_playback"
const val NOTIFICATION_ID = 1
fun start(context: Context) {
ContextCompat.startForegroundService(
context, Intent(context, PlaybackService::class.java)
)
}
fun stop(context: Context) {
context.stopService(Intent(context, PlaybackService::class.java))
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "ViewMate playback",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("ViewMate")
.setContentText("Звук на телефоне при просмотре на очках")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// держим CPU и WiFi живыми при выключенном экране
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
?.apply { acquire() }
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
START_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,98 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.delay
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
* connId живёт здесь, [GlassesHub] знает только строки.
*/
class WifiServerTransport(
private val port: Int = PhoneConfig.SERVER_PORT,
) : GlassesServerTransport {
override val name = "wifi"
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
@Volatile
private var server: EmbeddedServer<*, *>? = null
@Volatile
private var closed = false
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesWsModule(onMessage, onConnected, onDisconnected)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
while (!closed) {
delay(500)
}
}
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
private fun Application.glassesWsModule(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
val connId = UUID.randomUUID().toString()
sessionByConn[connId] = this
onConnected(connId)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
onMessage(connId, frame.readText())
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
sessionByConn.remove(connId)
onDisconnected(connId)
}
}
}
}
override suspend fun send(connId: String, json: String) {
sessionByConn[connId]?.send(json)
}
override fun close() {
closed = true
runCatching { server?.stop(1_000, 3_000) }
server = null
sessionByConn.clear()
}
}
@@ -0,0 +1,267 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.core.log
import java.util.concurrent.ConcurrentHashMap
/**
* Механика агента: ядро тулов + тулсеты + скиллы + сборка системного
* промпта. Чистая JVM-логика (без Android), юнит-тестируется.
*
* Три состояния промпта (пересобирается на каждый запрос):
* 1. Набор не активен: ядро + каталог тулсетов + индекс скиллов.
* 2. Активация: enable пересобирает промпт, сам вызов в историю НЕ пишется
* («системная тула-невидимка»).
* 3. Активен: ядро + описания тул набора + «Активные тулсеты: имя»;
* скилл, связанный с набором (имя скилла = имя тулсета), УБРАН из промпта.
*
* Бесшовная подмена: тул набора при НЕактивном наборе не выполняется вслепую —
* подменяется на enable_toolset|набор и модели явно сообщается, что вызов ещё
* НЕ выполнялся (иначе она выдумает результат). Если аргументы совпадают со
* схемой и исполнитель есть — вызов выполняется сразу, без второго круга.
*/
class AgentToolkit(
private val skills: SkillRegistry,
private val toolsets: ToolsetRegistry,
private val showText: ((String) -> Unit)? = null,
) {
companion object {
const val TOOL_TIME = "get_current_time"
const val TOOL_SHOW_TEXT = "show_text"
const val TOOL_READ_SKILL = "read_skill"
const val TOOL_ENABLE = "enable_toolset"
const val TOOL_DISABLE = "disable_toolset"
/** Ядро: 5 тул, всегда в промпте, компактно. */
val CORE_TOOL_NAMES: List<String> =
listOf(TOOL_TIME, TOOL_SHOW_TEXT, TOOL_READ_SKILL, TOOL_ENABLE, TOOL_DISABLE)
private val CORE_LINES = listOf(
"get_current_time — текущие дата, время, часовой пояс (ISO 8601).",
"show_text|text — показать текст на очках.",
"read_skill|имя — прочитать полное содержимое скилла по имени.",
"enable_toolset|имя — активировать тулсет (живёт 10 мин от последнего вызова его тул).",
"disable_toolset|имя — деактивировать тулсет.",
)
}
/** Одноразовые напоминания по сессиям (невидимые тулы: «уже активен», «набора нет»). */
private val pendingNotes = ConcurrentHashMap<Long, String>()
// ---------- сборка промпта ----------
/**
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
*/
fun systemPrompt(sessionId: Long): String {
val note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
sb.append("\n\nЯдро:")
CORE_LINES.forEach { sb.append('\n').append(it) }
val catalog = toolsets.catalogLines()
if (catalog.isNotEmpty()) {
sb.append("\n\nКаталог тулсетов (активация — enable_toolset|имя):")
catalog.forEach { sb.append('\n').append(it) }
}
if (activeSets.isNotEmpty()) {
sb.append("\n\nАктивные тулсеты: ").append(activeSets.joinToString(", "))
activeSets.forEach { name ->
toolsets.toolset(name)?.toolLines()?.forEach { sb.append('\n').append(it) }
}
}
val skillLines = skills.index(excluded = activeSets.toSet())
if (skillLines.isNotEmpty()) {
sb.append("\n\nСкиллы (read_skill|имя):")
skillLines.forEach { sb.append('\n').append(it) }
}
if (note != null) sb.append("\n\nВажно: ").append(note)
return sb.toString()
}
// ---------- парсинг аргументов ----------
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
fun schemaFor(tool: String): List<String> = when (tool) {
TOOL_TIME -> emptyList()
TOOL_SHOW_TEXT -> listOf("text")
TOOL_READ_SKILL -> listOf("name")
TOOL_ENABLE, TOOL_DISABLE -> listOf("name")
else -> toolsets.signatureOf(tool)?.args ?: emptyList()
}
/**
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
*/
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
val s = raw?.trim()
if (s.isNullOrEmpty()) return emptyMap()
val required = expected.filterNot { it.endsWith("?") }.distinct()
val out = LinkedHashMap<String, String>()
var bareIndex = 0
for (field in s.split(ALTER)) {
val f = field.trim()
if (f.isEmpty()) continue
val eq = f.indexOf('=')
if (eq > 0) {
val key = f.substring(0, eq).trim()
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
} else if (bareIndex < required.size) {
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
out[required[bareIndex++]] = f
}
}
return out
}
private val ALTER = Regex("[,&]")
// ---------- диспач вызовов ----------
/**
* Результат диспача:
* [Run] — результат пишется в историю (TOOL-сообщение);
* [Silent] — невидимая системная тула (enable/disable_toolset): в историю
* НЕ пишется, просто пересобирается промпт на следующем запросе.
*/
sealed interface Dispatch {
class Run(val result: String) : Dispatch
object Silent : Dispatch
}
/**
* Диспач вызова тула: ядро + префиксная маршрутизация в тулсеты.
* Все тулы идемпотентные и прощающие; аргументы валидируются по реестру.
*/
fun dispatch(sessionId: Long, tool: String, args: Map<String, String>): Dispatch {
when (tool) {
TOOL_TIME -> return Dispatch.Run(TimeTool.invoke("{}"))
TOOL_SHOW_TEXT -> {
val text = args["text"]
if (text.isNullOrBlank()) {
return Dispatch.Run(
"show_text: аргументы неверны, не хватает \"text\". Схема: show_text|text=<текст>."
)
}
if (showText == null) {
return Dispatch.Run("Текст \"$text\" не показан: отображение на очки не подключено.")
}
showText(text)
log("toolset", "show_text (сессия $sessionId): $text")
return Dispatch.Run("Показано на очках: $text")
}
TOOL_READ_SKILL -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("read_skill: аргументы неверны, не хватает \"name\". Схема: read_skill|имя=<скилл>.")
}
val content = skills.readFull(name)
?: return Dispatch.Run("Скилл \"$name\" не найден. " + skillsAvailable())
log("toolset", "read_skill (сессия $sessionId): $name")
return Dispatch.Run(content)
}
TOOL_ENABLE -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("enable_toolset: аргументы неверны, не хватает \"name\". Схема: enable_toolset|имя=<тулсет>.")
}
when (val r = toolsets.enable(sessionId, name)) {
is EnableResult.Activated ->
setNote(sessionId, if (r.alreadyActive) "Тулсет \"$name\" уже активен, таймаут продлён." else null)
is EnableResult.Unknown -> setNote(sessionId, r.message)
}
log("toolset", "enable_toolset (сессия $sessionId): $name")
return Dispatch.Silent
}
TOOL_DISABLE -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("disable_toolset: аргументы неверны, не хватает \"name\". Схема: disable_toolset|имя=<тулсет>.")
}
when (val r = toolsets.disable(sessionId, name)) {
is DisableResult.Disabled -> setNote(sessionId, null)
is DisableResult.NotActive -> setNote(sessionId, r.message)
}
log("toolset", "disable_toolset (сессия $sessionId): $name")
return Dispatch.Silent
}
}
// Префиксная маршрутизация: media_* → набор media.
val ts = toolsets.findByPrefix(tool)
?: return Dispatch.Run("Тул \"$tool\" не найден. " + available())
val sig = ts.signatureOf(tool)
?: return Dispatch.Run(
"Тул \"$tool\" в тулсете \"${ts.name}\" нет. Тулы тулсета: " +
ts.toolLines().joinToString("; ") + "."
)
if (!toolsets.isActive(sessionId, ts.name)) {
// Бесшовная подмена: набор неактивен — активируем его (как будто
// вызван enable_toolset|набор) и явно говорим, что вызов ещё не выполнен.
toolsets.enable(sessionId, ts.name)
log("toolset", "подмена $tool → enable_toolset|${ts.name} (сессия $sessionId)")
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
val executor = toolsets.executor(ts.name)
if (missing.isEmpty() && executor != null) {
val result = executor.execute(ts.name, tool, args)
toolsets.touch(sessionId, ts.name)
return Dispatch.Run(result)
}
return Dispatch.Run(substitutionMessage(ts, tool, sig))
}
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
if (missing.isNotEmpty()) {
val schema = buildString {
append(tool)
if (sig.args.isNotEmpty()) append('|').append(sig.args.joinToString(","))
}
return Dispatch.Run(
"$tool: аргументы неверны, не хватает обязательных: ${missing.joinToString(", ")}. Схема: $schema."
)
}
val executor = toolsets.executor(ts.name)
?: return Dispatch.Run("Тулсет \"${ts.name}\" активен, но его тулы ещё не реализованы.")
val result = executor.execute(ts.name, tool, args)
toolsets.touch(sessionId, ts.name)
log("toolset", "$tool (сессия $sessionId) → $result")
return Dispatch.Run(result)
}
/** Сообщение подмены: набор активирован + реестр + явная пометка «вызов ещё не выполнен». */
private fun substitutionMessage(ts: Toolset, tool: String, sig: ToolSignature): String {
val registry = ts.toolLines().joinToString("; ")
val usage = when {
sig.args.isEmpty() -> "Для выполнения вызови $tool."
else -> "Для выполнения вызови $tool с аргументами: ${sig.argNames().joinToString(", ")}."
}
return "Тулсет ${ts.name} активирован. Реестр: $registry. " +
"Вызов $tool ещё НЕ выполнялся. $usage"
}
private fun setNote(sessionId: Long, note: String?) {
if (note == null) pendingNotes.remove(sessionId) else pendingNotes[sessionId] = note
}
private fun skillsAvailable(): String =
"Доступные скиллы: " + skills.names().joinToString(", ").ifBlank { "нет скиллов" }
private fun available(): String {
val sets = toolsets.names()
return "Доступны: " + CORE_TOOL_NAMES.joinToString(", ") +
if (sets.isEmpty()) ". Тулсетов нет." else ". Тулсеты: " + sets.joinToString(", ") + "."
}
}
@@ -0,0 +1,53 @@
package pw.binom.viewmate.phone.agent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
* имени. Результат возвращается строкой и подставляется в историю как
* tool-сообщение (роль [ChatRole.TOOL]).
*
* Добавление нового тула: реализовать [AgentTool], добавить в список
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
* [describe]) — никакой магии.
*/
interface AgentTool {
/** Имя тула (как агент его называет, например `get_current_time`). */
val name: String
/** Короткое описание для системного промпта (что делает, что возвращает). */
val description: String
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
fun invoke(arguments: String): String
}
/** Реестр тулов ассистента. */
object AgentTools {
val ALL: List<AgentTool> = listOf(TimeTool)
/** Описания для системного промпта: «Тул: name — description». */
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
}
/**
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
* телефоне — LLM не нужна, время точное.
*/
object TimeTool : AgentTool {
override val name = "get_current_time"
override val description =
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
private val formatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
override fun invoke(arguments: String): String {
val now = Instant.now()
val zone = ZoneId.systemDefault()
return formatter.format(now.atZone(zone))
}
}
@@ -7,31 +7,147 @@ import kotlinx.coroutines.withTimeoutOrNull
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
* история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
* возвращается пользователю как финальный ответ.
*/
class Assistant(
private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL,
private val timeoutMs: Long = 30_000,
) {
/**
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
*/
suspend fun process(text: String): String {
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
return processInSession(sessionId)
}
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
suspend fun processInSession(sessionId: Long, text: String): String {
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
return processInSession(sessionId)
}
private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20)
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
var answer = callLlm(history, sessionId)
var lastToolResult: String? = null
var visibleCalls = 0
var llmCalls = 1
// Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) {
val parsed = parseToolCall(answer)
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
val toolName = parsed.name
if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
return "Тул $toolName не найден"
}
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
logTool(sessionId, toolName, lastToolResult)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
continue
}
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
is AgentToolkit.Dispatch.Run -> {
lastToolResult = result.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
logTool(sessionId, toolName, result.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
}
is AgentToolkit.Dispatch.Silent -> {
// Невидимая системная тула (enable/disable_toolset): вызов не
// пишется в историю, промпт пересобирается на следующем callLlm
// (переход в состояние 3 — «Активные тулсеты»).
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
if (llmCalls++ >= MAX_LLM_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
}
}
}
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
// чтобы пользователь получил данные, а не сырой маркер.
val stillTool = parseToolCall(answer)
return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else {
answer
}
}
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
?: return "Ассистент не ответил (таймаут)"
if (!isToolCall(answer)) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId)
}
return answer
}
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
private fun legacySystemPrompt(): String {
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
if (tools.isEmpty()) return base
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
}
companion object {
// TODO(персона): промпт выберет пользователь
const val SYSTEM_PROMPT =
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
/** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
const val MAX_TOOL_CALLS = 3
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
const val MAX_LLM_CALLS = 8
/**
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
*/
private val TOOL_CALL_RE =
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
data class ParsedToolCall(val name: String, val argsRaw: String?)
private fun parseToolCall(answer: String): ParsedToolCall? {
val m = TOOL_CALL_RE.find(answer) ?: return null
val argsRaw = m.groupValues[2].trim().ifBlank { null }
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
}
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
/** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
private fun logTool(sessionId: Long, toolName: String, result: String) {
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
}
}
}
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
}
/** Сессия по id, или null, если не существует. */
fun getSession(id: Long): Session? {
val cursor = db.readableDatabase.rawQuery(
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
arrayOf(id.toString()),
)
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
}
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
fun clearSession(id: Long) {
db.writableDatabase.delete(
ChatDb.TABLE_MESSAGES,
"session_id = ?",
arrayOf(id.toString()),
)
touchSession(id)
}
fun renameSession(id: Long, title: String) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
@@ -0,0 +1,40 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem
/**
* [MediaSource] поверх Jellyfin: search/item через [JellyfinClient].
* [userId] — кэш приложения (PhoneApp.userId, подтягивается при старте),
* передан провайдером: если к моменту вызова ещё не готов — [MediaSourceException]
* (исполнитель покажет модели «Поиск недоступен: ...», а не пахнет пустым результатом).
*/
class JellyfinMediaSource(
private val jellyfin: JellyfinClient,
private val userId: () -> String,
) : MediaSource {
override suspend fun search(query: String, limit: Int): List<MediaItem> {
val user = requireUserAvailable()
return jellyfin.search(user, query).take(limit).map { jellyfinToMediaItem(it) }
}
override suspend fun item(itemId: String): MediaItem? {
val user = requireUserAvailable()
return jellyfin.item(user, itemId)?.let { jellyfinToMediaItem(it) }
}
private fun requireUserAvailable(): String =
userId().trim().ifBlank { throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") }
}
/** Модель Jellyfin → тул-элемент (JVM, тестируемо: поля, «постер есть», длительность в секундах). */
fun jellyfinToMediaItem(item: JellyfinItem): MediaItem = MediaItem(
id = item.Id,
name = item.Name?.trim()?.takeIf { it.isNotEmpty() } ?: item.Id,
type = item.Type,
year = item.ProductionYear,
overview = item.Overview,
durationSeconds = item.RunTimeTicks?.let { it / 10_000_000 },
hasPoster = item.ImageTags?.containsKey("Primary") == true,
)
@@ -1,64 +1,125 @@
package pw.binom.viewmate.phone.agent
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.llm.LLMProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
*
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
* («Ассистент недоступен: …»), сам клиент не падает тихо.
*/
class LlmClient(
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.
*
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
* content на длинных историях (после tool-вызовов) — см. заметку
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
* модель отвечает стабильно, тул-цикл работает.
*
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
* [http] инжектируется (дефолт — свой клиент с таймаутами).
*/
class RemoteLlmClient(
private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw",
private val model: String, // модель укажет пользователь
) {
private val model: String,
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build(),
) : LlmClient {
private val client: OpenAILLMClient = OpenAILLMClient(
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl),
httpClientFactory = OkHttpKoogHttpClient.Factory(),
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatMessageDto(
val role: String,
val content: String,
)
private val llmModel: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = model,
capabilities = listOf(
LLMCapability.Completion,
LLMCapability.OpenAIEndpoint.Completions,
),
@Serializable
private data class ChatRequest(
val model: String,
val messages: List<ChatMessageDto>,
val max_tokens: Int = 1024,
val temperature: Double = 0.3,
)
@Serializable
private data class ChatResponse(
val choices: List<Choice> = emptyList(),
val error: ApiError? = null,
) {
@Serializable
data class Choice(val message: Message = Message())
@Serializable
data class Message(val content: String? = null)
@Serializable
data class ApiError(val message: String? = null)
}
/**
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
* System-сообщение, если задано, добавляется первым.
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
val prompt = prompt("assistant-chat") {
if (system != null) {
this.system(system)
}
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) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
}
}
}
return client.execute(prompt, llmModel).textContent()
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
val request = Request.Builder()
.url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return withContext(Dispatchers.IO) {
http.newCall(request).execute().use { resp ->
val raw = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
}
val text = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
text?.trim() ?: ""
}
}
}
}
@@ -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,416 @@
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 kotlinx.coroutines.withTimeout
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)
/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */
private fun sanitize(msg: String?): String {
if (msg == null) return "null"
val sb = StringBuilder()
for (ch in msg.take(200)) {
val c = ch.code
if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch)
}
return sb.toString()
}
/**
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
* переключается UI-блоком в настройках экрана «Телефон»:
* - [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",
)
/**
* Конфиг движка — точь-в-точь как в референсе vayunmathur/Modern-Apps
* (InferenceService.kt): GPU для основного и vision, CPU для аудио.
* Ключ: cacheDir должен быть системным applicationContext.cacheDir —
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc.
*/
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
modelPath = modelFile.absolutePath,
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
// этом HONOR гоняет весь движок (включая vision-сабаграф) на WebGPU; ранее
// мы режут vision на CPU из-за 128 МБ-лимита буфера, но чистый текст-чат
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
// картинке — вернём vision на CPU точечно.
backend = Backend.GPU(),
visionBackend = Backend.GPU(),
audioBackend = Backend.CPU(),
cacheDir = cacheDir.absolutePath,
)
}
// ---------- Чистые вспомогательные (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 {
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
val engine = ensureEngine()
log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}")
val conversation: Conversation = synchronized(engineLock) {
engine.createConversation(
ConversationConfig(
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
initialMessages = prompt.initialMessages,
)
)
}
log("llm", "chat: conversation создан")
try {
var text = ""
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
withTimeout(120_000) {
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом
// dex null — артефакт маски $default, не аргумент.
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
chunks++
val chunkText = messageChunk.text()
text += chunkText
if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
}
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
}
}
}
val totalMs = System.currentTimeMillis() - t0
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
return text.trim()
} catch (e: CancellationException) {
log("llm", "chat: отменено")
runCatching { conversation.cancelProcess() }
throw e
} catch (e: Exception) {
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally {
runCatching { conversation.close() }
}
}
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() {
val eng = synchronized(engineLock) {
val e = engine
engine = null
e
}
runCatching { eng?.close() }
}
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
@OptIn(ExperimentalApi::class)
private fun applyExperimentalFlags() {
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
ExperimentalFlags.enableSpeculativeDecoding = true
}
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
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()
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
// в кэш не пишет, поэтому работал и без mkdirs.
cacheDir.mkdirs()
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
try {
created.initialize()
} catch (e: Exception) {
runCatching { created.close() }
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
}
log("llm", "локальный движок инициализирован: ${modelFile.name}")
engine = created
created
}
}
}
@@ -0,0 +1,142 @@
package pw.binom.viewmate.phone.agent
import kotlinx.coroutines.runBlocking
/**
* Медиа-элемент для тул: минимум, что нужно модели.
* Не зависит от моделей lib-core — JVM, тестируемо фейками.
*/
data class MediaItem(
val id: String,
val name: String,
val type: String?,
val year: Int?,
val overview: String?,
val durationSeconds: Long?,
val hasPoster: Boolean,
)
/** Источник медиа для тулсета: поиск + детали элемента. */
interface MediaSource {
/** Поиск по [query]: до [limit] результатов. */
suspend fun search(query: String, limit: Int): List<MediaItem>
/** Детали элемента по [itemId]; null — не найден. */
suspend fun item(itemId: String): MediaItem?
}
/** Ошибка источника: нет пользователя Jellyfin, сеть недоступна и т.п. */
class MediaSourceException(message: String) : Exception(message)
/**
* Выполнить «включить на очках» (тул media_play): проверки + [pw.binom.viewmate.core.phone.PhoneActions.watch]
* + звук на телефоне — уровень приложения. Возвращает человекочитаемый текст.
*/
fun interface MediaPlayer {
suspend fun play(itemId: String, seekMs: Long): String
}
/** Лимит поиска по умолчанию, когда модель его не указала. */
const val MEDIA_SEARCH_DEFAULT_LIMIT = 5
/**
* Определение тулсета media: сигнатуры БЕЗ префикса (механика добавляет media_).
* Описания злые и короткие — маленькая модель (TASK-agent-tools, пункт 10).
*/
val MEDIA_TOOLSET = Toolset(
name = "media",
description = "медиа и воспроизведение: поиск, детали, запуск на очках (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов/сериалов по названию. Возвращает: id, название, год, тип.", args = listOf("query", "limit?")),
ToolSignature("info", "Детали элемента по id: название, тип, год, описание, длительность, постер.", args = listOf("id")),
ToolSignature("play", "Включить просмотр на очках: видео скачано на очки, звук — на телефон.", args = listOf("id", "seek_ms?")),
),
)
/**
* Исполнитель тул media: валидация аргументов + форматирование текста.
* Источники: [media] (Jellyfin) и [player] («включить на очках» — уровень приложения).
* Механика вызывает [execute] синхронно — а тулы suspend: мост через runBlocking
* (вызов и так на Dispatchers.IO, из ассистентского цикла).
*/
class MediaToolExecutor(
private val media: MediaSource,
private val player: MediaPlayer,
) : ToolsetExecutor {
private companion object {
const val OVERVIEW_MAX_CHARS = 300
fun exceptionMessage(e: Throwable): String = e.message?.trim()?.takeIf { it.isNotEmpty() } ?: e.toString()
}
override fun execute(toolset: String, tool: String, args: Map<String, String>): String = when (tool) {
"media_search" -> search(args["query"], args["limit"])
"media_info" -> info(args["id"])
"media_play" -> play(args["id"], args["seek_ms"])
else -> "Неизвестный тул \"$tool\". Доступны: media_search, media_info, media_play."
}
/** media_search: строки «id: Название (Год) — Тип», до limit (дефолт [MEDIA_SEARCH_DEFAULT_LIMIT]). */
private fun search(rawQuery: String?, rawLimit: String?): String {
val query = rawQuery?.trim().orEmpty()
if (query.isEmpty()) {
return "media_search: нет аргумента \"query\". Схема: media_search|<query> [limit=<n>]"
}
val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT
val items: List<MediaItem> = runCatching { runBlocking { media.search(query, limit) } }
.getOrElse { return "Поиск недоступен: ${exceptionMessage(it)}" }
if (items.isEmpty()) return "Ничего не найдено по запросу «$query»."
return items.joinToString("\n") { row ->
val year = row.year?.let { " ($it)" }.orEmpty()
val type = row.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
"${row.id}: ${row.name}$year — $type"
}
}
/** media_info: «Название» — Тип (Год), описание (≤ [OVERVIEW_MAX_CHARS]), длительность, постер. */
private fun info(rawId: String?): String {
val id = rawId?.trim().orEmpty()
if (id.isEmpty()) {
return "media_info: нет аргумента \"id\". Схема: media_info|<id>"
}
val item: MediaItem? = runCatching { runBlocking { media.item(id) } }
.getOrElse { return "Детали недоступны: ${exceptionMessage(it)}" }
if (item == null) return "Элемент \"$id\" не найден."
return infoText(item)
}
/** media_play: id обязателен, seek_ms — опционально (целые миллисекунды); выполняем через [player]. */
private fun play(rawId: String?, rawSeek: String?): String {
val id = rawId?.trim().orEmpty()
if (id.isEmpty()) {
return "media_play: нет аргумента \"id\". Схема: media_play|<id> [seek_ms=<n>]"
}
val seekMs = when (val s = rawSeek?.trim().orEmpty()) {
"" -> 0L
else -> s.toLongOrNull()?.coerceAtLeast(0L)
?: return "media_play: аргумент \"seek_ms\" должен быть числом (миллисекунды)."
}
return runCatching { runBlocking { player.play(id, seekMs) } }
.getOrElse { "Ошибка воспроизведения: ${exceptionMessage(it)}" }
}
private fun infoText(item: MediaItem): String {
val year = item.year?.let { " ($it)" }.orEmpty()
val type = item.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
return buildList {
add("«${item.name}» — ${type}$year")
val overview = item.overview?.trim()
if (!overview.isNullOrBlank()) {
add("Описание: " + if (overview.length > OVERVIEW_MAX_CHARS) overview.take(OVERVIEW_MAX_CHARS).trimEnd() + "…" else overview)
}
item.durationSeconds?.takeIf { it > 0 }?.let { add("Длительность: ${durationText(it)}") }
add("Постер: " + if (item.hasPoster) "есть" else "нет")
}.joinToString("\n")
}
/** Секунды в человекочитаемом виде: ≥ 60 сек — минуты (с секундами в скобках). */
private fun durationText(seconds: Long): String {
val minutes = seconds / 60
return if (minutes > 0) "${minutes} мин. (${seconds} сек.)" else "$seconds сек."
}
}
@@ -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 }
@@ -0,0 +1,103 @@
package pw.binom.viewmate.phone.agent
import java.io.File
/**
* Скилл = знание: «КАК пользоваться». Триггер (одна строка) + тело (markdown:
* шаги, команды, питфоллы). Пассивный: живёт в файлах, читается по требованию
* через tuл read_skill — содержимое в промпт НЕ попадает (ленивое чтение).
*/
data class Skill(
val name: String,
val trigger: String,
val body: String,
)
/**
* Читает скиллы из папки `files/skills/` (внутренняя files-папка приложения).
* Формат файла:
* ```
* ---
* name: media
* description: поиск фильмов, детали, запуск/управление просмотром.
* ---
* тело скилла (markdown)...
* ```
* Без frontmatter — имя берётся из имени файла, всё содержимое = тело.
* Нетовой папки или пустой каталог — пустой список (механика работает на
* пустом каталоге; реальные скиллы кладутся позже).
*/
class SkillRepository(private val directory: File) {
private val extensions = setOf("md", "markdown", "txt")
fun loadAll(): List<Skill> {
val files = directory.listFiles { f -> f.isFile && f.extension.lowercase() in extensions }
?: return emptyList()
return files
.sortedBy { it.name }
.mapNotNull { parse(it) }
.distinctBy { it.name }
}
private fun parse(file: File): Skill? {
val text = runCatching { file.readText() }.getOrNull() ?: return null
val lines = text.lines()
var name = file.nameWithoutExtension
var trigger = ""
var body = text.trim()
if (lines.isNotEmpty() && lines[0].trim() == "---") {
// Фронтматтер: блок строками key: value между первой и второй строкой ---.
val close = lines.drop(1).indexOfFirst { it.trim() == "---" }
if (close >= 0) {
val fm = lines.subList(1, close + 1)
fmValue(fm, "name")?.let { name = it }
trigger = fmValue(fm, "description") ?: ""
body = lines.drop(close + 2).joinToString("\n").trim()
}
}
return Skill(name = name, trigger = trigger, body = body)
}
/** Значение ключа из frontmatter (`key: значение`); пустое/отсутствующее — null. */
private fun fmValue(fm: List<String>, key: String): String? =
fm.firstOrNull { it.startsWith("$key:") }
?.substringAfter(':')
?.trim()
?.ifBlank { null }
}
/**
* Индекс скиллов (имя + триггер) для промпта; полное содержимое — только
* через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда
* связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ.
*/
class SkillRegistry(skills: List<Skill>) {
private val byName: Map<String, Skill> = skills.associateBy { it.name.lowercase() }
/** Все имена (стабильный порядок). */
fun names(): List<String> = byName.values.map { it.name }
fun has(name: String): Boolean = byName.containsKey(name.lowercase())
/**
* Строки индекса «имя — триггер» (одна строка на скилл). [excluded] —
* имена скиллов, которых в индексе не должно быть (например, скилл
* активного тулсета: искушение удалить, а не запретить).
*/
fun index(excluded: Set<String> = emptySet()): List<String> =
byName.values
.filter { !excluded.contains(it.name.lowercase()) }
.sortedBy { it.name }
.map { s -> s.trigger.takeIf { it.isNotBlank() }?.let { "${s.name} — $it" } ?: s.name }
/** Полное содержимое скилла (триггер + тело) — результат read_skill. */
fun readFull(name: String): String? =
byName[name.lowercase()]?.let { s ->
buildString {
append("Скилл \"").append(s.name).append("\"\n")
if (s.trigger.isNotBlank()) append("Триггер: ").append(s.trigger).append('\n')
append("\n")
append(s.body)
}
}
}
@@ -0,0 +1,191 @@
package pw.binom.viewmate.phone.agent
import java.util.concurrent.ConcurrentHashMap
/**
* Тулсет = набор тул (активные руки, «ЧТО МОЖНО»). Все тулы набора несут
* префикс ИМЕНИ набора: media_search, media_info — префикс = маршрутизация
* (система видит media_* → знает набор и сигнатуры).
*
* Аргументы сигнатуры описываются ИМЕНАМИ: «query» — обязательный,
* «seek_ms?» — опциональный («?»). Описание тула в промпте одной строкой:
* имя + что делает + аргументы — компактно для маленькой модели.
*/
data class ToolSignature(
val name: String,
val description: String,
val args: List<String> = emptyList(),
) {
/** Обязательные аргументы (без маркера «?»). */
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
fun argNames(): List<String> = args.map { it.substringBefore('?') }
}
/**
* Набор тул: имя (одновременно префикс), описание для каталога, список тул
* (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации).
*/
data class Toolset(
val name: String,
val description: String,
val tools: List<ToolSignature>,
) {
/** Полное имя тула (с префиксом): media + search → media_search. */
fun toolFull(signature: ToolSignature): String = "${name}_${signature.name}"
/** Найти сигнатуру по полному имени (media_search → search). */
fun signatureOf(fullName: String): ToolSignature? =
tools.firstOrNull { toolFull(it) == fullName }
/** Строка каталога: «media — управление медиа и воспроизведением (search/info/play)». */
fun catalogLine(): String = "$name — $description"
/** Строки описаний тул для промпта (секция «Активные тулсеты»). */
fun toolLines(): List<String> = tools.map { s ->
val argsPart = if (s.args.isEmpty()) "" else "|" + s.args.joinToString(",")
"${name}_${s.name}$argsPart — ${s.description}"
}
}
/** Исполнитель тул набора: (набор, тул-имя (с префиксом), аргументы) → строка результата. */
fun interface ToolsetExecutor {
fun execute(toolset: String, tool: String, args: Map<String, String>): String
}
/** Результат enable_toolset. */
sealed class EnableResult {
/** Активен (при повторном вызове уже активен — таймаут продлён). */
class Activated(val toolset: String, val alreadyActive: Boolean) : EnableResult()
/** Неизвестное имя: сообщение для модели («набора X нет, доступны: ...»). */
class Unknown(val name: String, val message: String) : EnableResult()
}
/** Результат disable_toolset — всегда не ошибка (идемпотентно и прощающе). */
sealed class DisableResult {
class Disabled(val toolset: String) : DisableResult()
/** Набор не был активен — информационно, не ошибка. */
class NotActive(val message: String) : DisableResult()
}
/**
* Реестр тулсетов + активность НА СЕССИЮ (сессия → набор → deadline).
*
* - Не персистится: перезапуск приложения = всё сброшено.
* - Таймаут: [DEFAULT_TIMEOUT_MS] (10 минут) с последнего вызова тулы набора;
* enable и каждый успешный вызов [touch] продлевают.
* - Проверка ТОЛЬКО при сборке промпта / диспаче — никаких фоновых таймеров:
* просроченные записи чистятся лениво в [activeNames]/[isActive].
*
* Часы и таймаут инжектируемые — юнит-тесты не ждут реальные 10 минут.
*/
class ToolsetRegistry(
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
private val clock: () -> Long = { System.currentTimeMillis() },
) {
companion object {
/** Дефолтный таймаут активности: 10 минут. */
const val DEFAULT_TIMEOUT_MS = 10 * 60_000L
}
private val toolsets = LinkedHashMap<String, Toolset>()
private val executors = HashMap<String, ToolsetExecutor>()
/** сессия → (набор → deadline, epoch-мс). Только в памяти. */
private val active = ConcurrentHashMap<Long, ConcurrentHashMap<String, Long>>()
/** Зарегистрировать набор (опционально с исполнителем его тул). */
fun register(toolset: Toolset, executor: ToolsetExecutor? = null) {
synchronized(this) {
toolsets[toolset.name] = toolset
if (executor != null) executors[toolset.name] = executor
}
}
/** Имена всех наборов каталога. */
fun names(): List<String> = synchronized(this) { toolsets.keys.toList() }
fun toolset(name: String): Toolset? = synchronized(this) { toolsets[name] }
/**
* Префиксная маршрутизация: имя тула с префиксом набора (media_search → media).
* Префикс = «имени_».
*/
fun findByPrefix(fullToolName: String): Toolset? =
synchronized(this) {
toolsets.entries.firstOrNull { (n, _) -> fullToolName.startsWith(n + "_") }?.value
}
fun executor(name: String): ToolsetExecutor? = synchronized(this) { executors[name] }
/** Строки каталога: «media — ...» (по одной строке на набор). */
fun catalogLines(): List<String> = synchronized(this) { toolsets.values.map { it.catalogLine() } }
/**
* Активные наборы сессии (deadline > now). Просроченные записи чистятся
* здесь же (ленивая проверка, нет фона). Порядок = порядок каталога.
*/
fun activeNames(sessionId: Long): List<String> =
synchronized(this) {
val session = active[sessionId] ?: return emptyList()
val now = clock()
val expired = session.entries.filter { it.value <= now }.map { it.key }
expired.forEach { session.remove(it) }
if (session.isEmpty()) active.remove(sessionId)
toolsets.keys.filter { session.containsKey(it) }
}
/** Активен ли набор в сессии (с очисткой просроченных). */
fun isActive(sessionId: Long, name: String): Boolean = activeNames(sessionId).contains(name)
/** Сигнатура тула, если тул известного набора (для валидации аргументов). */
fun signatureOf(fullToolName: String): ToolSignature? =
findByPrefix(fullToolName)?.signatureOf(fullToolName)
/**
* Активировать набор в сессии (идемпотентно: повторный enable = продление
* таймаута, не ошибка). Deadline = now + timeout.
*/
fun enable(sessionId: Long, name: String): EnableResult {
val ts = toolset(name) ?: return EnableResult.Unknown(
name = name,
message = "Набора \"$name\" нет. " + when {
toolsets.isNotEmpty() -> "Доступны: " + names().joinToString(", ")
else -> "Доступных тулсетов нет."
},
)
synchronized(this) {
val session = active.computeIfAbsent(sessionId) { ConcurrentHashMap() }
val already = session.containsKey(ts.name)
session[ts.name] = clock() + timeoutMs
return EnableResult.Activated(ts.name, already)
}
}
/**
* Снять набор в сессии (идемпотентно: «нет активных наборов» — не ошибка).
*/
fun disable(sessionId: Long, name: String): DisableResult {
synchronized(this) {
val session = active[sessionId]
if (session != null && session.remove(name) != null) return DisableResult.Disabled(name)
val anyActive = if (session != null && session.isNotEmpty()) {
val now = clock()
session.entries.removeAll { it.value <= now }
session.isNotEmpty()
} else {
false
}
val msg = if (anyActive) "Тулсет \"$name\" не активен." else "Нет активных тулсетов."
return DisableResult.NotActive(msg)
}
}
/** Продлить таймаут (вызывается после успешного выполнения тулы набора). */
fun touch(sessionId: Long, name: String) {
synchronized(this) {
val session = active[sessionId] ?: return
if (session.containsKey(name)) session[name] = clock() + timeoutMs
}
}
}
@@ -1,35 +1,233 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
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
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
/**
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
* локального ассистента (та же очередь, что и голос с очков). Управление
* сессиями: новая, переключение (выпадающий список), сброс текущей.
*/
@Composable
fun ChatScreen() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
var input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(null) }
val listState = rememberLazyListState()
// Загрузка списка сессий и сообщений при старте и при смене активной.
LaunchedEffect(activeId) {
val dao = app.chatDao
val (sess, msgs) = withContext(Dispatchers.IO) {
val all = dao.listSessions()
val target = activeId ?: all.firstOrNull()?.id
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
all to history.map { it.role to it.content }
}
sessions = sess
activeId = activeId ?: sess.firstOrNull()?.id
messages = msgs
}
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
val externalActive by app.activeSessionId.collectAsState()
LaunchedEffect(externalActive) {
if (externalActive != null && externalActive != activeId) {
activeId = externalActive
}
}
// Автопрокрутка вниз при новых сообщениях.
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
// асинхронно — лента должна подхватывать его без ручного обновления).
LaunchedEffect(activeId) {
while (true) {
delay(2_000)
val id = activeId
if (id != null) {
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
if (fresh != messages) messages = fresh
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
// Шапка: название + управление сессиями
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Чат с Порфирием",
text = "Порфирий",
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Переключение сессий
Box {
TextButton(onClick = { sessionMenu = true }) {
Text(
text = "в разработке (следующий этап)",
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
color = PhoneAccent,
fontSize = 12.sp,
)
}
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) },
onClick = {
activeId = s.id
app.selectChatSession(s.id)
sessionMenu = false
},
)
}
DropdownMenuItem(
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
onClick = {
activeId = app.newChatSession()
sessionMenu = false
},
)
}
}
// Сброс текущей
TextButton(onClick = {
app.resetChatSession()
activeId?.let { id ->
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
messages = history
}
}) {
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
}
}
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))
// Лента сообщений
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (messages.isEmpty()) {
Text(
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { (role, content) ->
MessageBubble(role = role, text = content)
}
}
}
}
Spacer(Modifier.height(8.dp))
// Ввод
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val text = input.trim()
if (text.isEmpty()) return@Button
input = ""
messages = messages + (ChatRole.USER to text)
app.sendToAssistant(text)
},
) {
Text("➤", fontSize = 16.sp)
}
}
}
}
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
@Composable
private fun MessageBubble(role: ChatRole, text: String) {
val isUser = role == ChatRole.USER
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Text(
text = text,
color = PhoneText,
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
.background(
color = if (isUser) PhoneSurface else PhoneCard,
shape = RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 6.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),
)
}
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
@@ -30,17 +29,34 @@ import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.fail
/**
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
*/
class GlassesServerTest {
private val json = protocolJson
@Test
fun helloGetsWelcome() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
private fun startServer(): Pair<GlassesServer, Int> {
val port = freePort()
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
server.start()
return server to port
}
client.webSocket(GLASSES_WS_PATH) {
private fun freePort(): Int {
val s = java.net.ServerSocket(0)
val p = s.localPort
s.close()
return p
}
@Test
fun helloGetsWelcome() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
val frame = incoming.receive() as Frame.Text
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
@@ -50,12 +66,9 @@ class GlassesServerTest {
}
@Test
fun gestureIsProcessed() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun gestureIsProcessed() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
}
@@ -64,28 +77,22 @@ class GlassesServerTest {
}
@Test
fun connectedCounterTracksSessions() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) { }
fun connectedCounterTracksSessions() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
awaitTrue { hub.connected.value == 0 }
}
@Test
fun broadcastReachesAllGlasses() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
fun broadcastReachesAllGlasses() = runServer { port, hub ->
val client = wsClient()
coroutineScope {
val opened = Channel<Unit>(2)
val received = Channel<HostToGlasses>(2)
val jobs = (1..2).map {
launch {
client.webSocket(GLASSES_WS_PATH) {
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
opened.send(Unit)
while (true) {
val frame = incoming.receive()
@@ -115,12 +122,9 @@ class GlassesServerTest {
}
@Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
}
@@ -131,12 +135,9 @@ class GlassesServerTest {
}
@Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(
json.encodeToString(
GlassesToHost.serializer(),
@@ -163,6 +164,21 @@ class GlassesServerTest {
assertEquals("0.2.0", hub.appVersion.value)
}
// ---------- помощники ----------
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
val (server, port) = startServer()
try {
kotlinx.coroutines.runBlocking { block(port, server.hub) }
} finally {
server.stop()
}
}
private fun wsClient() = io.ktor.client.HttpClient {
install(WebSockets)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
@@ -0,0 +1,308 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Механика агента: три состояния промпта, бесшовная подмена, валидация
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
*/
class AgentToolkitTest {
private var now = 10_000_000L
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
private fun fixture(withExecutor: Boolean = true): Fixture {
now = 10_000_000L
val calls = mutableListOf<String>()
val shown = mutableListOf<String>()
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
val ts = Toolset(
name = "media",
description = "Управление медиа и воспроизведением (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
ToolSignature("info", "Детали фильма.", args = listOf("id")),
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
),
)
if (withExecutor) {
registry.register(ts) { tset, tool, args ->
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
}
} else {
registry.register(ts, null)
}
val skills = SkillRegistry(
listOf(
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
),
)
return Fixture(
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
calls = calls,
shown = shown,
)
}
private fun run(d: AgentToolkit.Dispatch): String =
(d as AgentToolkit.Dispatch.Run).result
// ---------- три состояния промпта ----------
@Test
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
val p = fixture().toolkit.systemPrompt(1)
assertTrue(p.contains("Ядро:"))
assertTrue(p.contains("get_current_time"))
assertTrue(p.contains("show_text|text"))
assertTrue(p.contains("enable_toolset|имя"))
assertTrue(p.contains("Каталог тулсетов"))
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
// скиллы: по строке на каждый
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
assertTrue(p.contains("web — Как сделать веб-поиск."))
// описания тул набора ещё не раскрыты (набор не активен)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
}
@Test
fun `enable невидимый, промпт пересобирается, состояние 3`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Активные тулсеты: media"))
assertTrue(p.contains("media_search|query — Поиск фильмов"))
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
// связанный скилл убран из промпта (имя скилла = имя тулсета)
assertFalse(p.contains("media — Поиск фильмов"))
// остальные скиллы на месте
assertTrue(p.contains("web — Как сделать веб-поиск."))
}
@Test
fun `повторный enable, напоминание, уже активен`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p1 = f.toolkit.systemPrompt(1)
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
// напоминание одноразовое: следующий промпт без него
val p2 = f.toolkit.systemPrompt(1)
assertFalse(p2.contains("уже активен"))
}
@Test
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
val p1 = f.toolkit.systemPrompt(1)
assertFalse(p1.contains("Активные тулсеты"))
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p2 = f.toolkit.systemPrompt(1)
assertTrue(p2.contains("Нет активных тулсетов"))
}
@Test
fun `неизвестный набор, напоминание с доступными в промпте`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Набора \"other\" нет"))
assertTrue(p.contains("media"))
}
// ---------- бесшовная подмена ----------
@Test
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("Тулсет media активирован"))
assertTrue(msg.contains("Реестр:"))
assertTrue(msg.contains("media_search"))
assertTrue(msg.contains("ещё НЕ выполнялся"))
assertTrue(msg.contains("вызови media_search с аргументами: query"))
// тул не выполнялся реально
assertTrue(f.calls.isEmpty())
// набор стал активен (подмена = enable)
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
@Test
fun `подмена, валидные аргументы, выполнение без второго круга`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
assertTrue(msg.contains("Результат media_search"))
assertEquals(1, f.calls.size)
assertTrue(f.calls.single().contains("media.media_search"))
assertTrue(f.calls.single().contains("query=Рассел"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
// ---------- валидация и маршрутизация ----------
@Test
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("аргументы неверны"))
assertTrue(msg.contains("query"))
assertTrue(msg.contains("Схема: media_search|query"))
}
@Test
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
assertTrue(msg.contains("Результат media_info"))
assertTrue(f.calls.single().contains("media.media_info"))
assertTrue(f.calls.single().contains("id=123"))
}
@Test
fun `тул не входит в сигнатуры набора`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
}
@Test
fun `неизвестный тул, ответ со списком доступных`() {
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
assertTrue(msg.contains("Тул \"frobo\" не найден"))
assertTrue(msg.contains("enable_toolset"))
assertTrue(msg.contains("media"))
}
@Test
fun `активный тулсет без исполнителя, понятное сообщение`() {
val f = fixture(withExecutor = false)
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
assertTrue(msg.contains("ещё не реализованы"))
}
// ---------- ядро тулов ----------
@Test
fun `read_skill, весь текст скилла`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
}
@Test
fun `read_skill неизвестный скилл, прощающая подсказка`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
assertTrue(msg.contains("media"))
assertTrue(msg.contains("web"))
}
@Test
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
assertEquals("Привет!", f.shown.single())
assertTrue(msg.contains("Показано на очках: Привет!"))
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
assertTrue(bad.contains("\"text\""))
// без подключённого дисплея (showText = null) — понятный результат, не падение
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
assertTrue(disconnected.contains("не показан"))
}
@Test
fun `get_current_time, время в ISO`() {
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
}
// ---------- таймаут и сессии ----------
@Test
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
val p = f.toolkit.systemPrompt(1)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
}
@Test
fun `активность по сессиям, разные сессии независимы`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
assertTrue(msg.contains("Результат media_search"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
}
@Test
fun `disable в сессии 2 не трогает активный в сессии 1`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
}
// ---------- парсинг аргументов ----------
@Test
fun `парсинг пар и голых значений, прощающий к лишнему`() {
val tk = fixture().toolkit
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
assertEquals(
mapOf("id" to "12", "seek_ms" to "300"),
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
)
// «голое» значение — первому обязательному аргументу
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
// пар через «&» и лишние ключи — не ошибка
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
assertEquals("x", m["query"])
assertTrue(m.containsKey("extra"))
}
@Test
fun `схема аргументов, schemaFor, у тулов`() {
val tk = fixture().toolkit
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
assertEquals(listOf("text"), tk.schemaFor("show_text"))
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
}
}
@@ -0,0 +1,38 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты тулов ассистента — чистая логика без БД и Android.
*/
class AgentToolsTest {
@Test
fun `тул времени возвращает ISO 8601 с датой и временем`() {
val result = TimeTool.invoke("{}")
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
assertTrue(
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
"не ISO 8601: $result",
)
assertTrue(result.startsWith("20"), "год не 20xx: $result")
}
@Test
fun `реестр содержит тулу времени с описанием`() {
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
val desc = AgentTools.describeAll()
assertTrue(desc.contains("get_current_time"))
assertTrue(desc.contains("ISO 8601"))
}
@Test
fun `тула времени не зависит от аргументов`() {
// Пустой JSON и мусор — один результат (тула без параметров)
val a = TimeTool.invoke("{}")
val b = TimeTool.invoke("{\"x\":1}")
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
}
}
@@ -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,267 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem
/**
* Исполнитель тулсета media: валидация аргументов + форматирование текста
* (search/info/play). Источник и игрок иньекшены фейками — JVM, без Android.
*/
class MediaToolsTest {
private class FakeSource : MediaSource {
val items = mutableListOf(
MediaItem("i1", "Lara Croft", "Movie", 2018, "Tomb Raider reboot", 8100L, true),
MediaItem("i2", "The Witcher", "Series", 2019, null, 3300L, false),
MediaItem("i3", "Stranger Things", "Series", 2016, " ", 3540L, true),
MediaItem("i4", "Blade Runner", "Movie", 1982, "репликанты", 8340L, true),
MediaItem("i5", "Dune", "Series", 2021, null, 4200L, false),
)
var searchFails = false
var itemFails = false
/** Фильтр поиска (по умолчанию все — тестируем лимит). */
var match: (MediaItem) -> Boolean = { true }
override suspend fun search(query: String, limit: Int): List<MediaItem> {
if (searchFails) throw MediaSourceException("нет пользователя Jellyfin (userId пуст)")
return items.filter(match).take(limit)
}
override suspend fun item(itemId: String): MediaItem? {
if (itemFails) throw MediaSourceException("сеть недоступна")
return items.firstOrNull { it.id == itemId }
}
}
private class FakePlayer(
private val responses: Map<String, String> = emptyMap(),
private var failure: Throwable? = null,
) : MediaPlayer {
val calls = mutableListOf<Pair<String, Long>>()
override suspend fun play(itemId: String, seekMs: Long): String {
calls.add(itemId to seekMs)
failure?.let { throw it }
return responses[itemId] ?: "включил: $itemId"
}
}
private fun executor(source: FakeSource = FakeSource(), player: FakePlayer = FakePlayer()) =
MediaToolExecutor(source, player)
// ---------- определение тулсета ----------
@Test
fun `сигнатуры тулсета и префиксная маршрутизация`() {
assertEquals(listOf("search", "info", "play"), MEDIA_TOOLSET.tools.map { it.name })
assertEquals("media_search", MEDIA_TOOLSET.toolFull(MEDIA_TOOLSET.tools.first()))
assertEquals(listOf("id"), MEDIA_TOOLSET.signatureOf("media_play")?.requiredArgs())
assertEquals(listOf("query", "limit"), MEDIA_TOOLSET.signatureOf("media_search")?.argNames())
}
// ---------- media_search ----------
@Test
fun `search строки id название год тип`() {
val source = FakeSource()
source.match = { it.id == "i1" }
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
assertEquals("i1: Lara Croft (2018) — Movie", r)
}
@Test
fun `поиск без года не показывает скобок`() {
val source = FakeSource()
source.items[0] = source.items[0].copy(year = null)
source.match = { it.id == "i1" }
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
assertEquals("i1: Lara Croft — Movie", r)
}
@Test
fun `limit по умолчанию 5, явного, кривой и ноль`() {
val r = executor()
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x")).lines().size)
assertEquals(2, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "2")).lines().size)
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "ab")).lines().size)
// ноль и отрицательное — сжимаются в 1
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "0")).lines().size)
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "-4")).lines().size)
}
@Test
fun `поиск без query — прощающая схема`() {
for (args in listOf(emptyMap<String, String>(), mapOf("query" to ""))) {
val r = executor().execute("media", "media_search", args)
assertTrue(r.contains("нет аргумента \"query\""), r)
assertTrue(r.contains("media_search|<query>"), r)
}
}
@Test
fun `поиск пустая — ничего не найдено`() {
val source = FakeSource()
source.match = { false }
val r = executor(source).execute("media", "media_search", mapOf("query" to "такого нет"))
assertTrue(r.contains("Ничего не найдено"), r)
}
@Test
fun `поиск источник недоступен — сообщение с причиной`() {
val source = FakeSource()
source.searchFails = true
val r = executor(source).execute("media", "media_search", mapOf("query" to "x"))
assertTrue(r.contains("Поиск недоступен"), r)
assertTrue(r.contains("userId пуст"), r)
}
// ---------- media_info ----------
@Test
fun `info полный набор полей`() {
val r = executor().execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("«Lara Croft» — Movie (2018)"), r)
assertTrue(r.contains("Описание: Tomb Raider reboot"), r)
assertTrue(r.contains("Длительность: 135 мин. (8100 сек.)"), r)
assertTrue(r.contains("Постер: есть"), r)
}
@Test
fun `info без описания и постера — строки не выдумываются`() {
val r = executor().execute("media", "media_info", mapOf("id" to "i2"))
assertFalse(r.contains("Описание"), r)
assertFalse(r.contains("8100"), r)
assertTrue(r.contains("Постер: нет"), r)
assertTrue(r.contains("55 мин. (3300 сек.)"), r)
}
@Test
fun `info описание длиннее 300 символов — обрезка с многоточием`() {
val source = FakeSource()
source.items[0] = source.items[0].copy(overview = "А".repeat(400))
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("Описание: ${"А".repeat(300)}…"), r)
assertFalse(r.contains("А".repeat(301)), r)
}
@Test
fun `info не найден и без аргумента`() {
val r = executor().execute("media", "media_info", mapOf("id" to "iX"))
assertTrue(r.contains("\"iX\" не найден"), r)
val noArg = executor().execute("media", "media_info", emptyMap())
assertTrue(noArg.contains("нет аргумента \"id\""), noArg)
}
@Test
fun `info источник недоступен — сообщение с причиной`() {
val source = FakeSource()
source.itemFails = true
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("Детали недоступны"), r)
assertTrue(r.contains("сеть недоступна"), r)
}
// ---------- media_play ----------
@Test
fun `play передаёт id и seek_ms в игрок`() {
val player = FakePlayer()
val executor = executor(player = player)
val r = executor.execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "3600000"))
assertEquals("включил: i1", r)
assertEquals(listOf("i1" to 3_600_000L), player.calls)
// без seek — ноль
assertEquals("включил: i2", executor.execute("media", "media_play", mapOf("id" to "i2")))
assertEquals(2, player.calls.size)
assertEquals(0L, player.calls.last().second)
}
@Test
fun `play без id — схема аргументов`() {
val r = executor().execute("media", "media_play", emptyMap())
assertTrue(r.contains("нет аргумента \"id\""), r)
assertTrue(r.contains("media_play|<id>"), r)
}
@Test
fun `play кривой seek_ms — числовое требование`() {
for (bad in listOf("час", "12.5")) {
val r = executor().execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to bad))
assertTrue(r.contains("\"seek_ms\" должен быть числом"), r)
}
}
@Test
fun `play отрицательный seek — в ноль`() {
val player = FakePlayer()
executor(player = player).execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "-100"))
assertEquals(0L, player.calls.single().second)
}
@Test
fun `play игрок выбросил — обёртка ошибки`() {
val player = FakePlayer(failure = RuntimeException("видео не найдено"))
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
assertTrue(r.contains("Ошибка воспроизведения"), r)
assertTrue(r.contains("видео не найдено"), r)
}
@Test
fun `play текст проверки игрока проходит как есть`() {
val player = FakePlayer(mapOf("i1" to "видео не скачано на очки. Скачайте его."))
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
assertEquals("видео не скачано на очки. Скачайте его.", r)
}
@Test
fun `неизвестный тул — список доступных`() {
val r = executor().execute("media", "media_bogus", emptyMap())
assertTrue(r.contains("Неизвестный тул"), r)
assertTrue(r.contains("media_search, media_info, media_play"), r)
}
// ---------- маппинг Jellyfin → MediaItem ----------
@Test
fun `маппинг полей, постер, секунды из тиков`() {
val item = JellyfinItem(
Id = "abc",
Name = " Blade ",
Type = "Movie",
ProductionYear = 2259,
Overview = "текст",
RunTimeTicks = 33_000_000_000L, // 3300 сек
ImageTags = mapOf("Primary" to "hash"),
)
assertEquals(
MediaItem("abc", "Blade", "Movie", 2259, "текст", 3300L, true),
jellyfinToMediaItem(item),
)
}
@Test
fun `маппинг пустое имя в id, без постера и секунд`() {
val m = jellyfinToMediaItem(JellyfinItem(Id = "x1", Name = " ", Type = "Episode"))
assertEquals("x1", m.name)
assertFalse(m.hasPoster)
assertEquals(null, m.year)
assertEquals(null, m.durationSeconds)
}
// ---------- JellyfinMediaSource: защита userId ----------
@Test
fun `источник без пользователя — исключение у источника`() {
val source = JellyfinMediaSource(JellyfinClient("http://jellyfin.test", "key")) { " " }
runBlocking {
assertFailsWith<MediaSourceException> { source.search("x", 5) }
assertFailsWith<MediaSourceException> { source.item("i1") }
}
}
}
@@ -0,0 +1,101 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import java.io.File
// Скиллы: реестр (JVM, без Android) — файлы skills/*.md в приложении.
class SkillsTest {
private fun freshDir(): File =
File.createTempFile("vw-skills", "").also { it.delete(); it.mkdirs() }
private fun write(dir: File, fileName: String, content: String) {
File(dir, fileName).writeText(content)
}
@Test
fun `файл с frontmatter парсится в скилл`() {
val dir = freshDir()
write(dir, "media.md", """
---
name: media
---
Вызови enable_toolset|media и дождись реестра тул.
1. media_search|query=<название>
""".trimIndent())
val skills = SkillRepository(dir).loadAll()
assertEquals(1, skills.size)
val s = skills.single()
assertEquals("media", s.name)
assertTrue(s.body.contains("enable_toolset|media"))
// frontmatter (заголовок) не должен попасть в тело
assertFalse(s.body.startsWith("---"))
}
@Test
fun `description в frontmatter становится подсказкой (триггером)`() {
val dir = freshDir()
write(dir, "web.md", """
---
name: web
description: Как сделать веб-поиск.
---
Шаги поиска...
""".trimIndent())
val s = SkillRepository(dir).loadAll().single()
assertEquals("web", s.name)
assertEquals("Как сделать веб-поиск.", s.trigger)
assertTrue(s.body.contains("Шаги поиска"))
}
@Test
fun `файл без frontmatter берёт имя из имени файла`() {
val dir = freshDir()
write(dir, "tips.md", "Советы: запускать воспроизведение сразу после буферизации.")
val s = SkillRepository(dir).loadAll().single()
assertEquals("tips", s.name)
assertEquals("Советы: запускать воспроизведение сразу после буферизации.", s.body)
assertEquals("", s.trigger)
}
@Test
fun `пустой каталог и несуществующий каталог — пустой список`() {
val empty = freshDir()
assertTrue(SkillRepository(empty).loadAll().isEmpty())
assertTrue(SkillRepository(File("no-such-skills-dir")).loadAll().isEmpty())
}
@Test
fun `реестр строки индекса и имена`() {
val reg = SkillRegistry(
listOf(
Skill("media", "Поиск фильмов, детали, запуск/управление.", "тело1"),
Skill("web", "Как сделать веб-поиск.", "тело2"),
),
)
assertEquals(listOf("media", "web"), reg.names())
val lines = reg.index()
assertEquals(2, lines.size)
assertTrue(lines.any { it.startsWith("media — ") })
assertTrue(lines.any { it.startsWith("web — ") })
assertTrue(reg.index(excluded = setOf("media")).all { !it.startsWith("media") })
assertTrue(reg.index(excluded = emptySet()) == lines)
}
@Test
fun `readFull полный текст, неизвестный скилл — null и has`() {
val reg = SkillRegistry(listOf(Skill("media", "триггер", "текст_тела")))
val full = reg.readFull("media")
assertNotNull(full)
assertTrue(full.contains("триггер"))
assertTrue(full.contains("текст_тела"))
assertNull(reg.readFull("other"))
assertTrue(reg.has("media"))
assertFalse(reg.has("other"))
}
}
@@ -0,0 +1,145 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Реестр тулсетов: активность на сессию, таймаут (инжект часов — без ожидания
* реальных 10 минут), идемпотентность enable/disable, префиксная маршрутизация.
*/
class ToolsetRegistryTest {
private var now = 1_000_000L
private fun newRegistry(timeoutMs: Long = ToolsetRegistry.DEFAULT_TIMEOUT_MS): ToolsetRegistry {
now = 1_000_000L
return ToolsetRegistry(timeoutMs = timeoutMs, clock = { now })
}
private fun media(): Toolset = Toolset(
name = "media",
description = "Управление медиа и воспроизведением (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
ToolSignature("info", "Детали фильма.", args = listOf("id")),
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
),
)
@Test
fun `enable активирует набор, активность на сессию`() {
val r = newRegistry()
r.register(media())
val result = r.enable(1, "media")
assertTrue(result is EnableResult.Activated)
assertEquals(listOf("media"), r.activeNames(1))
assertTrue(r.isActive(1, "media"))
// другая сессия — не активна: активность НЕ персистится и НЕ общее
assertFalse(r.isActive(2, "media"))
assertEquals(emptyList(), r.activeNames(2))
}
@Test
fun `повторный enable — продление, не ошибка`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 9 * 60_000
val result = r.enable(1, "media") as EnableResult.Activated
assertTrue(result.alreadyActive)
now += 9 * 60_000 // 18 минут суммарно, но от последнего вызова — 9
assertTrue(r.isActive(1, "media"))
now += 600_001L
assertFalse(r.isActive(1, "media"))
}
@Test
fun `истёкший таймаут чистится лениво`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 600_001L
assertEquals(emptyList(), r.activeNames(1))
assertFalse(r.isActive(1, "media"))
}
@Test
fun `disable идемпотентен, не ошибка`() {
val r = newRegistry()
r.register(media())
val first = r.disable(1, "media") as DisableResult.NotActive
assertTrue(first.message.contains("Нет активных тулсетов"))
r.enable(1, "media")
assertTrue(r.disable(1, "media") is DisableResult.Disabled)
val again = r.disable(1, "media") as DisableResult.NotActive
assertTrue(again.message.contains("Нет активных тулсетов"))
}
@Test
fun `disable другого набора, когда активен свой — сообщение без «нет активных»`() {
val r = newRegistry()
r.register(media())
r.register(Toolset("web", "Работа с вебом.", emptyList()))
r.enable(1, "web")
val res = r.disable(1, "media") as DisableResult.NotActive
assertTrue(res.message.contains("\"media\" не активен"))
// активный «web» затронут не был
assertTrue(r.isActive(1, "web"))
}
@Test
fun `неизвестный набор — сообщение с доступными в ответе`() {
val r = newRegistry()
r.register(media())
val unknown = r.enable(1, "other") as EnableResult.Unknown
assertEquals("other", unknown.name)
assertTrue(unknown.message.contains("\"other\""))
assertTrue(unknown.message.contains("media"))
val emptyReg = newRegistry()
val empty = emptyReg.enable(1, "other") as EnableResult.Unknown
assertTrue(empty.message.contains("Доступных тулсетов нет"))
}
@Test
fun `маршрутизация по префкису и сигнатура по полному имени`() {
val r = newRegistry()
r.register(media())
assertEquals("media", r.findByPrefix("media_search")?.name)
assertEquals("media", r.findByPrefix("media_play")?.name)
// без подчёркивания это имя набора, не тул
assertEquals(null, r.findByPrefix("media"))
assertEquals(null, r.findByPrefix("web_search"))
assertEquals("search", r.signatureOf("media_search")?.name)
assertEquals(listOf("id", "seek_ms?"), r.signatureOf("media_play")?.args)
assertEquals(null, r.signatureOf("media_bogus"))
}
@Test
fun `touch продлевает таймаут на каждом вызове тулы набора`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 9 * 60_000
r.touch(1, "media")
now += 9 * 60_000 // от последнего touch — снова 9 минут
assertTrue(r.isActive(1, "media"))
now += 600_001L
assertFalse(r.isActive(1, "media"))
}
@Test
fun `каталог и активные — независимы, строки каталога по одной на набор`() {
val r = newRegistry()
r.register(media())
r.register(Toolset("web", "Работа с вебом.", emptyList()))
val catalog = r.catalogLines()
assertEquals(2, catalog.size)
assertTrue(catalog.any { it.startsWith("media — ") })
assertTrue(catalog.any { it.startsWith("web — ") })
// каталог есть, активных нет
assertEquals(emptyList(), r.activeNames(7))
}
}
+60
View File
@@ -0,0 +1,60 @@
# Жесты и режимы очков (design, согласовано 2026-08-22)
> Согласовано с пользователем. Реализация — opencode по TASK (см. ниже).
> Это документ проекта: сначала думаем, потом хуячим, потом тесты, потом ОБЯЗАТЕЛЬНО мануальные тесты на железе.
## Ограничения системы (факты)
- Жестов всего шесть: Click, DoubleClick, TripleClick, SlideForward, SlideBackward, LongClick (+ SlideContinuous с delta).
- **LongClick НЕ работает** — перехвачен системным ассистентом RayNeo. Не рассчитывать, не использовать.
- **SlideContinuous существует** (SDK: `TempleAction.SlideContinuous(delta: Float, ...)` — подтверждено javap) — плавный скролл возможен, delta = дельту движения пальца. Частоту событий и амплитуду дельт проверить на железе.
- Экран очков — широкий 1280×480 (640×480 на глаз). Текста влезает мало → скролл ответа обязателен для длинных ответов.
- Оверлей общения затемняет кино (72% чёрного) → режим общения ЯВНЫЙ: пользователь видит, что он в чате, а не в кино. Это позволяет переназначать свайпы в режиме общения без конфликта.
- Нет клавиатуры — только жесты и голос. Выход из режима — только жестом.
- LLM думает 5-15с — нужен индикатор «думаю...», иначе экран замирает.
## Общий паттерн жестов (единая семантика через все режимы)
| Жест | Смысл везде |
|---|---|
| **TripleClick** | вызов локального ассистента (переход в режим общения) |
| **DoubleClick** | отмена / назад |
| **Click** | основное действие текущего контекста |
## Матрица жестов по состояниям (ЗАКРЫТА)
| Состояние | Click | DoubleClick | TripleClick |
|---|---|---|---|
| **Кино** | плей/пауза | игнор (назад некуда) | вызов ассистента (пауза + «слушаю...») |
| **Прослушивание** | отправить фразу | отмена, **остаёмся в диалоге** | игнор |
| **Ответ виден** | новая фраза (микрофон открывается) | **выход из режима** | игнор (уже в режиме) |
Ключевой принцип: **клик = «главное действие здесь»**. В кино — плей/пауза, в диалоге — сказать новую фразу, при прослушивании — отправить. Один жест, контекст решает. Двойной — всегда «не хочу/назад». Тройной — всегда «хочу говорить с ассистентом».
Конфликтов нет: подрежимы различимы по состоянию (listening = микрофон открыт / assistantText непуст = ответ виден).
## Решения по деталям
1. **Отмена диктовки (DoubleClick в прослушивании)** — просто отмена, остаёмся в диалоге (назад к «ответу виден», ничего не теряется).
2. **Фильм после выхода (DoubleClick при ответе)** — ВСЕГДА на паузе, независимо от того, был ли он на паузе до входа в режим ассистента.
3. **Скролл ответа** — плавный: SlideContinuous, отслеживать движение пальца (delta), а не факт свайпа с шагом N. Свайпы SlideForward/SlideBackward в режиме общения — тоже скролл (запасной вариант, если SlideContinuous окажется нестабильным на железе).
4. **Новая фраза в режиме «ответ виден»** — Click: микрофон открывается, «слушаю...». Старый ответ с экрана — стереть (история живёт в SQLite на телефоне, не теряется).
5. **Индикатор «думаю...»** — после отправки фразы (между прослушиванием и ответом) показывать «думаю...» вместо замирания на тексте фразы.
## Что делать (TASK для opencode)
В `app-glasses` (MainActivity.kt + оверлей):
1. **Переразметка жестов по матрице**:
- Кино: как сейчас (Click=TOGGLE, TripleClick=вызов, свайпы=±30с, DoubleClick=игнор)
- Прослушивание: как сейчас (Click=отправить, DoubleClick=отмена, TripleClick=игнор)
- Ответ виден (listening=false, assistantText непуст): **Click → новая фраза** (открыть микрофон, стереть assistantText со старта), **DoubleClick → выход из режима** (закрыть оверлей, фильм на паузе, assistantText стереть), **TripleClick → игнор**, свайпы/SlideContinuous → скролл
2. **Скролл ответа**: плавный через SlideContinuous (delta), scrollState для текста ответа; запасной вариант — дискретные свайпы. Ограничить скролл границами текста.
3. **Индикатор «думаю...»**: состояние между отправкой фразы и получением ShowText; показывать в оверлее.
4. **Выход**: DoubleClick при «ответ виден» — закрыть оверлей, фильм остаётся на паузе (не трогать play/pause при выходе, только при входе ставим паузу).
Тесты: юнит-тесты на чистую логику разметки жестов (переходы состояний, скролл-дельта → смещение), в духе GestureCommandsTest. Мануальные тесты — ОБЯЗАТЕЛЬНО на железе (142: очки 7687f645 + телефон AXGL024B05001337): полный цикл кино → тройной тап → фраза → «думаю...» → ответ → скролл → выход → фильм на паузе.
## Смежные договорённости (архитектура агента)
Полная архитектура скиллов/тулсетов — в `TASK-agent-tools.md`. Ключевое для очков: ассистент — локальный, на телефоне; очки — тонкий клиент (микрофон → STT → телефон → LLM → ShowText на очки). Режим общения — поверх любого режима (кино/чат), активируется тройным тапом из любого места.
+158
View File
@@ -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. Приложение лимит картинок не задаёт.
+6 -1
View File
@@ -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.
@@ -18,6 +18,8 @@ data class JellyfinItem(
val Id: String,
val Name: String? = null,
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
/** Год релиза (Movie/Series); в ответах может отсутствовать. */
val ProductionYear: Int? = null,
val Overview: String? = null,
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
val SeriesName: String? = null, // для эпизодов
@@ -0,0 +1,20 @@
package pw.binom.viewmate.core.net
/**
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
* имя сервиса, разделитель строк-сообщений, период реконнекта.
*/
object BtTransportConstants {
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
const val SERVICE_NAME = "viewmate-spp"
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
const val NEWLINE: Char = '\n'
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
const val RETRY_DELAY_MS: Long = 3_000L
}
@@ -0,0 +1,63 @@
package pw.binom.viewmate.core.net
/**
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
* телефоне) — транспорт остаётся независимым от протокола.
*
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
* (Bluetooth SPP, флагом).
*/
interface GlassesTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
* обрыве) живёт внутри реализации, в корутине вызывающего.
*
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
*/
suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
suspend fun send(json: String)
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
fun close()
}
/**
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
* заботе (запускающий: GlassesHub).
*/
interface GlassesServerTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Бесконечно принимать подключения очков (в корутине вызывающего).
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
* @param onConnected установлено новое соединение [connId].
* @param onDisconnected соединение [connId] оборвано.
*/
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
)
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
suspend fun send(connId: String, json: String)
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
fun close()
}
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.defaultWsClient
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.protocolJson
/**
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
* реконнектом через 3с при обрыве.
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
*/
class GlassesWsClient(
private val url: String, // ws://<host>:8080/ws/glasses
private val http: HttpClient = defaultWsClient(),
) {
) : GlassesTransport {
override val name = "wifi"
@Volatile
private var closed = false
@@ -36,13 +34,13 @@ class GlassesWsClient(
/**
* Подключиться и слушать. Возвращается при вызове [close].
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
* события (пере)подключения (для Hello и смены статуса в UI).
*/
suspend fun connect(
onHostMessage: suspend (HostToGlasses) -> Unit,
onConnected: suspend () -> Unit = {},
onDisconnected: suspend () -> Unit = {},
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
while (!closed) {
try {
@@ -52,9 +50,7 @@ class GlassesWsClient(
try {
for (frame in ws.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
onHostMessage(msg)
onMessage(frame.readText())
}
}
} finally {
@@ -72,14 +68,13 @@ class GlassesWsClient(
}
}
/** Отправить сообщение хосту. Без активного соединения — no-op. */
suspend fun send(msg: GlassesToHost) {
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
session?.send(text)
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
override suspend fun send(json: String) {
session?.send(json)
}
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
fun close() {
override fun close() {
closed = true
session?.cancel()
}
@@ -0,0 +1,36 @@
package pw.binom.viewmate.core.net
import io.ktor.client.HttpClient
import pw.binom.viewmate.core.media.defaultWsClient
/**
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
* подключения/реконнекта не переписывается (переносится в интерфейс).
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
*/
class WifiGlassesTransport(
url: String,
http: HttpClient = defaultWsClient(),
) : GlassesTransport {
override val name = "wifi"
private val client = GlassesWsClient(url, http)
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
client.connect(onMessage, onConnected, onDisconnected)
}
override suspend fun send(json: String) {
client.send(json)
}
override fun close() {
client.close()
}
}
@@ -91,8 +91,15 @@ class PhoneActions(
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
* [startPositionMs] — стартовая позиция (seek для запуска с середины).
*/
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String {
suspend fun watch(
itemId: String,
audioIndex: Int = 0,
localVideoPath: String = "",
phoneAudioReady: Boolean = true,
startPositionMs: Long = 0,
): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
@@ -107,7 +114,7 @@ class PhoneActions(
}
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex, startPositionMs = startPositionMs))
// очки не помнят scale между запусками — при старте просмотра шлём текущий
if (state.scale != 1f) sender.send(SetScale(state.scale))
state.mode = GlassesMode.MOVIE
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
companion object {
private const val PATH = "/ws/glasses"
private val json = protocolJson
private fun hostToGlasses(text: String): HostToGlasses =
json.decodeFromString(HostToGlasses.serializer(), text)
private fun glassesToHost(text: String): GlassesToHost =
json.decodeFromString(GlassesToHost.serializer(), text)
}
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
/** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
private class TestServer(private val port: Int = 0) {
val received = ConcurrentLinkedQueue<GlassesToHost>()
/** Входящие JSON-строки (транспорт — независим от протокола). */
val receivedRaw = ConcurrentLinkedQueue<String>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
received.add(msg)
val text = frame.readText()
receivedRaw.add(text)
val msg = glassesToHost(text)
if (msg is Hello) {
send(protocolJson.encodeToString(
send(json.encodeToString(
HostToGlasses.serializer(),
Welcome(mode = GlassesMode.MOVIE),
))
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
get() = sessions.size
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
for (s in sessions) {
try {
s.send(text)
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Hello(appVersion = "0.1.0"))
client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
awaitTrue { messages.any { it is Welcome } }
val welcome = messages.filterIsInstance<Welcome>().first()
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
assertEquals(GlassesMode.MOVIE, welcome.mode)
client.close()
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Gesture(gesture = "DoubleClick"))
client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
awaitTrue { server.received.any { it is Gesture } }
val gesture = server.received.filterIsInstance<Gesture>().first()
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
glassesToHost(it) as Gesture
}
assertEquals("DoubleClick", gesture.gesture)
client.close()
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
server.broadcast(SetMode(GlassesMode.CHAT))
awaitTrue { messages.any { it is SetMode } }
val setMode = messages.filterIsInstance<SetMode>().first()
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
assertEquals(GlassesMode.CHAT, setMode.mode)
client.close()
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
}
}
/**
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
* и на стороне отправки (encode вызывающий), и на стороне приёма.
*/
@Test
fun transportApiExchangesRawJsonStrings() {
val server = TestServer()
try {
runBlocking {
val transport = WifiGlassesTransport(server.url())
val rawMessages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
transport.connect(
onMessage = { rawMessages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
assertEquals("wifi", transport.name)
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
val hello = glassesToHost(helloJson) as Hello
assertEquals("0.3.0", hello.appVersion)
// исходящее событие — тоже сырая строка; декод — за вызывающим
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
server.broadcast(Welcome(mode = GlassesMode.CHAT))
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
transport.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun reconnectsAfterServerDrop() {
val server = TestServer()
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
val client = GlassesWsClient(server.url())
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connects++ },
onDisconnected = {},
)
}