Files
agentik-desktop/STORAGE.md
T

275 lines
17 KiB
Markdown

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