Files

11 KiB

Кэш сообщений на клиенте

Задача: не тянуть всю историю диалога заново при каждом открытии окна.

Решение: кэш делаем. Ниже — что для этого есть в библиотеке, сама схема и места, где она может порваться. Всё сверено с исходниками agentik, не по памяти.


1. Схема

  1. Открыли диалог — смотрим свой кэш.
  2. Берём из кэша последнее сообщение и его дату.
  3. Спрашиваем библиотеку: есть ли сообщения новее этой даты.
  4. Есть — забираем только новые, кладём в кэш, рисуем.
  5. Нет — ничего не делаем.

Всю историю заново не тянем. Первый раз диалог грузится целиком, дальше — только прирост.


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. Порядок действий (предложение)

  1. Описать абстракцию хранилища — интерфейсы (STORAGE.md, §1). Где лежит — решено: SQLite, абстракция сверху.
  2. Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп».
  3. При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать.
  4. Подписка на живой поток — тоже «с момента».