17 KiB
Где что хранится на диске
Вопрос: где живут настройки, кэш истории и список диалогов.
Ответ коротко: хранилище — абстракция. Клиент просит «дай сообщения диалога», «сохрани настройки» — и не знает, где это лежит. Реализация — SQLite. Настройки — JSON, потому что их правит человек.
Абстракция нужна не ради красоты: потом будет Android, и там то же самое хранилище надо будет собрать на другой основе. Если клиент всюду дёргает SQLite напрямую, на Android придётся переписывать экраны. Если дёргает абстракцию — меняется одна реализация, экраны не трогаются.
1. Абстракция: два разных хранилища, не одно
Тут важно не свалить всё в одну кучу. Это два разных типа данных, и ведут себя они по-разному:
| Сообщения диалога | Настройки клиента | |
|---|---|---|
| Сколько | тысячи, растёт постоянно | десяток полей |
| Меняются | только дописываются | переписываются целиком |
| Кто читает | приложение | приложение и человек руками |
| Поиск/выборка | нужны (по диалогу, по дате) | не нужен |
Это две разные абстракции, и живут они раздельно:
/** Сообщения диалогов: дописываем и читаем. Где лежит — не дело клиента. */
interface MessageRepository {
suspend fun append(conversationId: String, message: CachedMessage)
suspend fun read(conversationId: String, after: Instant, limit: Int): List<CachedMessage>
suspend fun latest(conversationId: String): CachedMessage?
suspend fun drop(conversationId: String)
}
/** Настройки: прочитать целиком, записать целиком. */
interface SettingsRepository {
suspend fun load(): Settings
suspend fun save(settings: Settings)
}
И третья, для списка диалогов:
/** Снимок списка диалогов — занавеска, чтобы окно не было пустым при запуске. */
interface ConversationListSnapshotRepository {
suspend fun load(): List<ConversationSummary>
suspend fun save(list: List<ConversationSummary>)
}
Почему три, а не одна «на всё»:
- Сообщения и настройки — разная жизнь. Сообщения дописываются и читаются выборками, настройки переписываются целиком. Общий интерфейс заставит делать вид, что это одно и то же.
- Разные реализации — норма. Сообщения — SQLite. Настройки — JSON-файл. Третье — тоже JSON. Под одним интерфейсом это выглядело бы как насилие.
- Меньше знает — легче менять. Экрану нужны сообщения — он видит только сообщения. Как они лежат, его не касается.
Правило, по которому это проверяется
Ни одного упоминания SQLite вне реализации. Если в экране или в логике
клиента встретилось слово sqlite, SQL, ResultSet, query — абстракция
прохудилась. Это — тот самый признак, как с цветами: цвета числом в коде
не пишем, так и таблиц в экране не пишем.
2. Почему SQLite, а не файлы
Решение: SQLite.
- То же самое будет на Android. SQLite там родной. Одна реализация — два устройства. Ради этого всё и затевается.
- Не надо ничего придумывать. Поиск, порядок, выборка «новее указанной
даты», отсечение дублей по
id— это обычные запросы. С файлами каждое такое место пришлось бы писать руками и потом отлаживать. - Запись не рассыпается. Клиент упал посреди записи — база откатит незавершённую сделку. С дописыванием строки в файл остаётся обрубок.
- В библиотеке уже так. В
agentikхранилище построено на SQLDelight 2.3.2 — это SQLite, умеющий и JVM, и Android. Не изобретаем: берём тот же подход. (storage-sqliteв самомagentik— ровно это.)
Что для этого уже есть
- SQLDelight 2.3.2 — в библиотеке
agentikуже подключён, с драйвером и под JVM, и под Android. - Готовый образец запроса «новее» — в
agentikесть хранилище сообщений на SQLite, где такой запрос уже написан. Повторяем приём, не выдумываем.
Где лежит база
Один файл базы на клиента, в его каталоге:
<каталог клиента>/
client.db SQLite: сообщения, снимок списка диалогов, отметки
«докуда дочитано», раскладка по группам
settings.json настройки — их правит человек, поэтому JSON
blobs/ картинки из сообщений отдельными файлами
Почему картинки не в базе: в сообщении картинка едет как массив байт. Хранить её в базе можно, но база от этого распухает и копировать её становится тяжело. Поэтому картинка — файлом, а в базе только ссылка на файл.
3. Настройки — JSON
Единственное, что лежит не в базе. Потому что их правит человек.
Случилась беда, клиент не запускается из-за кривого адреса агента — открыл
settings.json, увидел, исправил. С базой так не получится: там чтобы
что-то поправить, нужен инструмент.
Там же — тема и выбранная группа: это тоже настройка, а не история переписки.
Секреты в этот файл не пишем. Если у агента будет пароль или ключ, он кладётся в системное хранилище паролей, а в настройках остаётся только ссылка. Иначе ключ утечёт вместе с файлом, который человек может кому-то переслать.
4. Сообщения диалога
Схема загрузки — в CACHE.md. Здесь только про хранение.
Таблица сообщений. Ключ — id сообщения, он стабилен: один и тот же
и в живом потоке, и в истории с сервера. Поэтому дубли отсекаются простой
проверкой, а не гаданием.
Что кладём: id, id диалога, дата, от кого, текст, признаки
(прервано/не закончено), ссылка на картинку из blobs/, если она есть.
Что НЕ кладём: сами байты картинок (см. §2).
Отметка «докуда дочитано» — отдельная мелочь на диалог. Из неё выходит точка «есть новое»: сравнили дату последнего изменения диалога с отметкой — и видно, появилось ли что-то. Цифра непрочитанных так не получится (см. §6), только точка.
Группы — раскладка «какой диалог в какой группе». Сервер про группы ничего не знает (см. §7), значит и это хранится только у нас.
5. Снимок списка диалогов — занавеска
Отдельный случай, и его надо понять правильно.
Список диалогов кэшировать не надо. Он лёгкий — сто строк по имени и дате это несколько килобайт, а не история переписки. И он меняется от чужой работы: пока мы смотрим один диалог, агент уже поработал в другом. Ловить тут прирост сложнее, чем получить пользу.
Но пустое окно при запуске — беда. Пока список едет, смотреть не на что. Поэтому мы держим прошлый снимок списка и показываем его сразу:
- Запустились — нарисовали список из снимка. Мгновенно, окно не пустое.
- Одновременно спросили у сервера свежий список.
- Пришёл — заменили нарисованное целиком.
Это не кэш и никакой сверки прироста не требует. Снимок всегда считается устаревшим, пришёл свежий — взяли его. Если снимка нет (первый запуск) — показываем «загружаю».
Лежит в той же базе — отдельной таблицей. Мелочь вроде бы, но и она идёт через абстракцию: экран просит «дай прошлый список», а не читает таблицу.
6. Что в API не хватает
Проверено по коду библиотеки. Что нужно для того, что нарисовано в макетах.
| Что нужно в списке | Есть? |
|---|---|
| Название диалога | Есть — title |
| Время последнего изменения | Есть — updatedAt, список уже отсортирован по свежести |
| Понять, что диалог удалён или переименован | Есть — события Deleted, Renamed |
| Постраничная выдача | Есть — по 100 штук |
| Последнее сообщение строкой | Нет |
| Число непрочитанных | Нет |
| Событие «в диалоге что-то произошло» | Нет |
| Группа диалога | Нет |
6.1. Превью последнего сообщения — нет
В макете в строке списка под именем стоит текст «Собрал отчёт, жду правок». Взять его неоткуда — в списке диалогов только имя и дата.
- Добавить в библиотеку — правильный путь. В том же хранилище лежат сообщения, превью берётся рядом со списком. Один запрос, как и был.
- Спрашивать по диалогу — сто диалогов, сто запросов. Не годится.
- Убрать из макета — строки станут суше, зато API не трогаем.
6.2. Число непрочитанных — нет
Кружок «2» в макете ничем не наполняется. Два уровня, разной цены:
- Точка «есть новое» — сервер менять не нужно. Есть
updatedAtи наша отметка «докуда дочитано». Есть новое = дата изменения новее отметки. - Число непрочитанных — нужен сервер. Чтобы получить «2», надо знать, сколько сообщений в диалоге всего. Одно поле в ответе списка.
6.3. «В диалоге что-то произошло» — события нет
В потоке агента три события: создан, удалён, переименован. События «пришло
новое сообщение» нет — хотя сервер в этот момент как раз обновляет дату
диалога (в коде это видно: touch(id, now)).
Что это значит: агент работает в другом диалоге, а мы смотрим список — список сам не обновится. Ни порядок не поедет, ни точка не загорится, пока не спросим сервер.
- Обновлять список самому, раз в несколько секунд — без изменения API. Список лёгкий, это честно и дёшево. Плюс обновлять при возвращении окна в фокус.
- Добавить событие — список живёт сам, но опрос всё равно остаётся страховкой от обрыва связи.
Первого достаточно. Второе — приятная добавка на потом.
6.4. Итог: что менять в API
Минимум, закрывающий макеты:
- Превью последнего сообщения в ответе списка диалогов.
- Число непрочитанных в ответе списка диалогов.
Обе — в том же хранилище, рядом с тем, что уже читается для списка. Новых запросов со стороны клиента не появляется.
Не обязательно: событие «в диалоге что-то произошло» — вместо него опрос. Решить отдельно: группы (§7).
7. Группы — сервер про них не знает
Важное. В описании диалога шесть полей: id, title, isTemporal,
createdAt, updatedAt и два про картинки. Поля «группа» нет. Значит,
группы — целиком наша выдумка, и живут они только у нас.
Что из этого следует: телефон и десктоп разойдутся. Создал «Работа» на компьютере — на телефоне её нет.
- Группы только на устройстве. Просто. Раскладка не ездит между устройствами.
- Группы на сервере. Тогда это новое понятие в библиотеке
agentik, а не правка клиента.
Это решение стоит принять до того, как начнём писать клиент.
8. Открытые вопросы
- Группы — на устройстве или на сервере? (§7)
- Меняем API: превью и число непрочитанных? Или убираем их из макета (§6.4).
- Что считать сообщением при подсчёте: только ответы агента или ещё вызовы инструментов, которые в истории тоже лежат записями.
- Бейдж — число или точка? Точка сервер не трогает вообще (§6.2).
- Где именно каталог клиента —
~/.agentik/или системный («Документы пользователя»). На Android понятие «домашний каталог» своё.