# Где что хранится на диске **Вопрос:** где живут настройки, кэш истории и список диалогов. **Ответ коротко:** хранилище — **абстракция**. Клиент просит «дай сообщения диалога», «сохрани настройки» — и не знает, где это лежит. Реализация — **SQLite**. Настройки — **JSON**, потому что их правит человек. Абстракция нужна не ради красоты: **потом будет Android**, и там то же самое хранилище надо будет собрать на другой основе. Если клиент всюду дёргает SQLite напрямую, на Android придётся переписывать экраны. Если дёргает абстракцию — меняется одна реализация, экраны не трогаются. --- ## 1. Абстракция: два разных хранилища, не одно Тут важно не свалить всё в одну кучу. Это **два разных типа данных**, и ведут себя они по-разному: | | Сообщения диалога | Настройки клиента | |---|---|---| | Сколько | тысячи, растёт постоянно | десяток полей | | Меняются | только дописываются | переписываются целиком | | Кто читает | приложение | приложение и **человек руками** | | Поиск/выборка | нужны (по диалогу, по дате) | не нужен | Это **две разные абстракции**, и живут они раздельно: ```kotlin /** Сообщения диалогов: дописываем и читаем. Где лежит — не дело клиента. */ interface MessageRepository { suspend fun append(conversationId: String, message: CachedMessage) suspend fun read(conversationId: String, after: Instant, limit: Int): List suspend fun latest(conversationId: String): CachedMessage? suspend fun drop(conversationId: String) } /** Настройки: прочитать целиком, записать целиком. */ interface SettingsRepository { suspend fun load(): Settings suspend fun save(settings: Settings) } ``` И **третья**, для списка диалогов: ```kotlin /** Снимок списка диалогов — занавеска, чтобы окно не было пустым при запуске. */ interface ConversationListSnapshotRepository { suspend fun load(): List suspend fun save(list: List) } ``` Почему три, а не одна «на всё»: - **Сообщения и настройки — разная жизнь.** Сообщения дописываются и читаются выборками, настройки переписываются целиком. Общий интерфейс заставит делать вид, что это одно и то же. - **Разные реализации — норма.** Сообщения — 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 понятие «домашний каталог» своё.