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. Подписка на живой поток — тоже «с момента».
+9
View File
@@ -19,12 +19,18 @@ sketches/004-settings-agents/index.html # настройки агентов
а что не берём (архитектура, экраны, дизайн — своё).
`MIC-ASR-SEARCH.md` — библиотеки для микрофона и распознавания: своё готовое
(`mic-kmp`, `asr-kmp`, `vad-kmp`), вместо копирования кода из assistent.
`CACHE.md` — кэш сообщений на клиенте: что для него есть в библиотеке, схема
загрузки «только новое», где может порваться.
`MARKDOWN-SOURCE.md` — откуда брать готовую отрисовку Markdown (файлы и адреса).
`REQUIREMENTS.md` — требования. Статус: накидываем, ни один пункт не обязателен
к исполнению в том виде, как записан.
## Что решено
- **Сначала десктоп, затем Android — по тем же лекалам.** Десктопный клиент
делаем образцом: понятный, удобный и **читаемый по коду**. Когда получится —
Android повторяет те же решения. Поэтому цель — максимально простой код,
который пользователь открывает и понимает.
- **Основа — вариант 1** (`001-sidebar-utility`). Остальные остаются рядом как
источник идей.
- Стиль — тёмный, из уже принятой темы клиента assistent: фон `#121218`,
@@ -68,6 +74,9 @@ sketches/004-settings-agents/index.html # настройки агентов
3. Различение агентов: одного цвета может оказаться мало.
4. Где хранится список агентов.
5. Нужны ли папки для диалогов, как в мобильном клиенте, или хватит поиска.
6. **Где хранится кэш сообщений** — файл или лёгкая база (см. `CACHE.md`).
7. **Что делать с куском прерванного ответа** в кэше — иначе схема даст сбой
на первом же нажатии «Стоп» (`CACHE.md`, п. 3.2).
## Что сознательно не решается здесь
+67
View File
@@ -154,6 +154,70 @@
- **R29.** Развёрнутый вид — подробности шага: аргументы, результат, время.
- **R30.** Сворачивание и разворачивание — по нажатию на строку.
## 8. Простота кода и кэш сообщений
### 8.1. Цель: клиент должен читаться
- **R31.** **Главная цель — чтобы клиент был максимально прост по коду.** Я должен
открыть исходники и понять, что там происходит. Это важнее, чем сэкономить
строки или вынести лишнее.
- **R31.1.** **Сначала десктоп, затем Android по тем же лекалам.** Десктопный клиент
— образец. Когда он получится удобным и понятным, Android делается по его
решениям. Поэтому простота кода здесь — не пожелание, а условие.
- **R32.** Распознавание голоса, микрофон и детекция речи — **готовые библиотеки**,
свои (`mic-kmp`, `asr-kmp`, `vad-kmp`). Писать заново ничего не надо, и это уже
не «вынос в отдельное место» — библиотеки и есть отдельное место.
- **R33.** Материал найден, разобран: `MIC-ASR-SEARCH.md`. Там же сказано, почему
код микрофона из `ai/assistent` брать **не** надо.
- **R34.** Из `ai/assistent` берём только приёмы отрисовки, плагины и версии —
не архитектуру, не экраны, не дизайн. Разбор: `BORROW-FROM-ASSISTENT.md`.
### 8.2. Кэш сообщений на клиенте — решение принято
**Задача:** не тянуть всю историю диалога заново при каждом открытии окна.
**Схема:**
1. Открыли диалог — смотрим свой кэш.
2. Берём из кэша **последнее сообщение** и его дату.
3. Спрашиваем библиотеку `client`: есть ли сообщения **новее** этой даты.
4. Были — забираем только новые, добавляем в кэш, рисуем.
5. Не были — ничего не делаем.
- **R35.** **Это работает: в библиотеке `client` всё нужное есть.** Функция
`Conversation.getMessages(after, offset, limit)` отдаёт сообщения **строго новее**
указанной даты (проверено: в отборе `created_at > ?`). Пустой ответ = после
нашего сообщения ничего не появилось. Есть и версия потоком, страницами по 100
(`PAGE_SIZE = 100`). Отдельно создавать диалог для проверки не нужно — только
если открываем новый.
- **R36.** **Дубли отсекаются по `id` сообщения.** Идентификатор один и тот же
и в живом потоке, и в истории (`Message.id`). То есть при догрузке не нужно
угадывать, что уже нарисовано — сверяем по идентификатору.
- **R37.** **Самого кэша в библиотеке нет** — это пишем мы. Библиотека даёт только
«спроси, что новее».
**Три места, где схема может порваться (найдено в коде, не предположения):**
- **R38.** **Одинаковые даты.** Отбор строго «новее» (`created_at > ?`), поэтому
если два сообщения получили **ровно одну и ту же дату** — второе в ответ не
попадёт. Порядок внутри одной даты сервер задаёт сам (сортировка по времени,
затем по `id`). Реальный риск невелик (дата с точностью до миллисекунд), но
при сверке по `id` (R36) он не страшен вовсе.
- **R39.** **Прерванный ответ в историю не попадает.** Если ответ прервали кнопкой
«Стоп», кусок ответа остаётся только в живом потоке, в историю он не пишется
(`Event.Interrupted`). В кэше окажется то, чего на сервере нет.
- **R40.** **Живой поток тоже надо просить «с этого момента».** У подписки на события
есть тот же параметр «после»; без него после переподключения пропустим события.
- **R41.** **Решение:** кэш делаем. Схема — из R35. Где хранится (файл или лёгкая
база) — **не решено**, см. раздел 9.
### 8.3. Что не выносим
- **R42.** Экраны, пузырь сообщения, хранение списка агентов — **остаются в клиенте**.
Это и есть клиент, выносить их некуда.
- **R43.** Полезный признак, что что-то не вынесено: **в экране накопилась логика
длиннее пары десятков строк.** Значит, это место жить в экране не должно.
## 9. Решения, которые ещё не приняты
- Запись: по нажатию или на удержание.
@@ -162,6 +226,9 @@
- Нужен ли картинке размер/форма — показывать кружком без изменений или обрезать.
- Где хранится список агентов — в файле на диске или спрашивать сервер.
- Как выглядит показ хода работы агента в свёрнутом виде.
- **Где хранится кэш сообщений** — обычный файл на диске или лёгкая база (R41).
- Сколько держать в кэше и когда чистить (старые диалоги).
- Что делать с куском прерванного ответа в кэше (R39): хранить или выбрасывать.
## 10. Как проверяем