Files

17 KiB
Raw Permalink Blame History

Где что хранится на диске

Вопрос: где живут настройки, кэш истории и список диалогов.

Ответ коротко: хранилище — абстракция. Клиент просит «дай сообщения диалога», «сохрани настройки» — и не знает, где это лежит. Реализация — 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. Снимок списка диалогов — занавеска

Отдельный случай, и его надо понять правильно.

Список диалогов кэшировать не надо. Он лёгкий — сто строк по имени и дате это несколько килобайт, а не история переписки. И он меняется от чужой работы: пока мы смотрим один диалог, агент уже поработал в другом. Ловить тут прирост сложнее, чем получить пользу.

Но пустое окно при запуске — беда. Пока список едет, смотреть не на что. Поэтому мы держим прошлый снимок списка и показываем его сразу:

  1. Запустились — нарисовали список из снимка. Мгновенно, окно не пустое.
  2. Одновременно спросили у сервера свежий список.
  3. Пришёл — заменили нарисованное целиком.

Это не кэш и никакой сверки прироста не требует. Снимок всегда считается устаревшим, пришёл свежий — взяли его. Если снимка нет (первый запуск) — показываем «загружаю».

Лежит в той же базе — отдельной таблицей. Мелочь вроде бы, но и она идёт через абстракцию: экран просит «дай прошлый список», а не читает таблицу.


6. Что в API не хватает

Проверено по коду библиотеки. Что нужно для того, что нарисовано в макетах.

Что нужно в списке Есть?
Название диалога Есть — title
Время последнего изменения Есть — updatedAt, список уже отсортирован по свежести
Понять, что диалог удалён или переименован Есть — события Deleted, Renamed
Постраничная выдача Есть — по 100 штук
Последнее сообщение строкой Нет
Число непрочитанных Нет
Событие «в диалоге что-то произошло» Нет
Группа диалога Нет

6.1. Превью последнего сообщения — нет

В макете в строке списка под именем стоит текст «Собрал отчёт, жду правок». Взять его неоткуда — в списке диалогов только имя и дата.

  • Добавить в библиотеку — правильный путь. В том же хранилище лежат сообщения, превью берётся рядом со списком. Один запрос, как и был.
  • Спрашивать по диалогу — сто диалогов, сто запросов. Не годится.
  • Убрать из макета — строки станут суше, зато API не трогаем.

6.2. Число непрочитанных — нет

Кружок «2» в макете ничем не наполняется. Два уровня, разной цены:

  • Точка «есть новое» — сервер менять не нужно. Есть updatedAt и наша отметка «докуда дочитано». Есть новое = дата изменения новее отметки.
  • Число непрочитанных — нужен сервер. Чтобы получить «2», надо знать, сколько сообщений в диалоге всего. Одно поле в ответе списка.

6.3. «В диалоге что-то произошло» — события нет

В потоке агента три события: создан, удалён, переименован. События «пришло новое сообщение» нет — хотя сервер в этот момент как раз обновляет дату диалога (в коде это видно: touch(id, now)).

Что это значит: агент работает в другом диалоге, а мы смотрим список — список сам не обновится. Ни порядок не поедет, ни точка не загорится, пока не спросим сервер.

  • Обновлять список самому, раз в несколько секунд — без изменения API. Список лёгкий, это честно и дёшево. Плюс обновлять при возвращении окна в фокус.
  • Добавить событие — список живёт сам, но опрос всё равно остаётся страховкой от обрыва связи.

Первого достаточно. Второе — приятная добавка на потом.

6.4. Итог: что менять в API

Минимум, закрывающий макеты:

  1. Превью последнего сообщения в ответе списка диалогов.
  2. Число непрочитанных в ответе списка диалогов.

Обе — в том же хранилище, рядом с тем, что уже читается для списка. Новых запросов со стороны клиента не появляется.

Не обязательно: событие «в диалоге что-то произошло» — вместо него опрос. Решить отдельно: группы (§7).


7. Группы — сервер про них не знает

Важное. В описании диалога шесть полей: id, title, isTemporal, createdAt, updatedAt и два про картинки. Поля «группа» нет. Значит, группы — целиком наша выдумка, и живут они только у нас.

Что из этого следует: телефон и десктоп разойдутся. Создал «Работа» на компьютере — на телефоне её нет.

  1. Группы только на устройстве. Просто. Раскладка не ездит между устройствами.
  2. Группы на сервере. Тогда это новое понятие в библиотеке agentik, а не правка клиента.

Это решение стоит принять до того, как начнём писать клиент.


8. Открытые вопросы

  1. Группы — на устройстве или на сервере? (§7)
  2. Меняем API: превью и число непрочитанных? Или убираем их из макета (§6.4).
  3. Что считать сообщением при подсчёте: только ответы агента или ещё вызовы инструментов, которые в истории тоже лежат записями.
  4. Бейдж — число или точка? Точка сервер не трогает вообще (§6.2).
  5. Где именно каталог клиента — ~/.agentik/ или системный («Документы пользователя»). На Android понятие «домашний каталог» своё.