CACHE.md + требования: кэш сообщений на клиенте, десктоп как эталон для Android
This commit is contained in:
@@ -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. Подписка на живой поток — тоже «с момента».
|
||||
@@ -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).
|
||||
|
||||
## Что сознательно не решается здесь
|
||||
|
||||
|
||||
@@ -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. Как проверяем
|
||||
|
||||
|
||||
Reference in New Issue
Block a user