# Кэш сообщений на клиенте **Задача:** не тянуть всю историю диалога заново при каждом открытии окна. **Решение: кэш делаем.** Ниже — что для этого есть в библиотеке, сама схема и места, где она может порваться. Всё сверено с исходниками `agentik`, не по памяти. --- ## 1. Схема 1. Открыли диалог — смотрим **свой** кэш. 2. Берём из кэша **последнее сообщение** и его дату. 3. Спрашиваем библиотеку: есть ли сообщения **новее** этой даты. 4. Есть — забираем только новые, кладём в кэш, рисуем. 5. Нет — ничего не делаем. Всю историю заново не тянем. Первый раз диалог грузится целиком, дальше — только прирост. --- ## 2. Что для этого уже есть в библиотеке `client` ### 2.1. Запрос «что новее» — есть ```kotlin suspend fun getMessages(after: Instant, offset: Int, limit: Int): List ``` Отбор **строго новее** указанной даты. Проверено на двух хранилищах агента: ```sql -- storage-sqlite/.../Message.sq listAfter: SELECT * FROM message WHERE conversation_id = ? AND created_at > ? ORDER BY created_at ASC, id ASC LIMIT :limit OFFSET :offset; ``` ```kotlin // storage-inmemory/.../InMemoryMessageStore.kt val filtered = all.filter { it.createdAt > after }.sortedBy { it.createdAt } ``` **Пустой ответ = после нашего сообщения ничего не появилось.** Ровно то, что нужно: «были сообщения после закэшированного?» ### 2.2. Версия потоком, страницами — есть ```kotlin fun getMessages(after: Instant, offset: Int = 0): Flow ``` Подгружает по `PAGE_SIZE = 100` за раз, пока страницы не кончатся. Для длинных диалогов — то, что надо. ### 2.3. Стабильный `id` сообщения — есть ```kotlin sealed interface Message { val id: String; val date: Instant } ``` В комментарии к коду прямо сказано: `id` **стабилен между живым потоком и историей** — id из `Event.ToolCall` равен id соответствующего `Message.ToolCall` в истории после завершения хода. **Зачем это нам:** при догрузке не надо угадывать, что уже нарисовано. Сверяем по `id` — дубли отсекаются точно. ### 2.4. Живой поток «с этого момента» — есть ```kotlin fun events(after: Instant): Flow ``` Подписка не повторяет прошедшее: «Не реплеит события, произошедшие до `after`». Если `after` = момент последнего виденного события, поток продолжается с того места. ### 2.5. Чего в библиотеке НЕТ **Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает. Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента». Заодно **нет и абстракции хранилища** — ни интерфейса «дай сообщения диалога», ни готовой реализации под клиента. И то и другое наше, см. `STORAGE.md`. Полезное: в самом `agentik` хранилище построено на **SQLDelight 2.3.2** — это SQLite, умеющий и JVM, и Android. Тот же подход берём и мы, а не выдумываем. --- ## 3. Где схема может порваться Найдено в коде, не предположения. Три места. ### 3.1. Одинаковые даты Отбор строго `>`, поэтому два сообщения с **ровно одной и той же датой** — второе в ответ не попадёт. Порядок внутри одной даты сервер задаёт сам (`ORDER BY created_at ASC, id ASC`). Реальный риск невелик: дата хранится с точностью до миллисекунд. Плюс при сверке по `id` (п. 2.3) он не страшен вовсе — второе сообщение просто подхватится сверкой, а не запросом. **Что делать:** не полагаться только на дату. Либо сверять последние сообщения по `id`, либо отступать на одно сообщение назад от края кэша при запросе и отбрасывать уже известные по `id`. Второй способ надёжнее. ### 3.2. Прерванный ответ в историю не попадает Если ответ прервали кнопкой «Стоп», приходит `Event.Interrupted`, и в комментарии к коду сказано прямо: **«Частичный ответ НЕ сохраняется в истории»**. В живом потоке кусок был, в `getMessages` его не будет. **Чем грозит:** в кэше окажется то, чего на сервере нет. При следующем открытии диалога по нашей схеме мы возьмём из кэша последнее сообщение — а его на сервере и нет. **Что решить:** хранить кусок прерванного ответа или выбрасывать. Пока **не решено** (в списке нерешённых). Вариант, который выглядит разумнее: помечать такое сообщение в кэше как прерванное и не считать его краем кэша — то есть запрашивать «новее» не по нему, а по последнему полному. ### 3.3. Живой поток тоже просить «с этого момента» Подписка на события без параметра «после» может пропустить то, что случилось между отрисовкой кэша и подпиской. Просить надо с момента последнего известного события (п. 2.4), а не с начала — иначе получим дубли всего потока. --- ## 4. Где хранить — РЕШЕНО **Хранилище — абстракция, реализация — SQLite.** Подробно: `STORAGE.md`. Клиент просит «дай сообщения диалога» и не знает, где они лежат. Реализацию можно будет заменить (это понадобится на Android) — экраны не тронутся. Ни одного упоминания SQLite вне реализации: если в экране встретилось `sqlite` / `SQL` / `query` — абстракция прохудилась. База — один файл в каталоге клиента. Настройки — **отдельно, JSON**: их правит человек руками, в базу для этого лазить не должно быть нужно. Осталось решить: - **Сколько держать** и когда чистить старые диалоги. - **Что делать с прерванным ответом** в кэше (см. 3.2). - Связывать ли кэш с диалогом по `id` диалога — он стабилен, так что связка простая. --- ## 5. Что осталось от обсуждения простоты кода ### Принято Главная цель — **клиент должен читаться**. Пользователь должен открыть исходники и понять, что происходит. Это важнее экономии строк. Голос (микрофон, распознавание, детекция речи) — **берём готовыми библиотеками**: `mic-kmp`, `asr-kmp`, `vad-kmp`. Разбор — `MIC-ASR-SEARCH.md`. Писать заново нечего. ### Отвергнуто - **«Вынести голос в отдельный модуль-обёртку над тремя библиотеками».** Отвергнуто: библиотеки и так уже отдельное место, обёртка добавит лишний слой без пользы. - **«Вынести несколько агентов в отдельный реестр подключений».** Отвергнуто: это и есть работа клиента, выносить некуда. ### Остаётся в клиенте Экраны, пузырь сообщения, хранение списка агентов. **Полезный признак**, что что-то не вынесено: в экране накопилась логика длиннее пары десятков строк. ### Отдельная задача, не сейчас `mic-kmp` — одна реализация микрофона уже есть, но очки `view-mate` её не используют: у них свой кусок на Android (`AudioRecord`). Двух реализаций быть не должно, но это отдельная задача, к этому клиенту не относится. --- ## 6. Порядок действий (предложение) 1. Описать абстракцию хранилища — интерфейсы (`STORAGE.md`, §1). Где лежит — решено: SQLite, абстракция сверху. 2. Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп». 3. При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать. 4. Подписка на живой поток — тоже «с момента».