Files
agentik-desktop/CACHE.md
T

194 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Кэш сообщений на клиенте
**Задача:** не тянуть всю историю диалога заново при каждом открытии окна.
**Решение: кэш делаем.** Ниже — что для этого есть в библиотеке, сама схема и
места, где она может порваться. Всё сверено с исходниками `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. Чего в библиотеке НЕТ
**Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает.
Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента».
Заодно **нет и абстракции хранилища** — ни интерфейса «дай сообщения диалога»,
ни готовой реализации под клиента. И то и другое наше, см. `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. Подписка на живой поток — тоже «с момента».