11 KiB
Кэш сообщений на клиенте
Задача: не тянуть всю историю диалога заново при каждом открытии окна.
Решение: кэш делаем. Ниже — что для этого есть в библиотеке, сама схема и
места, где она может порваться. Всё сверено с исходниками agentik, не по памяти.
1. Схема
- Открыли диалог — смотрим свой кэш.
- Берём из кэша последнее сообщение и его дату.
- Спрашиваем библиотеку: есть ли сообщения новее этой даты.
- Есть — забираем только новые, кладём в кэш, рисуем.
- Нет — ничего не делаем.
Всю историю заново не тянем. Первый раз диалог грузится целиком, дальше — только прирост.
2. Что для этого уже есть в библиотеке client
2.1. Запрос «что новее» — есть
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
Отбор строго новее указанной даты. Проверено на двух хранилищах агента:
-- 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;
// storage-inmemory/.../InMemoryMessageStore.kt
val filtered = all.filter { it.createdAt > after }.sortedBy { it.createdAt }
Пустой ответ = после нашего сообщения ничего не появилось. Ровно то, что нужно: «были сообщения после закэшированного?»
2.2. Версия потоком, страницами — есть
fun getMessages(after: Instant, offset: Int = 0): Flow<Message>
Подгружает по PAGE_SIZE = 100 за раз, пока страницы не кончатся. Для длинных
диалогов — то, что надо.
2.3. Стабильный id сообщения — есть
sealed interface Message { val id: String; val date: Instant }
В комментарии к коду прямо сказано: id стабилен между живым потоком и историей —
id из Event.ToolCall равен id соответствующего Message.ToolCall в истории
после завершения хода.
Зачем это нам: при догрузке не надо угадывать, что уже нарисовано. Сверяем
по id — дубли отсекаются точно.
2.4. Живой поток «с этого момента» — есть
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. Порядок действий (предложение)
- Описать абстракцию хранилища — интерфейсы (
STORAGE.md, §1). Где лежит — решено: SQLite, абстракция сверху. - Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп».
- При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать.
- Подписка на живой поток — тоже «с момента».