275 lines
17 KiB
Markdown
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 понятие «домашний каталог» своё.
|