Compare commits
12 Commits
1
..
51049d56a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 51049d56a8 | |||
| ce2d9c5448 | |||
| d585ddf576 | |||
| 75496dd35d | |||
| 7527cf8848 | |||
| 2d6ca1e097 | |||
| f75e672819 | |||
| f36d04adc1 | |||
| 28d0f9b68d | |||
| c5cbc893ac | |||
| 22369670cb | |||
| bbf7a020d4 |
@@ -0,0 +1,3 @@
|
|||||||
|
# >>> cortexkit:magic-context
|
||||||
|
magic-context/
|
||||||
|
# <<< cortexkit:magic-context
|
||||||
@@ -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 — невидимые тулы (вызовы не в истории)
|
||||||
|
- Скилл исчезает из промпта при активном наборе
|
||||||
|
- История не вычищается
|
||||||
|
- Подмена вызова на активацию — с явной пометкой «вызов ещё не выполнен»
|
||||||
@@ -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 или запасной), что осталось проверить на железе.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Задача: интерфейс LLM + две реализации (Qwen удалённо / Gemma E2B локально) + переключатель
|
||||||
|
|
||||||
|
Дизайн — TASK-local-llm.md. Заметка с разбором litertlm (скопирована в репо, читай её): `docs/litertlm-openassistant-notes.md`. Полная архитектура агента (тулсеты/скиллы) — TASK-agent-tools.md. Реализуй в `app-phone`.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Сейчас `LlmClient` — класс с прямым OkHttp к `https://llm.binom.pw/v1/chat/completions` (Qwen3.8-27B-NVFP4). Файл: `app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt` (НЕ app-phone/agent/ — путь с src/main/kotlin!). Вызывается из `PhoneApp.ensureAssistant()` (app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt) — создаётся один раз и передаётся в `Assistant(chat = { history, system -> llm.chat(history, system) })`.
|
||||||
|
- Нужно: **интерфейс** + **две реализации** + **ручной глобальный переключатель** в UI телефона. В будущем всё должно работать оффлайн.
|
||||||
|
- Модель для локальной: **Gemma 4 E2B (2B)**, формат `.litertlm` (LiteRT LM, Google). Разбор — в `docs/litertlm-openassistant-notes.md` (скопирован в репо — читай его, не /root/notes/).
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
### 1. Интерфейс LlmClient
|
||||||
|
|
||||||
|
Превратить `LlmClient` в интерфейс (или добавить интерфейс-обёртку):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface LlmClient {
|
||||||
|
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Существующий класс → `RemoteLlmClient` (реализация интерфейса, код не менять по сути — только implements).
|
||||||
|
- Все использования (PhoneApp.ensureAssistant) — через интерфейс.
|
||||||
|
|
||||||
|
### 2. Локальная реализация — `LocalLlmClient` (Gemma 4 E2B, LiteRT LM)
|
||||||
|
|
||||||
|
По заметке `/root/notes/openassistant-lite-llm.md` (секция «Две нейронки + токенайзер», «Как повторить», «Скорость»):
|
||||||
|
|
||||||
|
- **Зависимость**: `com.google.ai.edge.litertlm:litertlm-android:0.14.0` в app-phone/build.gradle.kts (репозиторий — google(), у нас в settings должен быть; проверить).
|
||||||
|
**ВАЖНО**: litertlm 0.14.0 требует **kotlinx-coroutines 1.11.0** (иначе NoSuchMethodError close$default на SendChannel — подтверждено автором Modern-Apps, см. его build.gradle.kts:47-48). Поднять версию kotlinx-coroutines в gradle/libs.versions.toml с 1.10.2 до 1.11.0 — проверить, что ничего не ломается (тесты после этого обязаны быть зелёными).
|
||||||
|
- **Модель**: файл `gemma-4-E2B-it.litertlm` (SHA256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`), ~0.8 ГБ. Хранить в `getExternalFilesDir(null)` (как у автора). **НЕ класть в APK** (большой) — загружается отдельно.
|
||||||
|
- **Загрузка**: если файла нет — скачать с зеркала (автор: `https://data.vayunmathur.com/models/`, но у нас доступнее HF: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/<файл>`), SHA256-проверка. Сделай через OkHttp (уже есть в проекте) + поток в файл, с прогрессом (лог).
|
||||||
|
- **Запуск** (из заметки):
|
||||||
|
- `Engine(EngineConfig(modelPath, backend = GPU(), visionBackend = GPU(), audioBackend = CPU(), cacheDir))`
|
||||||
|
- `ExperimentalFlags.enableSpeculativeDecoding = true` **ПЕРЕД** `engine.initialize()` (спекуляция у Gemma 4 встроена в архитектуру — MTP, флаг включает рантайм-путь)
|
||||||
|
- `ConversationConfig(systemInstruction, initialMessages, tools?, automaticToolCalling?)` + `sendMessageAsync` стриминг
|
||||||
|
- **Chat**: перевести `List<ChatMessage>` + system в формат litertlm (initialMessages — история, systemInstruction — system). Вернуть накопленный текст ответа. Стриминг можно агрегировать (не обязательно гнать по частям наружу — контракт `chat(): String`).
|
||||||
|
- **Обработка ошибок**: модель не загружена → понятное сообщение («локальная модель не скачана, скачайте в настройках»), инициализация упала → текст ошибки. НЕ падать.
|
||||||
|
- **Threading**: Engine litertlm требует аккуратности с потоками — смотри пример автора (InferenceService.kt). Скорее всего вызовы идут из корутины — оберни в withContext(Dispatchers.IO) и соблюдай требования движка.
|
||||||
|
- **Время жизни**: Engine создаётся один раз (как ассистент), переиспользуется; инициализация — лениво при первом вызове (модель может весить ~1-2 ГБ в RAM).
|
||||||
|
|
||||||
|
### 3. Переключатель (ручной, глобальный)
|
||||||
|
|
||||||
|
- **Состояние**: `LlmmodelChoice` = `REMOTE` | `LOCAL` (или enum с именами). Хранить в SharedPreferences (переживает рестарт). Global: влияет на весь ассистент, не на сессию.
|
||||||
|
- **Фабрика**: `PhoneApp.ensureAssistant()` выбирает реализацию по сохранённому выбору: REMOTE → `RemoteLlmClient`, LOCAL → `LocalLlmClient`. Переключение — пересоздать ассистента (или хотя бы llm-ссылку) при изменении выбора.
|
||||||
|
- **UI**: на экране «Информация об очках» (GlassesInfoScreen, там уже есть InfoRow) — или лучше отдельный блок в GlassesScreen — добавить переключатель «Модель ассистента: Qwen (сервер) / Gemma (локально)». Компонент: два варианта (радио-кнопки или сегментированная кнопка), плюс статус: для LOCAL — «модель скачана/не скачана», кнопка «Скачать модель» (с прогрессом), для REMOTE — «сервер».
|
||||||
|
- **Индикатор активной модели** — в чате (ChatScreen) мелкой подписью «модель: qwen/gemma» (не обязательно, но желательно — пользователь должен видеть, кто отвечает).
|
||||||
|
|
||||||
|
### 4. Проверка/тесты
|
||||||
|
|
||||||
|
- Юнит-тесты (JVM, без Android — что можно):
|
||||||
|
- Выбор реализации по настройке (фабрика/логика выбора — чистая часть).
|
||||||
|
- Формирование промпта для litertlm (маппинг ChatMessage → initialMessages) — если вынести в чистую функцию.
|
||||||
|
- SHA256-проверка файла (чистая функция).
|
||||||
|
- Сборка: `./gradlew :app-phone:testDebugUnitTest` и `:app-phone:assembleDebug` — зелёные (все старые 142 теста остаются зелёными).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Код только Kotlin
|
||||||
|
- НЕ трогать механики тулсетов/скиллов (AgentToolkit/Skills/Toolsets), Assistant.kt — менять только точку создания LlmClient
|
||||||
|
- НЕ ломать существующие экраны
|
||||||
|
- НЕ коммитить
|
||||||
|
|
||||||
|
## Проверка на железе (после твоей части)
|
||||||
|
|
||||||
|
1. Сборка + установка на телефон (142, adb).
|
||||||
|
2. Загрузка модели (нужен доступ к HF — проверь, скачай на машину, залей через adb, если HF с телефона недоступен).
|
||||||
|
3. Переключение REMOTE ↔ LOCAL, общение с ассистентом на каждой.
|
||||||
|
4. Оффлайн-тест: `svc wifi disable` → локальная модель отвечает, удалённая — понятная ошибка.
|
||||||
|
5. Замер скорости локальной (ток/с из логов).
|
||||||
|
|
||||||
|
## Ответ
|
||||||
|
|
||||||
|
Опиши: какие файлы создал/изменил, как устроен интерфейс, как инициализируется Engine, где переключатель, что осталось проверить на железе (особенно: работает ли litertlm на нашем устройстве, скачивается ли модель).
|
||||||
@@ -0,0 +1,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 на одинаковых запросах
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Задача: медиа-тулы для тулсета media (агент «Порфирий»)
|
||||||
|
|
||||||
|
Механика тулсетов/скиллов УЖЕ готова (AgentToolkit, Skills, Toolsets — см. TASK-agent-tools.md, закоммичено c5cbc89). Твоя задача — **реализовать сам тулсет media с исполнителем** на базе Jellyfin. Чистую механику НЕ трогать.
|
||||||
|
|
||||||
|
## Контекст (что уже есть)
|
||||||
|
|
||||||
|
- `AgentToolkit` (app-phone/.../agent/AgentToolkit.kt): ядро 5 тул + диспач + подмена. Регистрация тулсета: `ToolsetRegistry.register(Toolset(...), executor)`.
|
||||||
|
- `Toolset`/`ToolSignature`/`ToolsetExecutor` (Toolsets.kt): сигнатуры с префиксом `media_`, аргументы строками (`query`, `id`, `seek_ms?`).
|
||||||
|
- `PhoneApp.kt` уже создаёт `AgentToolkit(skills=..., toolsets=ToolsetRegistry(), showText=...)` — тулсеты пустые, тулы не зарегистрированы.
|
||||||
|
- `JellyfinClient` (lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt):
|
||||||
|
- `suspend fun search(userId: String, query: String): List<JellyfinItem>`
|
||||||
|
- `suspend fun item(userId: String, itemId: String): JellyfinItem?`
|
||||||
|
- `suspend fun allItems(userId: String, types: List<String>): List<JellyfinItem>`
|
||||||
|
- `fun posterUrl(itemId: String, maxWidth: Int): String`
|
||||||
|
- `JellyfinItem` (JellyfinModels.kt): Id, Name, Type, Overview, ImageTags, SeriesName, SeasonId, IndexNumber, ParentIndexNumber, RunTimeTicks.
|
||||||
|
- В PhoneApp: `jellyfin: JellyfinClient`, `chatDao`, `server.hub` (broadcast), `actions` (воспроизведение), `state`.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
### 1. Тулсет media (зарегистрировать в PhoneApp)
|
||||||
|
|
||||||
|
Тулы (имена БЕЗ префикса — префикс `media_` добавляется механизмом):
|
||||||
|
|
||||||
|
- `search` — поиск фильмов/сериалов по названию. Аргументы: `query` (обязательный), `limit?` (опциональный, дефолт 5).
|
||||||
|
- Возврат: список строк «id: Название (Год) — Тип» (до limit), либо «ничего не найдено по запросу ...».
|
||||||
|
- `info` — детали элемента по id. Аргументы: `id` (обязательный).
|
||||||
|
- Возврат: Название, Тип, Год, Обзор (Overview, обрезать до ~300 символов), длительность (RunTimeTicks/10_000_000 сек), есть ли постер.
|
||||||
|
- `play` — запуск воспроизведения на очках. Аргументы: `id` (обязательный), `seek_ms?` (опциональный).
|
||||||
|
- Проверки: очки подключены (hub.connected > 0), видео скачано на очки (локальный путь file://<glassesMediaDir>/<id>/video.mkv существует на очках — через hub/каталог), звук готов на телефоне (audioReady).
|
||||||
|
- Воспроизведение — через существующий механизм DetailsScreen (actions.watch(item.Id, audioIndex, localVideoPath, phoneAudioReady)) — вынеси/переиспользуй логику, не дублируй криво.
|
||||||
|
- Возврат: «запущено: <Название>» или понятная ошибка с причиной («очки не подключены», «видео не скачано на очки — скачай вручную», «звук не готов»).
|
||||||
|
|
||||||
|
### 2. Юзер-ID для Jellyfin
|
||||||
|
|
||||||
|
Jellyfin требует userId. Посмотри, как DetailsScreen/CatalogScreen получают userId (вероятно `app.jellyfin.users()` первый или конфиг). Используй тот же механизм, закешируй.
|
||||||
|
|
||||||
|
### 3. Скилл media (файл skills/media.md)
|
||||||
|
|
||||||
|
Формат (как в Skills.kt):
|
||||||
|
```
|
||||||
|
---
|
||||||
|
name: media
|
||||||
|
description: поиск фильмов, детали, запуск/управление просмотром.
|
||||||
|
---
|
||||||
|
Текст скилла: объясни модели, что для работы с медиа вызвать enable_toolset|media, и опиши тулы search/info/play с аргументами.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Тесты (JVM, без Android — по возможности)
|
||||||
|
|
||||||
|
Механику тестировать НЕ надо (уже покрыта). Но исполнитель media_search можно покрыть, если JellyfinClient абстрагируется (интерфейс/fake). Если JellyfinClient — конкретный класс с Ktor, сделай минимальный интерфейс `MediaSource` (search/item) и обёртку — тогда executor тестируется на фейке. Не усложняй сверх меры: если это требует большого рефакторинга, опиши, что осталось непокрытым.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Код только Kotlin
|
||||||
|
- НЕ трогать AgentToolkit/Skills/Toolsets (механика) — только регистрация тулсета и его исполнитель
|
||||||
|
- НЕ ломать существующие экраны (DetailsScreen/CatalogScreen)
|
||||||
|
- Все новые файлы — в app-phone (агент живёт на телефоне)
|
||||||
|
- Сборка: `./gradlew :app-phone:assembleDebug` — зелёная; `./gradlew :app-phone:testDebugUnitTest` — зелёный (все старые тесты остаются зелёными)
|
||||||
|
- НЕ коммитить
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (старые + новые)
|
||||||
|
2. `./gradlew :app-phone:assembleDebug` — собирается
|
||||||
|
3. Опиши в ответе: какие файлы создал/изменил, как зарегистрирован тулсет, как получается userId, что осталось непокрыто тестами.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Задача: реализовать механику скиллов и тулсетов агента «Порфирий»
|
||||||
|
|
||||||
|
Прочитай ТЗ: TASK-agent-tools.md (в корне репо). Реализуй ТОЛЬКО механику, БЕЗ привязки к медиа-данным (медиа-тулы будут позже, отдельно).
|
||||||
|
|
||||||
|
## Объём работ
|
||||||
|
|
||||||
|
### 1. Механика скиллов
|
||||||
|
- Абстракция `Skill`: name, description (триггер, одна строка), body (текст)
|
||||||
|
- `SkillRepository`: читает скиллы из папки `files/skills/` (YAML frontmatter: `---\nname: ...\ndescription: ...\n---\n` + markdown body)
|
||||||
|
- `SkillRegistry`: индекс (имя + триггер) для промпта; `readSkill(name)` возвращает полное содержимое
|
||||||
|
- Скиллов реальных пока НЕТ (механика работает на пустом каталоге или тестовых скиллах в юнит-тестах)
|
||||||
|
|
||||||
|
### 2. Механика тулсетов
|
||||||
|
- `Toolset`: name, описание набора (для каталога), список тул (имя с префиксом набора + описание + сигнатура аргументов)
|
||||||
|
- `ToolsetRegistry`: активность НА СЕССИЮ (Map сессия→(набор→deadline)), хипп, таймаут 10 минут с последнего вызова тулы набора, проверка при сборке промпта (никаких фоновых таймеров)
|
||||||
|
- `enable_toolset|имя`: идемпотентно (повторный = продлить), неактивный вызов НЕ пишется в историю (системная тула-невидимка)
|
||||||
|
- `disable_toolset|имя`: снять, идемпотентно («нет активных наборов» — не ошибка)
|
||||||
|
- Неизвестное имя набора — «набора X нет, доступны: ...»
|
||||||
|
|
||||||
|
### 3. Сборщик промпта (три состояния)
|
||||||
|
- Состояние 1 (набор не активен): ядро + каталог тулсетов + индекс скиллов
|
||||||
|
- Состояние 2 (активация): enable вызывает пересборку, вызов в историю НЕ пишется
|
||||||
|
- Состояние 3 (активен): ядро + описания тул набора + «Активные тулсеты: имя» + скилл, связанный с набором, УБРАН из промпта
|
||||||
|
- Промпт пересобирается на каждый запрос
|
||||||
|
|
||||||
|
### 4. Бесшовная подмена вызова
|
||||||
|
- Вызов тулы набора при НЕактивном наборе (например media_search): НЕ выполнять, подменить как будто вызван enable_toolset|media, ответить «Тулсет media активирован. Реестр: ... Поиск ещё НЕ выполнялся. Для поиска вызови media_search с аргументом query.»
|
||||||
|
- Если аргументы вызова совпадают со схемой из реестра — можно выполнить сразу
|
||||||
|
|
||||||
|
### 5. Обработчик вызовов
|
||||||
|
- Префиксная маршрутизация: имя тулы с префиксом набора (media_*) → набор
|
||||||
|
- Ядро-тулы: get_current_time (есть), show_text (есть ShowText), read_skill, enable_toolset, disable_toolset
|
||||||
|
- Все тулы идемпотентные, прощающие, с понятными ошибками
|
||||||
|
- Валидация аргументов по реестру
|
||||||
|
|
||||||
|
### 6. Юнит-тесты (JVM, без Android) — ОБЯЗАТЕЛЬНО
|
||||||
|
Покрыть механику полностью:
|
||||||
|
- сборка промпта: 3 состояния
|
||||||
|
- подмена вызова при неактивном наборе (включая «поиск ещё не выполнен»)
|
||||||
|
- идемпотентность enable/disable
|
||||||
|
- таймаут (инжектируемые часы — не ждать реальные 10 минут)
|
||||||
|
- активность на сессию (переключение сессий)
|
||||||
|
- префиксная маршрутизация, валидация аргументов
|
||||||
|
- read_skill: чтение из files/skills, парсинг frontmatter
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
- Код только Kotlin (как в проекте)
|
||||||
|
- НЕ трогать существующую логику ассистента без необходимости — встраиваться аккуратно
|
||||||
|
- НЕ делать реальные медиа-тулы (search по каталогу и т.п.) — это отдельный этап
|
||||||
|
- Юнит-тесты должны быть зелёными: ./gradlew :app-phone:testDebugUnitTest
|
||||||
|
- Не коммитить без подтверждения
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
1. ./gradlew :app-phone:testDebugUnitTest — зелёные
|
||||||
|
2. ./gradlew :app-phone:assembleDebug — собирается
|
||||||
@@ -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`, прогнать тесты, показать результат.
|
||||||
@@ -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, агрессивная фоновая
|
||||||
|
политика) замораживает/убивает процесс:
|
||||||
|
|
||||||
|
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
|
||||||
|
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
|
||||||
|
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Добавить в 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" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
fun ConnectionScreen() {
|
class PlaybackService : Service() {
|
||||||
val connection = GlassesApp.instance.hostConnection
|
companion object {
|
||||||
val connected by connection.connected.collectAsState()
|
const val CHANNEL_ID = "viewmate_playback"
|
||||||
val status by connection.status.collectAsState()
|
const val NOTIFICATION_ID = 1
|
||||||
val messages by connection.messages.collectAsState()
|
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
Binocular(scale = false) {
|
context, Intent(context, PlaybackService::class.java)
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
)
|
||||||
val screenHeightPx = BinocularScreenSize.current.height
|
}
|
||||||
// масштаб через layout — как в GlassesMovieScreen
|
fun stop(context: Context) {
|
||||||
val containerW = (screenWidthPx * scale).toInt()
|
context.stopService(Intent(context, PlaybackService::class.java))
|
||||||
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),
|
|
||||||
) {
|
|
||||||
... существующий контент ...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
|
||||||
|
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
|
||||||
|
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
|
||||||
|
|
||||||
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
|
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
|
||||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
|
||||||
|
|
||||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
- **Старт — при каждом старте аудио** (3 места):
|
||||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
(строка ~127) и второй play (строка ~215)
|
||||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
приложение живо) — НО только если пользователь не против постоянного
|
||||||
|
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
|
||||||
|
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
|
||||||
|
уведомление — постоянный индикатор «ViewMate работает». Это проще и
|
||||||
|
покрывает и watch, и autostartAudio, и STT-сессии.
|
||||||
|
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
|
||||||
|
|
||||||
## Баг 2: скролл каталога теряется при навигации
|
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
|
||||||
|
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
|
||||||
|
приложения-компаньона — так работает, например, в паре с очками).
|
||||||
|
|
||||||
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
|
### 4. Проверка
|
||||||
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
|
|
||||||
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
|
|
||||||
папку, потом ещё — и т.д.»
|
|
||||||
|
|
||||||
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
|
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||||
|
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||||
**Фикс:** добавить в `CatalogState`:
|
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||||
- `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) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
и `LazyVerticalGrid(state = gridState, ...)`.
|
|
||||||
|
|
||||||
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
|
|
||||||
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
|
|
||||||
каждый уровень сохраняет свою позицию.
|
|
||||||
|
|
||||||
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
|
|
||||||
`androidx.compose.runtime.snapshotFlow`.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
|
|
||||||
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
|
|
||||||
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
|
|
||||||
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
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
|
<application
|
||||||
android:name=".GlassesApp"
|
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 хоста); на устройстве телефон
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
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); остаётся до новой сессии. */
|
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||||
val assistantText = MutableStateFlow("")
|
val assistantText = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||||
|
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||||
|
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||||
|
*/
|
||||||
|
val thinking = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||||
|
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||||
|
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||||
|
*/
|
||||||
|
val answerScrollDelta = MutableStateFlow(0f)
|
||||||
|
|
||||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||||
var mainActivity: MainActivity? = null
|
var mainActivity: MainActivity? = null
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
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.AssistantStateMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
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.SttDone
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обвязка над [GlassesWsClient] для UI очков:
|
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||||
* Hello при подключении, периодический статус/позиция,
|
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||||
|
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||||
*/
|
*/
|
||||||
class HostConnection(
|
class HostConnection(
|
||||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||||
@@ -56,11 +61,14 @@ class HostConnection(
|
|||||||
private val discovery = MdnsClient(context)
|
private val discovery = MdnsClient(context)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||||
@Volatile
|
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||||
private var client: GlassesWsClient? = null
|
|
||||||
|
|
||||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||||
|
@Volatile
|
||||||
|
private var transport: GlassesTransport? = null
|
||||||
|
|
||||||
|
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastUrl: String? = null
|
private var lastUrl: String? = null
|
||||||
|
|
||||||
@@ -81,10 +89,10 @@ class HostConnection(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
client?.send(buildStatus())
|
sendToHost(buildStatus())
|
||||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||||
client?.send(
|
sendToHost(
|
||||||
PlaybackPosition(
|
PlaybackPosition(
|
||||||
positionMs = snapshot.positionMs,
|
positionMs = snapshot.positionMs,
|
||||||
playing = snapshot.playing,
|
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с на
|
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||||
* повторный mDNS-цикл.
|
* повторный mDNS-цикл.
|
||||||
*/
|
*/
|
||||||
private suspend fun connectLoop() {
|
private suspend fun connectLoopWifi() {
|
||||||
val url = lastUrl ?: discoverPhoneUrl()
|
val url = lastUrl ?: discoverPhoneUrl()
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
log("app", "телефон не найден — повтор поиска через 3с")
|
log("app", "телефон не найден — повтор поиска через 3с")
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
connectLoop()
|
connectLoopWifi()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastUrl = url
|
lastUrl = url
|
||||||
log("app", "подключаюсь к $url")
|
log("app", "подключаюсь к $url (WiFi)")
|
||||||
|
|
||||||
val c = GlassesWsClient(url)
|
val t = WifiGlassesTransport(url)
|
||||||
client = c
|
transport = t
|
||||||
c.connect(
|
t.connect(
|
||||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
onMessage = { text -> handleHostMessageText(text) },
|
||||||
onConnected = {
|
onConnected = {
|
||||||
_connected.value = true
|
_connected.value = true
|
||||||
_status.value = "подключено: $url"
|
_status.value = "подключено: ${t.name} $url"
|
||||||
addMessage("соединение установлено")
|
addMessage("соединение установлено")
|
||||||
discovery.stop()
|
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()) {
|
if (reconnectResume.consume()) {
|
||||||
withContext(Dispatchers.Main) { movieController.resume() }
|
withContext(Dispatchers.Main) { movieController.resume() }
|
||||||
addMessage("связь восстановлена — продолжаю")
|
addMessage("связь восстановлена — продолжаю")
|
||||||
@@ -145,7 +197,7 @@ class HostConnection(
|
|||||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||||
*/
|
*/
|
||||||
private suspend fun discoverPhoneUrl(): String {
|
private suspend fun discoverPhoneUrl(): String {
|
||||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||||
@@ -171,18 +223,29 @@ class HostConnection(
|
|||||||
return ""
|
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) {
|
suspend fun sendToHost(msg: GlassesToHost) {
|
||||||
client?.send(msg)
|
val t = transport ?: return
|
||||||
|
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||||
fun sendPlaybackPositionNow() {
|
fun sendPlaybackPositionNow() {
|
||||||
val c = client ?: return
|
if (transport == null) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val (positionMs, playing, itemId, audioIndex) =
|
val (positionMs, playing, itemId, audioIndex) =
|
||||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
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 -> {
|
is ShowText -> {
|
||||||
addMessage("ShowText: ${msg.text}")
|
addMessage("ShowText: ${msg.text}")
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||||
GlassesApp.instance.assistantText.value = msg.text
|
GlassesApp.instance.assistantText.value = msg.text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +353,7 @@ class HostConnection(
|
|||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
log("stt", "отменено")
|
log("stt", "отменено")
|
||||||
GlassesApp.instance.listening.value = false
|
GlassesApp.instance.listening.value = false
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
@@ -306,7 +372,7 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
client?.close()
|
transport?.close()
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -18,7 +19,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -36,7 +36,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
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.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.SttAudio
|
import pw.binom.viewmate.core.protocol.SttAudio
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
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.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.status.StatusPanelCompose
|
||||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
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.Binocular
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||||
@@ -79,9 +82,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
private val debugReceiver = object : BroadcastReceiver() {
|
private val debugReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
when (intent?.getStringExtra("type")) {
|
when (intent?.getStringExtra("type")) {
|
||||||
"triple_click" -> handleTripleClick()
|
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
||||||
"click" -> handleClick()
|
"click" -> onTempleAction(TempleAction.Click())
|
||||||
"double_click" -> handleDoubleClick()
|
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
||||||
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
GlassesApp.instance.mainActivity = this
|
GlassesApp.instance.mainActivity = this
|
||||||
|
requestBtPermissions()
|
||||||
collectTempleActions()
|
collectTempleActions()
|
||||||
disableTrackpadSounds()
|
disableTrackpadSounds()
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
@@ -135,6 +139,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
} else {
|
} else {
|
||||||
ConnectionScreen()
|
ConnectionScreen()
|
||||||
}
|
}
|
||||||
|
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
||||||
|
// виден в «слушаю»/«думаю»/«ответ виден».
|
||||||
|
AssistantOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,78 +195,137 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun requestBtPermissions() {
|
||||||
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
|
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||||
* завершить распознавание, DoubleClick — отмена.
|
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
*/
|
}
|
||||||
|
if (granted) return
|
||||||
|
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
||||||
|
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
||||||
private fun collectTempleActions() {
|
private fun collectTempleActions() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
templeActionViewModel.state.collect { action ->
|
templeActionViewModel.state.collect { action ->
|
||||||
when (action) {
|
onTempleAction(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)}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
|
/**
|
||||||
private fun handleTripleClick() {
|
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||||
if (GlassesApp.instance.listening.value) {
|
* (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 проигнорирован: распознавание уже идёт")
|
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("stt", "ассистент: слушаю")
|
log("stt", "ассистент: слушаю")
|
||||||
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
|
if (app.movieController.pauseIfPlaying()) {
|
||||||
log("stt", "кино на паузе (TripleClick)")
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||||
}
|
}
|
||||||
GlassesApp.instance.listening.value = true
|
app.listening.value = true
|
||||||
GlassesApp.instance.sttText.value = ""
|
app.thinking.value = false
|
||||||
GlassesApp.instance.assistantText.value = ""
|
app.sttText.value = ""
|
||||||
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
|
app.assistantText.value = ""
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
|
/** Click (кино): плей/пауза — как сегодня. */
|
||||||
private fun handleClick() {
|
private fun togglePlayback() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
|
||||||
log("gesture", "Click → завершить распознавание")
|
|
||||||
sttMic.stop()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
|
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||||
private fun handleDoubleClick() {
|
private fun sendPhrase() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
log("gesture", "Click → отправить фразу")
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
sttMic.stop()
|
||||||
sttMic.stop()
|
GlassesApp.instance.thinking.value = true
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||||
}
|
}
|
||||||
return
|
}
|
||||||
|
|
||||||
|
/** 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
}
|
}
|
||||||
log("gesture", "жест (резерв): DoubleClick")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,41 +465,7 @@ fun ConnectionScreen() {
|
|||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||||
scale = scale,
|
scale = scale,
|
||||||
)
|
)
|
||||||
|
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||||
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
|
|
||||||
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
|
|
||||||
// (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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+255
@@ -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)
|
||||||
|
}
|
||||||
@@ -98,6 +98,9 @@ dependencies {
|
|||||||
implementation(libs.koog.openai.client)
|
implementation(libs.koog.openai.client)
|
||||||
implementation(libs.koog.http.client.okhttp)
|
implementation(libs.koog.http.client.okhttp)
|
||||||
|
|
||||||
|
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
|
||||||
|
implementation(libs.litertlm.android)
|
||||||
|
|
||||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||||
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -7,7 +7,18 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
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.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
|
<application
|
||||||
android:name=".PhoneApp"
|
android:name=".PhoneApp"
|
||||||
@@ -16,13 +27,28 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:usesCleartextTraffic="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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</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
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
import io.ktor.server.application.Application
|
import android.content.Context
|
||||||
import io.ktor.server.application.install
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import io.ktor.server.cio.CIO
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
import kotlinx.coroutines.Job
|
||||||
import io.ktor.server.engine.embeddedServer
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import io.ktor.server.routing.routing
|
import kotlinx.coroutines.cancel
|
||||||
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.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
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.GlassesSender
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
import pw.binom.viewmate.core.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||||
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
|
||||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
|
||||||
* DownloadProgress → карта скачивания на очки).
|
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
|
||||||
|
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
|
||||||
*/
|
*/
|
||||||
class GlassesHub(
|
class GlassesHub(
|
||||||
private val state: PhoneState,
|
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)
|
private val _connected = MutableStateFlow(0)
|
||||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||||
@@ -110,32 +109,29 @@ class GlassesHub(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var sttFactory: (() -> SttStreamer?)? = null
|
var sttFactory: (() -> SttStreamer?)? = null
|
||||||
|
|
||||||
fun add(session: DefaultWebSocketServerSession) {
|
fun add(connId: String) {
|
||||||
sessions[session] = Unit
|
sessions[connId] = Unit
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(session: DefaultWebSocketServerSession) {
|
fun remove(connId: String) {
|
||||||
sessions.remove(session)
|
sessions.remove(connId)
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение всем подключённым очкам. */
|
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (session in sessions.keys) {
|
for (connId in sessions.keys) {
|
||||||
try {
|
runCatching { transport.send(connId, text) }
|
||||||
session.send(text)
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
} catch (e: Exception) {
|
|
||||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обработать входящее текстовое сообщение от очков. */
|
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
suspend fun handle(connId: String, text: String) {
|
||||||
val msg = try {
|
val msg = try {
|
||||||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -144,8 +140,8 @@ class GlassesHub(
|
|||||||
}
|
}
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is Hello -> {
|
is Hello -> {
|
||||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||||||
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
is Gesture -> {
|
is Gesture -> {
|
||||||
@@ -199,80 +195,67 @@ class GlassesHub(
|
|||||||
is StopStt -> {
|
is StopStt -> {
|
||||||
if (msg.cancel) {
|
if (msg.cancel) {
|
||||||
log("stt", "распознавание отменено (очки)")
|
log("stt", "распознавание отменено (очки)")
|
||||||
send(session, SttCancel())
|
send(connId, SttCancel())
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||||
send(session, SttDone(full))
|
send(connId, SttDone(full))
|
||||||
stt?.reset()
|
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)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
session.send(text)
|
runCatching { transport.send(connId, text) }
|
||||||
}
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
|
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
|
||||||
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
|
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
|
||||||
|
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
|
||||||
|
* (SPP). Поведение по умолчанию (WiFi) не меняется.
|
||||||
*/
|
*/
|
||||||
class GlassesServer(
|
class GlassesServer(
|
||||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||||
private val state: PhoneState = PhoneState(),
|
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). */
|
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
||||||
val sender: GlassesSender = object : GlassesSender {
|
val sender: GlassesSender = object : GlassesSender {
|
||||||
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
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() {
|
fun start() {
|
||||||
if (server != null) return
|
if (acceptJob != null) return
|
||||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
acceptJob = scope.launch {
|
||||||
glassesServerModule(hub)
|
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() {
|
fun stop() {
|
||||||
server?.stop(1_000, 3_000)
|
transport.close()
|
||||||
server = null
|
scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
package pw.binom.viewmate.phone
|
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) {
|
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
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import pw.binom.viewmate.phone.stt.SttDebug
|
import pw.binom.viewmate.phone.stt.SttDebug
|
||||||
import pw.binom.viewmate.phone.agent.LlmDebug
|
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||||
@@ -15,6 +18,7 @@ import java.io.File
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestBtPermissions()
|
||||||
setContent {
|
setContent {
|
||||||
ViewMateApp()
|
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]. */
|
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
||||||
private fun runLlmDebug() {
|
private fun runLlmDebug() {
|
||||||
showOverlay("[llm] вызываю llm.binom.pw...")
|
showOverlay("[llm] вызываю llm.binom.pw...")
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import java.io.File
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
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.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
import pw.binom.viewmate.phone.agent.ChatDb
|
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.SttStreamer
|
||||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||||
|
|
||||||
@@ -39,8 +54,13 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
val state: PhoneState = PhoneState()
|
val state: PhoneState = PhoneState()
|
||||||
|
|
||||||
/** WS-сервер для очков. */
|
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
|
||||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
val server: GlassesServer = GlassesServer(
|
||||||
|
port = PhoneConfig.SERVER_PORT,
|
||||||
|
state = state,
|
||||||
|
useBluetooth = PhoneConfig.USE_BLUETOOTH,
|
||||||
|
btContext = this,
|
||||||
|
)
|
||||||
|
|
||||||
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||||
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||||
@@ -85,31 +105,188 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
private var assistantLock = Any()
|
private var assistantLock = Any()
|
||||||
private var assistant: Assistant? = null
|
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? {
|
private fun ensureAssistant(): Assistant? {
|
||||||
synchronized(assistantLock) {
|
synchronized(assistantLock) {
|
||||||
assistant?.let { return it }
|
val choice = _llmChoice.value
|
||||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
if (assistant != null) {
|
||||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
if (assistantChoice == choice) return assistant
|
||||||
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
|
assistant = null
|
||||||
.also { assistant = it }
|
}
|
||||||
|
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}»"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||||
*/
|
*/
|
||||||
private suspend fun assistantLoop() {
|
private suspend fun assistantLoop() {
|
||||||
for (phrase in assistantChannel) {
|
for (phrase in assistantChannel) {
|
||||||
|
log("assistant", "получена фраза: $phrase")
|
||||||
val assistant = ensureAssistant()
|
val assistant = ensureAssistant()
|
||||||
if (assistant == null) {
|
if (assistant == null) {
|
||||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
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}") }
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
server.hub.broadcast(ShowText(answer))
|
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 —
|
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||||
@@ -189,8 +396,12 @@ class PhoneApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
|
PlaybackService.start(this)
|
||||||
|
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||||
|
|
||||||
server.start()
|
server.start()
|
||||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
|
DebugHttpServer.start(this)
|
||||||
nsd.publish()
|
nsd.publish()
|
||||||
server.hub.sttFactory = { ensureStt() }
|
server.hub.sttFactory = { ensureStt() }
|
||||||
scope.launch { assistantLoop() }
|
scope.launch { assistantLoop() }
|
||||||
@@ -264,6 +475,7 @@ class PhoneApp : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
|
PlaybackService.stop(this)
|
||||||
nsd.unpublish()
|
nsd.unpublish()
|
||||||
sttStreamer?.close()
|
sttStreamer?.close()
|
||||||
server.stop()
|
server.stop()
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ object PhoneConfig {
|
|||||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||||
const val SERVER_PORT = 8080
|
const val SERVER_PORT = 8080
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||||
|
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||||
|
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
|
||||||
|
*/
|
||||||
|
const val USE_BLUETOOTH = true
|
||||||
|
|
||||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
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,153 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
||||||
* история накапливается в сессии. Синхронный вызов из одной корутины
|
* история накапливается в сессии. Синхронный вызов из одной корутины
|
||||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||||
|
*
|
||||||
|
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||||
|
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
||||||
|
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
||||||
|
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||||
|
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||||
|
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||||
|
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
||||||
|
* возвращается пользователю как финальный ответ.
|
||||||
*/
|
*/
|
||||||
class Assistant(
|
class Assistant(
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
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,
|
private val timeoutMs: Long = 30_000,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
|
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
|
||||||
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
|
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
|
||||||
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
|
||||||
|
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||||
*/
|
*/
|
||||||
suspend fun process(text: String): String {
|
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)
|
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)
|
||||||
|
// Сессия безымянная (создана кнопкой «новая сессия») — имя из первого сообщения.
|
||||||
|
val s = chatDao.getSession(sessionId)
|
||||||
|
if (s != null && s.title.isBlank()) {
|
||||||
|
val t = text.trim().take(30)
|
||||||
|
chatDao.renameSession(sessionId, if (t.isEmpty()) "беседка" else t)
|
||||||
|
}
|
||||||
|
return processInSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processInSession(sessionId: Long): String {
|
||||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
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 "Ассистент не ответил (таймаут)"
|
?: return "Ассистент не ответил (таймаут)"
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
if (!isToolCall(answer)) {
|
||||||
chatDao.touchSession(sessionId)
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||||
|
chatDao.touchSession(sessionId)
|
||||||
|
}
|
||||||
return answer
|
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 {
|
companion object {
|
||||||
// TODO(персона): промпт выберет пользователь
|
/** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
|
||||||
const val SYSTEM_PROMPT =
|
const val MAX_TOOL_CALLS = 3
|
||||||
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
|
|
||||||
|
/** Общий лимит 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 }
|
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) {
|
fun renameSession(id: Long, title: String) {
|
||||||
db.writableDatabase.update(
|
db.writableDatabase.update(
|
||||||
ChatDb.TABLE_SESSIONS,
|
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
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
|
import kotlinx.coroutines.Dispatchers
|
||||||
import ai.koog.prompt.dsl.prompt
|
import kotlinx.coroutines.withContext
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
|
import kotlinx.serialization.Serializable
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
import kotlinx.serialization.encodeToString
|
||||||
import ai.koog.prompt.llm.LLMCapability
|
import kotlinx.serialization.json.Json
|
||||||
import ai.koog.prompt.llm.LLModel
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import ai.koog.prompt.llm.LLMProvider
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
|
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
|
||||||
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
|
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
|
||||||
|
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
|
||||||
|
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
|
||||||
*
|
*
|
||||||
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
|
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
|
||||||
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
|
* («Ассистент недоступен: …»), сам клиент не падает тихо.
|
||||||
*
|
|
||||||
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
|
|
||||||
*/
|
*/
|
||||||
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 apiKey: String,
|
||||||
private val baseUrl: String = "https://llm.binom.pw",
|
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(
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
apiKey = apiKey,
|
|
||||||
settings = OpenAIClientSettings(baseUrl = baseUrl),
|
@Serializable
|
||||||
httpClientFactory = OkHttpKoogHttpClient.Factory(),
|
private data class ChatMessageDto(
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val llmModel: LLModel = LLModel(
|
@Serializable
|
||||||
provider = LLMProvider.OpenAI,
|
private data class ChatRequest(
|
||||||
id = model,
|
val model: String,
|
||||||
capabilities = listOf(
|
val messages: List<ChatMessageDto>,
|
||||||
LLMCapability.Completion,
|
val max_tokens: Int = 1024,
|
||||||
LLMCapability.OpenAIEndpoint.Completions,
|
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 {
|
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||||
val prompt = prompt("assistant-chat") {
|
val dto = ArrayList<ChatMessageDto>()
|
||||||
if (system != null) {
|
if (system != null) dto += ChatMessageDto("system", system)
|
||||||
this.system(system)
|
for (msg in messages) {
|
||||||
}
|
when (msg.role) {
|
||||||
for (msg in messages) {
|
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
|
||||||
when (msg.role) {
|
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
|
||||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
|
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
|
||||||
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
|
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
|
||||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
|
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
|
||||||
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
|
|
||||||
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
|
|
||||||
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
|
|
||||||
ChatRole.TOOL -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 — пропускаю")
|
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val client = LlmClient(apiKey = apiKey, model = model)
|
val client = RemoteLlmClient(apiKey = apiKey, model = model)
|
||||||
try {
|
try {
|
||||||
val reply = runBlockingSafe {
|
val reply = runBlockingSafe {
|
||||||
client.chat(
|
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 toolCalls: List<ToolCall>,
|
||||||
val attachments: List<Attachment>,
|
val attachments: List<Attachment>,
|
||||||
val createdAt: Long,
|
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 в строковые колонки БД. */
|
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
|
||||||
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
|
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,241 @@
|
|||||||
package pw.binom.viewmate.phone.ui
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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
|
@Composable
|
||||||
fun ChatScreen() {
|
fun ChatScreen() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.fillMaxSize()
|
||||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
.imePadding()
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
// Шапка: название + управление сессиями
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Порфирий",
|
||||||
|
color = PhoneText,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Переключение сессий
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { sessionMenu = true }) {
|
||||||
|
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(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 = "Чат с Порфирием",
|
text = text,
|
||||||
color = PhoneText,
|
color = PhoneText,
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier
|
||||||
)
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
Text(
|
.background(
|
||||||
text = "в разработке (следующий этап)",
|
color = if (isUser) PhoneSurface else PhoneCard,
|
||||||
color = PhoneMuted,
|
shape = RoundedCornerShape(10.dp),
|
||||||
fontSize = 13.sp,
|
)
|
||||||
modifier = Modifier.padding(top = 8.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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
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
|
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.WebSockets
|
||||||
import io.ktor.client.plugins.websocket.webSocket
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
import io.ktor.server.testing.testApplication
|
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import io.ktor.websocket.send
|
import io.ktor.websocket.send
|
||||||
@@ -30,17 +29,34 @@ import kotlin.test.assertEquals
|
|||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
|
||||||
|
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
|
||||||
|
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
|
||||||
|
*/
|
||||||
class GlassesServerTest {
|
class GlassesServerTest {
|
||||||
|
|
||||||
private val json = protocolJson
|
private val json = protocolJson
|
||||||
|
|
||||||
@Test
|
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
|
||||||
fun helloGetsWelcome() = testApplication {
|
private fun startServer(): Pair<GlassesServer, Int> {
|
||||||
val hub = GlassesHub(PhoneState())
|
val port = freePort()
|
||||||
application { glassesServerModule(hub) }
|
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
|
||||||
val client = createClient { install(WebSockets) }
|
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")))
|
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||||
val frame = incoming.receive() as Frame.Text
|
val frame = incoming.receive() as Frame.Text
|
||||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
@@ -50,12 +66,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun gestureIsProcessed() = testApplication {
|
fun gestureIsProcessed() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||||
}
|
}
|
||||||
@@ -64,28 +77,22 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectedCounterTracksSessions() = testApplication {
|
fun connectedCounterTracksSessions() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) { }
|
|
||||||
awaitTrue { hub.connected.value == 0 }
|
awaitTrue { hub.connected.value == 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun broadcastReachesAllGlasses() = testApplication {
|
fun broadcastReachesAllGlasses() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val opened = Channel<Unit>(2)
|
val opened = Channel<Unit>(2)
|
||||||
val received = Channel<HostToGlasses>(2)
|
val received = Channel<HostToGlasses>(2)
|
||||||
|
|
||||||
val jobs = (1..2).map {
|
val jobs = (1..2).map {
|
||||||
launch {
|
launch {
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
opened.send(Unit)
|
opened.send(Unit)
|
||||||
while (true) {
|
while (true) {
|
||||||
val frame = incoming.receive()
|
val frame = incoming.receive()
|
||||||
@@ -115,12 +122,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
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)))
|
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
||||||
}
|
}
|
||||||
@@ -131,12 +135,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(
|
send(
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
GlassesToHost.serializer(),
|
GlassesToHost.serializer(),
|
||||||
@@ -163,6 +164,21 @@ class GlassesServerTest {
|
|||||||
assertEquals("0.2.0", hub.appVersion.value)
|
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) {
|
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (!condition()) {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 на очки). Режим общения — поверх любого режима (кино/чат), активируется тройным тапом из любого места.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# OpenAssistant (vayun-mathur/Modern-Apps) — локальные нейронки на Android
|
||||||
|
|
||||||
|
Источник: https://github.com/vayun-mathur/Modern-Apps/tree/main/openassistant
|
||||||
|
Клон: ~/WORK/modern-apps/ (depth 1). Разбор 2026-08-10.
|
||||||
|
|
||||||
|
## Текстовые альтернативы E2B в формате .litertlm (проверено 2026-08-10, полка litert-community)
|
||||||
|
Вопрос: есть ли ТЕКСТОВЫЕ модели заметно умнее E2B (2B) за те же/чуть большие токены, тот же формат.
|
||||||
|
|
||||||
|
| Модель | Парам. | Контекст | Размер | Декод на 8 Gen 3 | Оценка |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Gemma 4 E2B (текущая) | 2B | 32K | ~0.8 ГБ | 20-35 т/с (спекуляция вкл) | база сравнения |
|
||||||
|
| Qwen3-8B (mixed int4) | 8B | 2048 (!) | 4.66 ГБ | ~10-12 т/с (оценка, телеф. бенча нет) | заметно умнее, но контекст урезан вдрызг |
|
||||||
|
| Phi-4-mini-instruct | 3.8B | 4096 | 3.9 ГБ | 10.39 т/с GPU (S24 Ultra, 8 Gen 3, динамик int8) | умнее, в 2 раза медленнее |
|
||||||
|
| Ministral-3-3B-Reasoning | 3.3B | 4096 | ~2.2 ГБ | нет телеф. бенча | reasoning, текст, но медленный по природе |
|
||||||
|
| DeepSeek-R1-Distill-Qwen-1.5B | 1.5B | 4096 | 1.83 ГБ | 26.35 т/с CPU (S24 Ultra) | не умнее, другой класс (reasoning) |
|
||||||
|
| Gemma2-2B-IT | 2B | — | — | — | доступ ограничен (restricted) |
|
||||||
|
| Gemma3-4B-IT | 4B | — | — | — | мультимодальная, не подходит по критерию |
|
||||||
|
|
||||||
|
ВЫВОД: за "умнее + текст" платишь скоростью и контекстом. Phi-4-mini — единственный с честным замером на 8 Gen 3 (10.39 т/с — вдвое медленнее 20-25). Qwen3-8B — умнее всех, но 2048 контекст и ~10 т/с. E2B остаётся оптимумом для "незаметной" скорости (20+ т/с).
|
||||||
|
|
||||||
|
|
||||||
|
## Две нейронки + токенайзер
|
||||||
|
|
||||||
|
### 1. Чат-LLM: Gemma 4 E2B (2B) — файл gemma-4-E2B-it.litertlm
|
||||||
|
- Формат .litertlm (LiteRT LM, Google; бывш. AI Edge / MediaPipe LLM Inference)
|
||||||
|
- Библиотека: com.google.ai.edge.litertlm:litertlm-android:0.14.0
|
||||||
|
- Запуск: Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir)) + engine.initialize()
|
||||||
|
- Speculative decoding: ExperimentalFlags.enableSpeculativeDecoding = true
|
||||||
|
- Мультимодальный ввод: Content.Text / Content.ImageFile / Content.AudioFile (голос — WavRecorder, обрабатывается на CPU)
|
||||||
|
- Источник: huggingface.co/litert-community/gemma-4-E2B-it-litert-lm
|
||||||
|
- SHA256: 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c
|
||||||
|
- На диске: gemma4-2b.litertlm в getExternalFilesDir(null)
|
||||||
|
|
||||||
|
### 2. Эмбеддер SigLIP2-base (patch16, 224) — семантический поиск по фото
|
||||||
|
- Служит фото-приложению через ResultReceiver (IPC): text/image/info-запросы
|
||||||
|
- ONNX Runtime: com.microsoft.onnxruntime:onnxruntime-android:1.27.0
|
||||||
|
- Два файла: vision_model_fp16.onnx (картинки), text_model_int8.onnx (текст)
|
||||||
|
- Вектора 768-мерные (dim читается из модели), L2-нормированные, косинус
|
||||||
|
- Источник: huggingface.co/onnx-community/siglip2-base-patch16-224-ONNX
|
||||||
|
- SHA256 vision: a1959f7bd3993a607e48839f6d01e25b876fe76afda301b028b78eef68aabd95
|
||||||
|
- SHA256 text: 3a0603d3a00c05a80a6ded4743c16aaac7b1e62cdcc7e362e7ce418659b96400
|
||||||
|
- ВАЖНО: из выходов брать pooler_output (ранг-2), НЕ last_hidden_state (ранг-3), иначе вектора бессмысленны
|
||||||
|
- Препроцессинг картинки: ресайз прямо в 224x224 (без center-crop), (px-127.5)/127.5, NCHW RGB
|
||||||
|
- Сессии ONNX однопоточные (setIntraOpNumThreads(1)) — бережёт батарею
|
||||||
|
|
||||||
|
### 3. Токенайзер SentencePiece (Gemma tokenizer.model, ~4MB, 256k vocab)
|
||||||
|
- Файл: siglip2_tokenizer.model (SHA256 61a7b147390c64585d6c3543dd6fc636906c9af3865a5548f27f31aee1d4c8e2)
|
||||||
|
- Нужен ТОЛЬКО текстовой башне SigLIP2 (litertlm не отдаёт token id наружу)
|
||||||
|
- Реализация без protobuf-зависимости: ручной парсинг ModelProto (pieces = field 1; piece=1 string, score=2 float, type=3 enum; индекс == token id)
|
||||||
|
- Кодирование: Viterbi Unigram сегментация + byte fallback (<0xNN>, Gemma шлёт все 256 байтовых писов)
|
||||||
|
- Препроцессинг текста: lowercase → NFKC → схлопнуть пробелы → dummy-prefix пробел → ▁ (U+2581)
|
||||||
|
- SEQ_LEN = 64 (падинг padId=0, обрезка)
|
||||||
|
- Риск: совпадение с transformers Siglip2Processor — первое, что проверять при повторе
|
||||||
|
|
||||||
|
## Как это работает (архитектура)
|
||||||
|
|
||||||
|
- Foreground-сервис InferenceService, 3 очереди: standard (чат), intent (JSON-извлечение), embedding (SigLIP2 — отдельный consumer, не ждёт LLM)
|
||||||
|
- Модели качаются ТОЛЬКО с зеркала https://data.vayunmathur.com/models/ (никакого HF-фолбэка, supply-chain митигация), SHA-256 проверка, Content-Length + ETag
|
||||||
|
- Чат: история из Room-БД → ConversationConfig(systemInstruction, initialMessages, tools, automaticToolCalling=true) → sendMessageAsync стриминг
|
||||||
|
- Инструменты (AssistantToolSet): add_to_memory / get_memories / remove_memory и др., automaticToolCalling=true
|
||||||
|
- Intent-режим: system prompt "data extraction engine", стриминг, tryExtractLargestJson + валидация по JSON Schema, досрочный HALT через CancellationException("HALT"), таймаут 45с, очередь-дроп по 45с
|
||||||
|
- Версионирование: cleanupStaleSiglipModels по SiglipEmbedder.MODEL_VERSION; legacy gemma4.litertlm / gemma4-4b.litertlm удаляются при старте
|
||||||
|
- Сборка: noCompress += "onnx"; packaging pickFirsts: libLiteRtTopKOpenClSampler.so, libc++_shared.so
|
||||||
|
|
||||||
|
## Ключевой вывод (что ценно для нас)
|
||||||
|
|
||||||
|
Главный урок не в архитектуре, а в скорости: автор довёл локальную нейронку на телефоне до >=20-25 ток/с — пользователь не видит задержки, ассистент отвечает "на скидку" (вживую, без заметных пауз). Это рецепт быстрого запуска нейронки на устройстве:
|
||||||
|
- Модель 2B (Gemma 4 E2B) в оптимизированном формате LiteRT LM — компактная, но живая
|
||||||
|
- GPU-бэкенд для text/vision (OpenCL), CPU только для аудио
|
||||||
|
- Speculative decoding включён (ExperimentalFlags) — дёшево ускоряет генерацию
|
||||||
|
- Одна сессия движка, переиспользуется; очереди разнесены, эмбеддинги не блокируют чат
|
||||||
|
- Токенайзер ручной (без protobuf), эмбеддер на ONNX — но это обвязка, не узкое место
|
||||||
|
|
||||||
|
Т.е. заявка на повтор: 2B-модель + LiteRT LM + GPU + speculative decoding = быстрый локальный ассистент.
|
||||||
|
|
||||||
|
## Ответы на вопросы (2026-08-10)
|
||||||
|
|
||||||
|
### Что за формат .litertlm и можно ли заменить модель
|
||||||
|
- .litertlm — контейнер LiteRT-LM (Google; бывш. MediaPipe LLM Inference / AI Edge): внутри квантованная модель (смесь 2/4/8 бит, text-only вес ~0.8GB), эмбеддинги (1.12GB, memory-mapped), токенайзер, плюс движок даёт KV-cache менеджмент, промпт-темплейт, function calling
|
||||||
|
- ЗАМЕНИТЬ МОЖНО, и это просто: готовые .litertlm в litert-community на HF (десятки): Qwen3 0.6B/1.7B/8B, Qwen2.5 0.5B/1.5B, TinyLlama-1.1B, Phi-4-mini, SmolLM-135M, DeepSeek-R1-Distill-Qwen-1.5B (reasoning), Gemma3-1B/4B, FunctionGemma-270M (function calling), Nanbeige-3B, Ministral-3-3B, Mage-VL (VLM), FastVLM-0.5B. Свои модели — через LiteRT Torch Generative API (ai-edge-litert, Python)
|
||||||
|
- В openassistant замена = поменять URL+SHA256 в ModelUrls и файл. SigLIP2-токенайзер от LLM не зависит
|
||||||
|
- Ограничения при замене: (а) speculative decoding вшит в сборку модели — у Gemma 4 E2B есть, у других не факт; (б) Content.ImageFile/AudioFile работают только у мультимодальных сборок; (в) tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех
|
||||||
|
- Для скорости на слабом железе: Qwen3-0.6B / SmolLM-135M / Qwen2.5-0.5B (быстрее, но глупее); компромисс: Qwen3-1.7B, Gemma3-1B; русский текст у Qwen обычно лучше
|
||||||
|
|
||||||
|
### Мультимодальность Gemma 4 E2B
|
||||||
|
- ДА: текст + картинки + аудио в одном сообщении. В коде: Content.Text / Content.ImageFile / Content.AudioFile; text/vision на GPU, audio на CPU
|
||||||
|
- Из README: картинки/аудио класть ПЕРЕД текстом в промпте; переменное разрешение картинок через visual token budget (токенов на картинку); vision/audio части грузятся по требованию
|
||||||
|
- В openassistant intent-режим шлёт imagePaths + userText одним Messages.user — живой пример смешанного ввода
|
||||||
|
|
||||||
|
### Контекст
|
||||||
|
- Полная Gemma 4 — до 256K токенов (model card). On-device litertlm-сборка: "supports up to 32k context length" — 32K токенов
|
||||||
|
- Бенчмарки litert-community: 1024 prefill + 256 decode, контекст 2048; S26 Ultra GPU: decode 52 ток/с (baseline), 66-92 ток/с со speculative decoding; prefill 3808 ток/с; TTFT 0.3с
|
||||||
|
- Приложение лимита не задаёт (ConversationConfig без maxTokens) — контекстом рулит движок (KV-cache менеджмент); история из Room идёт в initialMessages, упор в 32k движок разруливает сам
|
||||||
|
- 20-25 ток/с автора — нижняя граница "незаметно", на флагмане официально 46-92
|
||||||
|
|
||||||
|
### Скорость на Snapdragon 8 Gen 3 (12 ГБ RAM)
|
||||||
|
- УСТРОЙСТВО АВТОРА (подтверждено 2026-08-10): Honor Magic V3, Snapdragon 8 Gen 3, 12 ГБ RAM. (Сначала назвал Gen 2 — ложный след, снят с протокола.)
|
||||||
|
- Официальная карточка litert-community НЕ даёт 8 Gen 3 — бенчмарки только на S26 Ultra (чип 2026, новее): GPU decode 52.1 ток/с baseline, 66.5-91.7 со speculative decoding; prefill 3808 ток/с; TTFT 0.3с; CPU: decode 46.9, prefill 557, TTFT 1.8с. Память: GPU-бэкенд 676 МБ, CPU 1733 МБ
|
||||||
|
- 8 Gen 3 (2023, Adreno 750, напр. S24 Ultra US): официальных цифр нет; сторонние замеры (MindStudio): E2B 20-35 ток/с, E4B 12-20 ток/с. Прим.: у MindStudio Pixel 9 приписан к 8 Gen 3 ошибочно (там Tensor G4); настоящий 8 Gen 3 — S24 Ultra (US) и другие
|
||||||
|
- Память 12 ГБ — с большим запасом: модель на GPU ~676-800 МБ RSS (text-only), эмбеддинги 1.12 ГБ memory-mapped (не в RSS), vision/audio по требованию. Ограничение не в RAM, а в скорости GPU/пропускной способности
|
||||||
|
- Вывод: заявленные автором 20-25 ток/с — реалистичная нижняя/средняя граница вилки для 8 Gen 3; со speculative decoding можно ждать заметно больше (порядка 50+ на флагмане, по аналогии с S26 Ultra)
|
||||||
|
|
||||||
|
### Спекулятивное декодирование (MTP) на Snapdragon 8 Gen 3
|
||||||
|
- Что это: LLM обычно выдаёт 1 токен за проход сети. Спекулятивное декодирование: маленький черновик-предсказатель набрасывает сразу N следующих токенов, большая модель проверяет их ОДНИМ проходом — если угадал, получил N токенов ценой одного прохода. Ускорение до ~3x без потери качества
|
||||||
|
- У Gemma 4 это НЕ внешняя надстройка, а встроенная архитектура — Multi-Token Prediction (MTP): черновик-головы сидят прямо в чекпоинте модели (ai.google.dev/gemma/docs/mtp). Спекуляция у Gemma 4 работает автоматически при поддержке рантайма; флаг лишь включает рантайм-путь
|
||||||
|
- В openassistant флаг ПОДТВЕРЖДЁН в коде: InferenceService.kt:486 — ExperimentalFlags.enableSpeculativeDecoding = true перед engine.initialize()
|
||||||
|
- ВЫВОД: 20-25 ток/с автора НЕ означают, что спекуляция выключена. Наоборот — она включена. У автора Honor Magic V3 = Snapdragon 8 Gen 3 (Adreno 750): вилка со спекуляцией ~20-35 ток/с (MindStudio), без неё ~10-15 (вдвое меньше). Его 20-25 — ровно середина ожидаемого С включённой спекуляцией, ближе к нижней границе (зависит от термальных лимитов складного корпуса)
|
||||||
|
- Как проверить на живом телефоне: поставить enableSpeculativeDecoding = false, замерить — падение вдвое = она работала
|
||||||
|
- 8 Gen 3 (2023, Adreno 750): сторонние замеры E2B 20-35 ток/с; на 8 Gen 2 (2022, Adreno 740) было бы ниже на ~15-20%
|
||||||
|
|
||||||
|
## Как повторить (чеклист)
|
||||||
|
|
||||||
|
1. Скачать 4 файла с HF (см. SHA256 выше), разложить под зеркало /models/
|
||||||
|
2. Android: litertlm-android 0.14.0 + onnxruntime-android 1.27.0 (+ kotlinx-coroutines 1.11.0 — иначе close$default на SendChannel)
|
||||||
|
3. Backend: GPU для text/vision, CPU для audio
|
||||||
|
4. Из SigLIP2 ONNX читать pooler_output, не last_hidden_state
|
||||||
|
|
||||||
|
## Бонус: что ещё в монорепо Modern-Apps
|
||||||
|
findfamily (UWB-локатор), keyboard (хангыль-композитор), maps, games (Stockfish через JitPack ncnn), библиотеки: downloadservice, room, image.
|
||||||
|
|
||||||
|
## Raspberry Pi 5: да, работает
|
||||||
|
|
||||||
|
Вопрос: потянет ли Gemma 4 E2B Raspberry Pi 5? Ответ: да, и это официально.
|
||||||
|
|
||||||
|
Формат и рантайм:
|
||||||
|
- LiteRT-LM теперь официально кросс-платформенный: Android, iOS, Web, Desktop, IoT (Raspberry Pi). Есть официальный CLI (uvx-установка), который гоняет .litertlm прямо на Pi — без конвертации. GitHub: google-ai-edge/LiteRT-LM.
|
||||||
|
- Google сам пишет «Try Gemma4-E4B with MTP on Linux, macOS, Windows or Raspberry Pi with the LiteRT-LM CLI».
|
||||||
|
|
||||||
|
Реальные замеры на RPi 5 (8 ГБ, 4×Cortex-A76 @ 2.4 ГГц, без GPU-акселератора):
|
||||||
|
1. Ollama (GGUF): первый токен ~3-4 сек, генерация ~8-12 t/s. Модель ~1.5 ГБ.
|
||||||
|
2. Стоковый llama.cpp (Q4): ~6.6 t/s.
|
||||||
|
3. mkturkcan/mote (оптимизированный llama.cpp + MTP-спекуляция + A76-ядра + KleidiAI): 11.2 t/s на обычном тексте, до 16 на шаблонном, до 20.4 в turbo на повторах (логи/CSV). Устанавливается одной командой, ничего не собирается на Pi.
|
||||||
|
4. Официальный LiteRT-LM CLI: цифры не подтверждены, но движок тот же — ожидаемо в районе 6-12 t/s.
|
||||||
|
|
||||||
|
Важно про контекст на Pi:
|
||||||
|
- Модель заявляет 128K контекста, но KV-кэш на 128K не влезает в 8 ГБ. На RPi 5 (8 ГБ) реально 4K-8K токенов, дальше — своп и обвал скорости. На 16 ГБ — комфортно ~32K.
|
||||||
|
- Мультимодальность (текст+картинки+видео) на Pi работает, но медленнее телефона.
|
||||||
|
|
||||||
|
Вывод: на RPi 5 E2B даёт ~8-12 t/s (сток) или ~11-20 t/s (оптимизированный mote). Это в 2-3 раза медленнее телефона (20-25 t/s), но для офлайн-ассистента, пакетной обработки и домашних экспериментов — достаточно. Воспринимается как «медленно, но читаемо», а не как «зависло».
|
||||||
|
|
||||||
|
## Батарея и скорость обработки картинок (Honor Magic V3, 8 Gen 3, 12 ГБ)
|
||||||
|
|
||||||
|
### Батарея
|
||||||
|
- Точных ватт на Android под GPU-нагрузкой НЕТ ни у кого: Battery Manager API Android ненадёжен под нагрузкой, iOS API не отдаёт (arxiv 2603.23640, "LLM Inference at the Edge", бенчмарк на S24 Ultra = тот же 8 Gen 3).
|
||||||
|
- Ориентиры из той статьи: S24 Ultra idle ~0.8 Вт, max ~12 Вт (система целиком). Qwen 1.5B через MLC-LLM: 9.93 ток/с sustained, префилл 25 сек (OpenCL-оверхед MLC), термальный фейл на 6-й итерации подряд: GPU freq упала до 231 МГц, 78.3°C — жёсткий флор от термогубернатора, а не плавный DVFS как у iPhone.
|
||||||
|
- Энергия на токен на edge-платформах: ~270-300 мДж/токен (Hailo-10H 1.87 Вт / 6.9 ток/с; RTX 4050 ноут 34 Вт / 131.7 ток/с).
|
||||||
|
- Honor Magic V3: батарея 5150 мАч ≈ 19.9 Вт·ч.
|
||||||
|
- Прикидка: Gemma 4 E2B на 8 Gen 3 ~22 ток/с при ~4-5 Вт системы → 0.2 Дж/токен ≈ 56 мкВт·ч/токен. Непрерывная генерация ≈ 4-4.5 часа на всю батарею (20-25%/час). Сценарий ассистента (5-10 мин генерации в час) → 1.5-4%/час — почти незаметно.
|
||||||
|
- Термальный капкан 8 Gen 3: непрерывная нагрузка без пауз → троттлинг, скорость падает до ~2 раз. Magic V3 тонкий — отвод тепла хуже, троттлинг раньше. Паузы между запросами обязательны (раз в несколько минут — ок).
|
||||||
|
- LiteRT-LM оптимизирован под Adreno лучше MLC-LLM (это гугловский движок), поэтому 20-25 ток/с у автора — уже с учётом реального троттлинга.
|
||||||
|
|
||||||
|
### Картинки (видео-поток с камеры)
|
||||||
|
- Gemma 4: переменное разрешение через токен-бюджет: 70/140/280/560/1120 токенов на картинку. Google прямо советует для кадров видео НИЗКИЙ бюджет (70) ради скорости. Механика: 9 патчей на токен (3x3 среднее).
|
||||||
|
- Каждый кадр = вижн-энкодер + префилл токенов бюджета. Порядок: 0.3-1.5 сек на кадр на 8 Gen 3 (оценка, точного бенчмарка нет).
|
||||||
|
- 30 fps НЕВОЗМОЖНО: 70 токенов/кадр × 30 = 2100 ток/с префилла — префилл на телефоне ~200-500 ток/с. Реалистично: кадр раз в 2-5 сек, бюджет 70-140.
|
||||||
|
- Контекст 32K: при 140 ток/кадр ≈ 230 кадров в окне, при 70 ≈ 450. Кадр раз в 2 сек → 8-15 минут непрерывного окна → нужен скользящий буфер кадров.
|
||||||
|
- Для "где я иду/еду": модель получает кадр + вопрос ("где я?"), отвечает за 1-3 сек. Рабочий вариант — периодический анализ, а не поток. Сколько картинок за сообщение принимает on-device сборка — проверять на практике (в доке: reasoning across multiple images).
|
||||||
|
- Код автора: Content.ImageFile(path) шлёт файлы как есть, бэкенд GPU для текста+зрения (InferenceService.kt:493-494), аудио CPU. Приложение лимит картинок не задаёт.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlinx-serialization = "1.8.1"
|
kotlinx-serialization = "1.8.1"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.11.0"
|
||||||
ktor = "3.3.0"
|
ktor = "3.3.0"
|
||||||
android = "8.9.2"
|
android = "8.9.2"
|
||||||
compose-plugin = "1.8.0"
|
compose-plugin = "1.8.0"
|
||||||
@@ -10,6 +10,7 @@ androidx-activity-compose = "1.10.1"
|
|||||||
exoplayer = "2.17.1"
|
exoplayer = "2.17.1"
|
||||||
media3 = "1.6.1"
|
media3 = "1.6.1"
|
||||||
koog = "1.1.1"
|
koog = "1.1.1"
|
||||||
|
litertlm-android = "0.14.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
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" }
|
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
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-совместимый)
|
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
||||||
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
|
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class JellyfinItem(
|
|||||||
val Id: String,
|
val Id: String,
|
||||||
val Name: String? = null,
|
val Name: String? = null,
|
||||||
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
||||||
|
/** Год релиза (Movie/Series); в ответах может отсутствовать. */
|
||||||
|
val ProductionYear: Int? = null,
|
||||||
val Overview: String? = null,
|
val Overview: String? = null,
|
||||||
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
||||||
val SeriesName: String? = null, // для эпизодов
|
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.CancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import pw.binom.viewmate.core.log
|
import pw.binom.viewmate.core.log
|
||||||
import pw.binom.viewmate.core.media.defaultWsClient
|
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],
|
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
|
||||||
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
|
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
|
||||||
* реконнектом через 3с при обрыве.
|
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
|
||||||
|
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
|
||||||
*/
|
*/
|
||||||
class GlassesWsClient(
|
class GlassesWsClient(
|
||||||
private val url: String, // ws://<host>:8080/ws/glasses
|
private val url: String, // ws://<host>:8080/ws/glasses
|
||||||
private val http: HttpClient = defaultWsClient(),
|
private val http: HttpClient = defaultWsClient(),
|
||||||
) {
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var closed = false
|
private var closed = false
|
||||||
@@ -36,13 +34,13 @@ class GlassesWsClient(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключиться и слушать. Возвращается при вызове [close].
|
* Подключиться и слушать. Возвращается при вызове [close].
|
||||||
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
|
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
|
||||||
* события (пере)подключения (для Hello и смены статуса в UI).
|
* события (пере)подключения (для Hello и смены статуса в UI).
|
||||||
*/
|
*/
|
||||||
suspend fun connect(
|
override suspend fun connect(
|
||||||
onHostMessage: suspend (HostToGlasses) -> Unit,
|
onMessage: suspend (String) -> Unit,
|
||||||
onConnected: suspend () -> Unit = {},
|
onConnected: suspend () -> Unit,
|
||||||
onDisconnected: suspend () -> Unit = {},
|
onDisconnected: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
while (!closed) {
|
while (!closed) {
|
||||||
try {
|
try {
|
||||||
@@ -52,9 +50,7 @@ class GlassesWsClient(
|
|||||||
try {
|
try {
|
||||||
for (frame in ws.incoming) {
|
for (frame in ws.incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
onMessage(frame.readText())
|
||||||
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
|
||||||
onHostMessage(msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,14 +68,13 @@ class GlassesWsClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение хосту. Без активного соединения — no-op. */
|
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
|
||||||
suspend fun send(msg: GlassesToHost) {
|
override suspend fun send(json: String) {
|
||||||
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
|
session?.send(json)
|
||||||
session?.send(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
||||||
fun close() {
|
override fun close() {
|
||||||
closed = true
|
closed = true
|
||||||
session?.cancel()
|
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 с ЛОКАЛЬНЫМ путём на очках.
|
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
||||||
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
||||||
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
|
* Звук — на телефоне: [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 item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
@@ -107,7 +114,7 @@ class PhoneActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val title = item.Name ?: itemId
|
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 между запусками — при старте просмотра шлём текущий
|
// очки не помнят scale между запусками — при старте просмотра шлём текущий
|
||||||
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
||||||
state.mode = GlassesMode.MOVIE
|
state.mode = GlassesMode.MOVIE
|
||||||
|
|||||||
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PATH = "/ws/glasses"
|
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) {
|
private class TestServer(private val port: Int = 0) {
|
||||||
val received = ConcurrentLinkedQueue<GlassesToHost>()
|
/** Входящие JSON-строки (транспорт — независим от протокола). */
|
||||||
|
val receivedRaw = ConcurrentLinkedQueue<String>()
|
||||||
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
||||||
|
|
||||||
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
||||||
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
|
val text = frame.readText()
|
||||||
received.add(msg)
|
receivedRaw.add(text)
|
||||||
|
val msg = glassesToHost(text)
|
||||||
if (msg is Hello) {
|
if (msg is Hello) {
|
||||||
send(protocolJson.encodeToString(
|
send(json.encodeToString(
|
||||||
HostToGlasses.serializer(),
|
HostToGlasses.serializer(),
|
||||||
Welcome(mode = GlassesMode.MOVIE),
|
Welcome(mode = GlassesMode.MOVIE),
|
||||||
))
|
))
|
||||||
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
|
|||||||
get() = sessions.size
|
get() = sessions.size
|
||||||
|
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (s in sessions) {
|
for (s in sessions) {
|
||||||
try {
|
try {
|
||||||
s.send(text)
|
s.send(text)
|
||||||
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
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 } }
|
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
|
||||||
val welcome = messages.filterIsInstance<Welcome>().first()
|
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
|
||||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
|
|||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
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 } }
|
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
|
||||||
val gesture = server.received.filterIsInstance<Gesture>().first()
|
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
|
||||||
|
glassesToHost(it) as Gesture
|
||||||
|
}
|
||||||
assertEquals("DoubleClick", gesture.gesture)
|
assertEquals("DoubleClick", gesture.gesture)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
server.broadcast(SetMode(GlassesMode.CHAT))
|
server.broadcast(SetMode(GlassesMode.CHAT))
|
||||||
|
|
||||||
awaitTrue { messages.any { it is SetMode } }
|
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
|
||||||
val setMode = messages.filterIsInstance<SetMode>().first()
|
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
|
||||||
assertEquals(GlassesMode.CHAT, setMode.mode)
|
assertEquals(GlassesMode.CHAT, setMode.mode)
|
||||||
|
|
||||||
client.close()
|
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
|
@Test
|
||||||
fun reconnectsAfterServerDrop() {
|
fun reconnectsAfterServerDrop() {
|
||||||
val server = TestServer()
|
val server = TestServer()
|
||||||
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
|
|||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connects++ },
|
onConnected = { connects++ },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user