CACHE.md + требования: кэш сообщений на клиенте, десктоп как эталон для Android

This commit is contained in:
Porfiry
2026-09-19 04:45:43 +03:00
parent 57e7fe1d17
commit d5f4c67779
3 changed files with 251 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Кэш сообщений на клиенте
**Задача:** не тянуть всю историю диалога заново при каждом открытии окна.
**Решение: кэш делаем.** Ниже — что для этого есть в библиотеке, сама схема и
места, где она может порваться. Всё сверено с исходниками `agentik`, не по памяти.
---
## 1. Схема
1. Открыли диалог — смотрим **свой** кэш.
2. Берём из кэша **последнее сообщение** и его дату.
3. Спрашиваем библиотеку: есть ли сообщения **новее** этой даты.
4. Есть — забираем только новые, кладём в кэш, рисуем.
5. Нет — ничего не делаем.
Всю историю заново не тянем. Первый раз диалог грузится целиком, дальше — только
прирост.
---
## 2. Что для этого уже есть в библиотеке `client`
### 2.1. Запрос «что новее» — есть
```kotlin
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
```
Отбор **строго новее** указанной даты. Проверено на двух хранилищах агента:
```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<Message>
```
Подгружает по `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<Event>
```
Подписка не повторяет прошедшее: «Не реплеит события, произошедшие до `after`».
Если `after` = момент последнего виденного события, поток продолжается с того
места.
### 2.5. Чего в библиотеке НЕТ
**Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает.
Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента».
---
## 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. Что ещё нужно решить (не решено)
- **Где хранить кэш** — обычный файл на диске или лёгкая база.
- **Сколько держать** и когда чистить старые диалоги.
- **Что делать с прерванным ответом** в кэше (см. 3.2).
- Связывать ли кэш с диалогом по `id` диалога — он стабилен, так что связка простая.
---
## 5. Что осталось от обсуждения простоты кода
### Принято
Главная цель — **клиент должен читаться**. Пользователь должен открыть исходники
и понять, что происходит. Это важнее экономии строк.
Голос (микрофон, распознавание, детекция речи) — **берём готовыми библиотеками**:
`mic-kmp`, `asr-kmp`, `vad-kmp`. Разбор — `MIC-ASR-SEARCH.md`. Писать заново
нечего.
### Отвергнуто
- **«Вынести голос в отдельный модуль-обёртку над тремя библиотеками».** Отвергнуто:
библиотеки и так уже отдельное место, обёртка добавит лишний слой без пользы.
- **«Вынести несколько агентов в отдельный реестр подключений».** Отвергнуто:
это и есть работа клиента, выносить некуда.
### Остаётся в клиенте
Экраны, пузырь сообщения, хранение списка агентов. **Полезный признак**, что
что-то не вынесено: в экране накопилась логика длиннее пары десятков строк.
### Отдельная задача, не сейчас
`mic-kmp` — одна реализация микрофона уже есть, но очки `view-mate` её не
используют: у них свой кусок на Android (`AudioRecord`). Двух реализаций быть
не должно, но это отдельная задача, к этому клиенту не относится.
---
## 6. Порядок действий (предложение)
1. Определиться, где хранится кэш (раздел 4).
2. Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп».
3. При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать.
4. Подписка на живой поток — тоже «с момента».