diff --git a/CACHE.md b/CACHE.md new file mode 100644 index 0000000..18bac8b --- /dev/null +++ b/CACHE.md @@ -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 +``` + +Отбор **строго новее** указанной даты. Проверено на двух хранилищах агента: + +```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. Чего в библиотеке НЕТ + +**Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает. +Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента». + +--- + +## 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. Подписка на живой поток — тоже «с момента». diff --git a/README.md b/README.md index a49d533..c080c3b 100644 --- a/README.md +++ b/README.md @@ -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). ## Что сознательно не решается здесь diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2da14bd..cfdd5ad 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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. Как проверяем