STORAGE.md переписан: хранилище — абстракция, реализация SQLite, настройки JSON; не файлы
This commit is contained in:
@@ -84,6 +84,12 @@ fun events(after: Instant): Flow<Event>
|
|||||||
**Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает.
|
**Самого кэша.** Хранилища на стороне клиента библиотека не даёт и не предлагает.
|
||||||
Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента».
|
Это пишем мы. Библиотека умеет только «спроси, что новее» и «подпишись с момента».
|
||||||
|
|
||||||
|
Заодно **нет и абстракции хранилища** — ни интерфейса «дай сообщения диалога»,
|
||||||
|
ни готовой реализации под клиента. И то и другое наше, см. `STORAGE.md`.
|
||||||
|
|
||||||
|
Полезное: в самом `agentik` хранилище построено на **SQLDelight 2.3.2** — это
|
||||||
|
SQLite, умеющий и JVM, и Android. Тот же подход берём и мы, а не выдумываем.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Где схема может порваться
|
## 3. Где схема может порваться
|
||||||
@@ -127,9 +133,20 @@ fun events(after: Instant): Flow<Event>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Что ещё нужно решить (не решено)
|
## 4. Где хранить — РЕШЕНО
|
||||||
|
|
||||||
|
**Хранилище — абстракция, реализация — SQLite.** Подробно: `STORAGE.md`.
|
||||||
|
|
||||||
|
Клиент просит «дай сообщения диалога» и не знает, где они лежат. Реализацию
|
||||||
|
можно будет заменить (это понадобится на Android) — экраны не тронутся.
|
||||||
|
Ни одного упоминания SQLite вне реализации: если в экране встретилось
|
||||||
|
`sqlite` / `SQL` / `query` — абстракция прохудилась.
|
||||||
|
|
||||||
|
База — один файл в каталоге клиента. Настройки — **отдельно, JSON**:
|
||||||
|
их правит человек руками, в базу для этого лазить не должно быть нужно.
|
||||||
|
|
||||||
|
Осталось решить:
|
||||||
|
|
||||||
- **Где хранить кэш** — обычный файл на диске или лёгкая база.
|
|
||||||
- **Сколько держать** и когда чистить старые диалоги.
|
- **Сколько держать** и когда чистить старые диалоги.
|
||||||
- **Что делать с прерванным ответом** в кэше (см. 3.2).
|
- **Что делать с прерванным ответом** в кэше (см. 3.2).
|
||||||
- Связывать ли кэш с диалогом по `id` диалога — он стабилен, так что связка простая.
|
- Связывать ли кэш с диалогом по `id` диалога — он стабилен, так что связка простая.
|
||||||
@@ -169,7 +186,8 @@ fun events(after: Instant): Flow<Event>
|
|||||||
|
|
||||||
## 6. Порядок действий (предложение)
|
## 6. Порядок действий (предложение)
|
||||||
|
|
||||||
1. Определиться, где хранится кэш (раздел 4).
|
1. Описать абстракцию хранилища — интерфейсы (`STORAGE.md`, §1). Где лежит —
|
||||||
|
решено: SQLite, абстракция сверху.
|
||||||
2. Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп».
|
2. Определиться с прерванным ответом (3.2) — иначе схема даст сбой на первом же «Стоп».
|
||||||
3. При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать.
|
3. При открытии диалога: кэш → спросить «новее» → догрузить → нарисовать.
|
||||||
4. Подписка на живой поток — тоже «с момента».
|
4. Подписка на живой поток — тоже «с момента».
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ sketches/005-new-chat-picker/index.html # черновик модалки
|
|||||||
а что не берём (архитектура, экраны, дизайн — своё).
|
а что не берём (архитектура, экраны, дизайн — своё).
|
||||||
`MIC-ASR-SEARCH.md` — библиотеки для микрофона и распознавания: своё готовое
|
`MIC-ASR-SEARCH.md` — библиотеки для микрофона и распознавания: своё готовое
|
||||||
(`mic-kmp`, `asr-kmp`, `vad-kmp`), вместо копирования кода из assistent.
|
(`mic-kmp`, `asr-kmp`, `vad-kmp`), вместо копирования кода из assistent.
|
||||||
`STORAGE.md` — где что хранится: настройки, группы, кэш истории, список
|
`STORAGE.md` — где что хранится: **хранилище делаем абстракцией, реализация —
|
||||||
диалогов; почему файлы, а не база; что менять в API (превью и число непрочитанных).
|
SQLite, настройки — JSON.** Три отдельные абстракции (сообщения / настройки /
|
||||||
|
снимок списка), почему SQLite, что менять в API (превью и число непрочитанных).
|
||||||
`CACHE.md` — кэш сообщений на клиенте: что для него есть в библиотеке, схема
|
`CACHE.md` — кэш сообщений на клиенте: что для него есть в библиотеке, схема
|
||||||
загрузки «только новое», где может порваться.
|
загрузки «только новое», где может порваться.
|
||||||
`THEMES.md` — цветовые темы: цвета не пишем в коде, берём из схемы. Тем будет
|
`THEMES.md` — цветовые темы: цвета не пишем в коде, берём из схемы. Тем будет
|
||||||
|
|||||||
+28
-4
@@ -267,8 +267,25 @@
|
|||||||
- **R40.** **Живой поток тоже надо просить «с этого момента».** У подписки на события
|
- **R40.** **Живой поток тоже надо просить «с этого момента».** У подписки на события
|
||||||
есть тот же параметр «после»; без него после переподключения пропустим события.
|
есть тот же параметр «после»; без него после переподключения пропустим события.
|
||||||
|
|
||||||
- **R41.** **Решение:** кэш делаем. Схема — из R35. Где хранится (файл или лёгкая
|
- **R41.** **Решение:** кэш делаем. Схема — из R35.
|
||||||
база) — **не решено**, см. раздел 9.
|
- **R41.1.** **Хранилище — абстракция.** Клиент просит «дай сообщения диалога»,
|
||||||
|
«сохрани настройки» — и не знает, где это лежит. Реализацию можно заменить
|
||||||
|
(понадобится на Android) — экраны не тронутся. Подробно: `STORAGE.md`.
|
||||||
|
- **R41.2.** **Ни одного упоминания базы вне реализации.** Если в экране или
|
||||||
|
в логике клиента встретилось `sqlite` / `SQL` / `query` — абстракция
|
||||||
|
прохудилась. Тот же признак, что и с цветами числом в коде.
|
||||||
|
- **R41.3.** **Реализация — SQLite**, тот же подход, что в `agentik`:
|
||||||
|
**SQLDelight 2.3.2** (умеет и JVM, и Android). **Не файлы.**
|
||||||
|
- **R41.4.** **Три отдельные абстракции, не одна на всё:** сообщения диалога;
|
||||||
|
настройки; снимок списка диалогов. Разная жизнь — разными интерфейсами.
|
||||||
|
- **R41.5.** **Настройки — JSON-файл, не в базе.** Их правит человек руками:
|
||||||
|
сломался адрес агента — открыл, увидел, исправил. С базой для этого нужен
|
||||||
|
инструмент. Там же тема и выбранная группа.
|
||||||
|
- **R41.6.** **Секреты в JSON не пишем.** Пароль/ключ — в системное хранилище
|
||||||
|
паролей, в настройках только ссылка.
|
||||||
|
- **R41.7.** **Картинки — файлами, не в базе.** В сообщении картинка едет
|
||||||
|
массивом байт; держать её в базе нельзя — база распухнет и станет тяжёлой
|
||||||
|
для копирования. В базе — ссылка на файл.
|
||||||
|
|
||||||
### 8.3. Что не выносим
|
### 8.3. Что не выносим
|
||||||
|
|
||||||
@@ -283,11 +300,18 @@
|
|||||||
- Нужны ли вложения (картинки) в этом клиенте.
|
- Нужны ли вложения (картинки) в этом клиенте.
|
||||||
- Как именно различать агентов (см. R21–R22): цвета или картинки достаточно?
|
- Как именно различать агентов (см. R21–R22): цвета или картинки достаточно?
|
||||||
- Нужен ли картинке размер/форма — показывать кружком без изменений или обрезать.
|
- Нужен ли картинке размер/форма — показывать кружком без изменений или обрезать.
|
||||||
- Где хранится список агентов — в файле на диске или спрашивать сервер.
|
- Где хранится список агентов — в контроле настроек (JSON) или спрашивать сервер.
|
||||||
- Как выглядит показ хода работы агента в свёрнутом виде.
|
- Как выглядит показ хода работы агента в свёрнутом виде.
|
||||||
- **Где хранится кэш сообщений** — обычный файл на диске или лёгкая база (R41).
|
|
||||||
- Сколько держать в кэше и когда чистить (старые диалоги).
|
- Сколько держать в кэше и когда чистить (старые диалоги).
|
||||||
- Что делать с куском прерванного ответа в кэше (R39): хранить или выбрасывать.
|
- Что делать с куском прерванного ответа в кэше (R39): хранить или выбрасывать.
|
||||||
|
- **Группы — только на устройстве или на сервере?** Сервер про них не знает
|
||||||
|
(`STORAGE.md`, §7). Если только у нас — раскладка не поедет между десктопом
|
||||||
|
и телефоном.
|
||||||
|
- **Меняем ли API: превью последнего сообщения и число непрочитанных.**
|
||||||
|
Взять неоткуда, в макете они нарисованы (`STORAGE.md`, §6).
|
||||||
|
- **Бейдж — число или точка.** Точка сервер не трогает вообще (`STORAGE.md`, §6.2).
|
||||||
|
- **Где каталог клиента** — `~/.agentik/` или системный («Документы»).
|
||||||
|
На Android понятие «домашний каталог» своё.
|
||||||
|
|
||||||
## 10. Как проверяем
|
## 10. Как проверяем
|
||||||
|
|
||||||
|
|||||||
+218
-194
@@ -2,249 +2,273 @@
|
|||||||
|
|
||||||
**Вопрос:** где живут настройки, кэш истории и список диалогов.
|
**Вопрос:** где живут настройки, кэш истории и список диалогов.
|
||||||
|
|
||||||
**Ответ коротко:** всё лежит в одном каталоге клиента. Никакой базы не нужно —
|
**Ответ коротко:** хранилище — **абстракция**. Клиент просит «дай сообщения
|
||||||
история и есть поток строк, а список диалогов вообще кэшировать не надо так,
|
диалога», «сохрани настройки» — и не знает, где это лежит. Реализация —
|
||||||
как историю. Ниже — почему, и что для этого уже есть в библиотеке.
|
**SQLite**. Настройки — **JSON**, потому что их правит человек.
|
||||||
|
|
||||||
Всё сверено с исходниками `agentik`, не по памяти.
|
Абстракция нужна не ради красоты: **потом будет Android**, и там то же самое
|
||||||
|
хранилище надо будет собрать на другой основе. Если клиент всюду дёргает SQLite
|
||||||
|
напрямую, на Android придётся переписывать экраны. Если дёргает абстракцию —
|
||||||
|
меняется одна реализация, экраны не трогаются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Карта: что где лежит
|
## 1. Абстракция: два разных хранилища, не одно
|
||||||
|
|
||||||
```
|
Тут важно не свалить всё в одну кучу. Это **два разных типа данных**, и ведут
|
||||||
~/.agentik/ каталог клиента (на Windows — соответствующий системный)
|
себя они по-разному:
|
||||||
settings.json настройки: агенты, адреса, тема, выбранная группа
|
|
||||||
groups.json группы и в какой группе какой диалог
|
|
||||||
dialogs.json снимок списка диалогов — чтобы окно не было пустым
|
|
||||||
read.json докуда дочитан каждый диалог (для счётчиков)
|
|
||||||
history/
|
|
||||||
<id диалога>.jsonl сообщения: одна строка — одно сообщение
|
|
||||||
blobs/
|
|
||||||
<id сообщения> картинки из сообщений, отдельными файлами
|
|
||||||
```
|
|
||||||
|
|
||||||
Один каталог — простое правило: **удалил каталог, клиент чистый.** Ничего не
|
| | Сообщения диалога | Настройки клиента |
|
||||||
прячется в других местах.
|
|
||||||
|
|
||||||
### Почему файлы, а не база
|
|
||||||
|
|
||||||
История диалога — **только дописывается**: сообщений не правят и не удаляют
|
|
||||||
(в библиотеке так и написано: «Только `insert` и чтение. Никаких обновлений»).
|
|
||||||
Для дописываемого потока база не нужна:
|
|
||||||
|
|
||||||
- **Строка на сообщение.** Читаем конец файла — знаем последнее сообщение.
|
|
||||||
Дописываем в конец — вот и весь кэш.
|
|
||||||
- **Понятно человеку.** Открыл файл — увидел сообщения. В случае беды можно
|
|
||||||
посмотреть глазами и починить руками.
|
|
||||||
- **Нет лишней зависимости.** База — это драйвер, версии, миграции схемы.
|
|
||||||
|
|
||||||
Плюс это прямо соответствует тому, ради чего мы вообще выбрали десктоп
|
|
||||||
эталоном: код, который **можно прочитать и понять**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Настройки — один файл
|
|
||||||
|
|
||||||
Пара агентов, адреса, тема, выбранная группа. Это десяток полей —
|
|
||||||
им не нужна база и не нужен отдельный файл на каждую настройку.
|
|
||||||
|
|
||||||
Важно: **настройки человек правит руками.** Если клиент однажды не запустится из-за
|
|
||||||
кривого адреса, должно быть можно открыть `settings.json`, увидеть и исправить.
|
|
||||||
Поэтому формат — обычный читаемый, а не сжатый.
|
|
||||||
|
|
||||||
Отдельно: **секреты не в этом файле.** Если у агента будет пароль/ключ,
|
|
||||||
он кладётся в системное хранилище паролей, а в настройках остаётся только
|
|
||||||
ссылка на него. Иначе ключ утечёт вместе с настройками, которые человек
|
|
||||||
может кому-то переслать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Группы — они существуют только у нас
|
|
||||||
|
|
||||||
**Это важное. Сервер про группы ничего не знает.**
|
|
||||||
|
|
||||||
В библиотеке диалог описывается шестью полями: `id`, `title`, `isTemporal`,
|
|
||||||
`createdAt`, `updatedAt` и два признака про картинки. **Поля «группа» там нет.**
|
|
||||||
Значит, группы — целиком наша выдумка на стороне клиента, и жить они будут
|
|
||||||
в `groups.json`.
|
|
||||||
|
|
||||||
**Что из этого следует, и это надо решить:** телефон и десктоп **разойдутся**.
|
|
||||||
Создал группу «Работа» на десктопе — на телефоне её нет. Отсюда два пути:
|
|
||||||
|
|
||||||
1. **Группы только на устройстве.** Просто, но раскладка не ездит между
|
|
||||||
устройствами. Для одного человека за одним компьютером — нормально.
|
|
||||||
2. **Группы на сервере.** Тогда нужно добавить в библиотеку хранение групп —
|
|
||||||
это уже не «просто клиент», это расширение самого agentik.
|
|
||||||
|
|
||||||
Пока в макетах нарисован путь 1. Если хотим 2 — это отдельное решение,
|
|
||||||
и его лучше принять **до** того, как начнём писать клиент.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Кэш истории — файл на диалог
|
|
||||||
|
|
||||||
Схема целиком описана в `CACHE.md`. Здесь — только где лежит.
|
|
||||||
|
|
||||||
**Файл на диалог, одна строка на сообщение.** Причина простая: диалоги друг
|
|
||||||
с другом не связаны. Открыли один — читаем один файл, чужого не касаемся.
|
|
||||||
Заодно один битый файл не портит остальные.
|
|
||||||
|
|
||||||
**Как читаем:** храним в памяти, на чём остановились (последняя прочитанная
|
|
||||||
строка). При открытии диалога читаем конец файла — это последнее сообщение,
|
|
||||||
с него и спрашиваем сервер «что новее». Весь файл при каждом открытии не
|
|
||||||
перечитываем.
|
|
||||||
|
|
||||||
**Картинки — отдельно.** В сообщении картинка едет как массив байт. Держать
|
|
||||||
её внутри строки истории нельзя: файл распухнет, и станет нечитаемым.
|
|
||||||
Поэтому картинка кладётся в `blobs/<id сообщения>`, а в истории остаётся
|
|
||||||
ссылка на неё.
|
|
||||||
|
|
||||||
### Три риска, честно
|
|
||||||
|
|
||||||
1. **Обрыв записи.** Клиент упал посреди записи строки — в конце файла
|
|
||||||
остался обрубок. Тогда: обрубок отбрасываем, спрашиваем сервер заново
|
|
||||||
по последней целой строке. Так как сервер — источник правды, потеря
|
|
||||||
не страшна.
|
|
||||||
2. **Очень длинный диалог.** Файл растёт. Пока спасает то, что читаем конец,
|
|
||||||
а не весь файл. Если однажды станет тяжело — режем историю на части
|
|
||||||
по месяцам, но это потом, не сейчас.
|
|
||||||
3. **Файлов много.** Да, на каждый диалог свой файл. Но файлы мелкие,
|
|
||||||
а система умеет держать миллионы файлов. Проблемой это станет на порядки
|
|
||||||
позже, чем что-то другое.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Список диалогов — кэшировать не надо (в главном)
|
|
||||||
|
|
||||||
Вот тут отличие от истории, и оно важное.
|
|
||||||
|
|
||||||
| | История диалога | Список диалогов |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Сколько данных | много сообщений, каждое с текстом | сто строк, в каждой имя и дата |
|
| Сколько | тысячи, растёт постоянно | десяток полей |
|
||||||
| Размер одной порции | сотни килобайт | несколько килобайт |
|
| Меняются | только дописываются | переписываются целиком |
|
||||||
| Растёт | на каждом ответе агента | медленно |
|
| Кто читает | приложение | приложение и **человек руками** |
|
||||||
|
| Поиск/выборка | нужны (по диалогу, по дате) | не нужен |
|
||||||
|
|
||||||
**Вывод: список диалогов надо честно спрашивать у сервера.** Причины:
|
Это **две разные абстракции**, и живут они раздельно:
|
||||||
|
|
||||||
- **Он лёгкий.** Сто диалогов — это несколько килобайт. Одна быстрая просьба,
|
```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?
|
||||||
берёт по 100 штук и подгружает следующие страницы по мере надобности.
|
suspend fun drop(conversationId: String)
|
||||||
Отдельно ничего придумывать не надо.
|
}
|
||||||
|
|
||||||
### Но пустое окно при запуске — реальная беда
|
/** Настройки: прочитать целиком, записать целиком. */
|
||||||
|
interface SettingsRepository {
|
||||||
|
suspend fun load(): Settings
|
||||||
|
suspend fun save(settings: Settings)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Проблема не в размере списка, а в том, что **пока он едет, окно пустое.**
|
И **третья**, для списка диалогов:
|
||||||
Поэтому в карте выше и лежит `dialogs.json` — **снимок** списка с прошлого раза.
|
|
||||||
|
|
||||||
Схема такая:
|
```kotlin
|
||||||
|
/** Снимок списка диалогов — занавеска, чтобы окно не было пустым при запуске. */
|
||||||
|
interface ConversationListSnapshotRepository {
|
||||||
|
suspend fun load(): List<ConversationSummary>
|
||||||
|
suspend fun save(list: List<ConversationSummary>)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
1. Запустились — рисуем список из снимка. Мгновенно, окно не пустое.
|
Почему три, а не одна «на всё»:
|
||||||
2. Одновременно спрашиваем сервер свежий список.
|
|
||||||
3. Пришёл — заменяем нарисованное целиком.
|
|
||||||
|
|
||||||
Это **не кэш**, а занавеска, чтобы не смотреть в пустоту. Никакой сверки
|
- **Сообщения и настройки — разная жизнь.** Сообщения дописываются и читаются
|
||||||
прироста, никаких сложных правил: пришёл свежий список — взяли его целиком.
|
выборками, настройки переписываются целиком. Общий интерфейс заставит делать
|
||||||
Снимок всегда считается устаревшим.
|
вид, что это одно и то же.
|
||||||
|
- **Разные реализации — норма.** Сообщения — 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 не хватает
|
## 6. Что в API не хватает
|
||||||
|
|
||||||
Проверено по коду библиотеки. Вот что есть и чего нет для того, что
|
Проверено по коду библиотеки. Что нужно для того, что нарисовано в макетах.
|
||||||
нарисовано в макетах.
|
|
||||||
|
|
||||||
| Что нужно в списке | Есть в библиотеке? |
|
| Что нужно в списке | Есть? |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Название диалога | **Есть** — `title` |
|
| Название диалога | **Есть** — `title` |
|
||||||
| Время последнего изменения | **Есть** — `updatedAt`, список уже отсортирован по свежести |
|
| Время последнего изменения | **Есть** — `updatedAt`, список уже отсортирован по свежести |
|
||||||
| Понять, что диалог удалён или переименован | **Есть** — события `Deleted`, `Renamed` |
|
| Понять, что диалог удалён или переименован | **Есть** — события `Deleted`, `Renamed` |
|
||||||
| Постраничная выдача | **Есть** — по 100 штук |
|
| Постраничная выдача | **Есть** — по 100 штук |
|
||||||
| **Последнее сообщение строкой** | **Нет!** |
|
| **Последнее сообщение строкой** | **Нет** |
|
||||||
| **Сколько непрочитанных** | **Нет!** |
|
| **Число непрочитанных** | **Нет** |
|
||||||
| **Событие «в диалоге что-то произошло»** | **Нет!** |
|
| **Событие «в диалоге что-то произошло»** | **Нет** |
|
||||||
| **Группа диалога** | **Нет!** |
|
| **Группа диалога** | **Нет** |
|
||||||
|
|
||||||
Три дырки разберём отдельно — они разные по последствиям.
|
|
||||||
|
|
||||||
### 6.1. Превью последнего сообщения — нет
|
### 6.1. Превью последнего сообщения — нет
|
||||||
|
|
||||||
В макете в каждой строке под именем стоит текст: «Собрал отчёт, жду правок».
|
В макете в строке списка под именем стоит текст «Собрал отчёт, жду правок».
|
||||||
**Взять его неоткуда.** В списке диалогов только имя и дата.
|
**Взять его неоткуда** — в списке диалогов только имя и дата.
|
||||||
|
|
||||||
Варианты:
|
- **Добавить в библиотеку — правильный путь.** В том же хранилище лежат
|
||||||
|
сообщения, превью берётся рядом со списком. Один запрос, как и был.
|
||||||
|
- Спрашивать по диалогу — сто диалогов, сто запросов. Не годится.
|
||||||
|
- Убрать из макета — строки станут суше, зато API не трогаем.
|
||||||
|
|
||||||
- **Добавить в библиотеку.** В том же хранилище лежат сообщения — превью
|
### 6.2. Число непрочитанных — нет
|
||||||
берётся одним запросом рядом со списком. Это правильный путь: один запрос
|
|
||||||
за списком, как и было.
|
|
||||||
- **Спрашивать по диалогу.** Сто диалогов — сто запросов. Не годится.
|
|
||||||
- **Убрать из макета.** Строки станут суше, зато ничего не меняем.
|
|
||||||
|
|
||||||
### 6.2. Счётчик непрочитанных — нет
|
Кружок «2» в макете ничем не наполняется. Два уровня, разной цены:
|
||||||
|
|
||||||
В макете справа в строке стоит «2». **Числа взять неоткуда.**
|
- **Точка «есть новое»** — сервер менять **не нужно**. Есть `updatedAt`
|
||||||
|
и наша отметка «докуда дочитано». Есть новое = дата изменения новее отметки.
|
||||||
Тут два уровня, и они разной цены:
|
|
||||||
|
|
||||||
- **Точка «есть новое»** — сервер менять **не нужно**. У нас уже есть
|
|
||||||
`updatedAt` (когда диалог последний раз менялся) и свой `read.json`
|
|
||||||
(докуда дочитали). Есть новое = `updatedAt` новее нашей отметки.
|
|
||||||
- **Число непрочитанных** — **нужен сервер.** Чтобы получить «2», надо знать,
|
- **Число непрочитанных** — **нужен сервер.** Чтобы получить «2», надо знать,
|
||||||
сколько сообщений в диалоге всего: тогда число = всего минус прочитанное.
|
сколько сообщений в диалоге всего. Одно поле в ответе списка.
|
||||||
Одно поле в ответе списка, считается по той же базе.
|
|
||||||
|
|
||||||
### 6.3. «В диалоге что-то произошло» — события нет
|
### 6.3. «В диалоге что-то произошло» — события нет
|
||||||
|
|
||||||
В потоке агента есть три события: **создан, удалён, переименован.**
|
В потоке агента три события: **создан, удалён, переименован.** События «пришло
|
||||||
События «пришло новое сообщение» **нет** — хотя сервер в этот момент как раз
|
новое сообщение» **нет** — хотя сервер в этот момент как раз обновляет дату
|
||||||
обновляет `updatedAt` диалога (это видно по коду: `touch(id, now)`).
|
диалога (в коде это видно: `touch(id, now)`).
|
||||||
|
|
||||||
**Что это значит на практике:** агент работает в другом диалоге, а мы в этот
|
**Что это значит:** агент работает в другом диалоге, а мы смотрим список —
|
||||||
момент смотрим список — **список сам не обновится.** Ни порядок не поедет,
|
**список сам не обновится.** Ни порядок не поедет, ни точка не загорится,
|
||||||
ни точка «есть новое» не загорится, пока мы не спросим сервер заново.
|
пока не спросим сервер.
|
||||||
|
|
||||||
Пути:
|
- **Обновлять список самому, раз в несколько секунд** — без изменения API.
|
||||||
|
Список лёгкий, это честно и дёшево. Плюс обновлять при возвращении окна
|
||||||
|
в фокус.
|
||||||
|
- Добавить событие — список живёт сам, но опрос всё равно остаётся страховкой
|
||||||
|
от обрыва связи.
|
||||||
|
|
||||||
- **Обновлять список самому, раз в несколько секунд.** Без изменения API.
|
Первого достаточно. Второе — приятная добавка на потом.
|
||||||
Список лёгкий (см. §5) — это честно и дёшево. Плюс обновлять при
|
|
||||||
возвращении окна в фокус.
|
|
||||||
- **Добавить событие.** Тогда список живёт сам, без опроса. Но опрос всё
|
|
||||||
равно остаётся как страховка от обрыва связи.
|
|
||||||
|
|
||||||
Первый путь достаточен. Второй — приятная добавка на потом.
|
|
||||||
|
|
||||||
### 6.4. Итог: что менять в API
|
### 6.4. Итог: что менять в API
|
||||||
|
|
||||||
**Минимум, который закрывает макеты:**
|
**Минимум, закрывающий макеты:**
|
||||||
|
|
||||||
1. **Превью последнего сообщения** в ответе списка диалогов.
|
1. **Превью последнего сообщения** в ответе списка диалогов.
|
||||||
2. **Число непрочитанных** в ответе списка диалогов.
|
2. **Число непрочитанных** в ответе списка диалогов.
|
||||||
|
|
||||||
Обе правки — в том же хранилище, рядом с тем, что уже читается для списка.
|
Обе — в том же хранилище, рядом с тем, что уже читается для списка. Новых
|
||||||
Ни новых таблиц, ни новых запросов со стороны клиента: как был один запрос
|
запросов со стороны клиента не появляется.
|
||||||
за списком, так и остался.
|
|
||||||
|
|
||||||
**Не обязательно:** событие «в диалоге что-то произошло» — вместо него опрос
|
**Не обязательно:** событие «в диалоге что-то произошло» — вместо него опрос.
|
||||||
раз в несколько секунд. Можно отложить.
|
**Решить отдельно:** группы (§7).
|
||||||
|
|
||||||
**Нужно решить отдельно:** группы (§3). Это не правка ответа, это новое
|
|
||||||
понятие в библиотеке.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Открытые вопросы
|
## 7. Группы — сервер про них не знает
|
||||||
|
|
||||||
1. **Группы — на устройстве или на сервере?** От этого зависит, поедут ли они
|
**Важное.** В описании диалога шесть полей: `id`, `title`, `isTemporal`,
|
||||||
между десктопом и телефоном (§3).
|
`createdAt`, `updatedAt` и два про картинки. **Поля «группа» нет.** Значит,
|
||||||
2. **Меняем API: превью и число непрочитанных?** Или убираем их из макета
|
группы — целиком наша выдумка, и живут они только у нас.
|
||||||
и живём на том, что есть (§6.4).
|
|
||||||
3. **Что засчитывать сообщением** при подсчёте непрочитанных: только ответы
|
**Что из этого следует:** телефон и десктоп **разойдутся**. Создал «Работа»
|
||||||
агента или ещё вызовы инструментов, которые в истории тоже лежат
|
на компьютере — на телефоне её нет.
|
||||||
отдельными записями.
|
|
||||||
4. **Бейдж — число или точка?** Точка не требует менять сервер вообще (§6.2).
|
1. **Группы только на устройстве.** Просто. Раскладка не ездит между
|
||||||
|
устройствами.
|
||||||
|
2. **Группы на сервере.** Тогда это новое понятие в библиотеке `agentik`,
|
||||||
|
а не правка клиента.
|
||||||
|
|
||||||
|
Это решение стоит принять **до** того, как начнём писать клиент.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Открытые вопросы
|
||||||
|
|
||||||
|
1. **Группы — на устройстве или на сервере?** (§7)
|
||||||
|
2. **Меняем API: превью и число непрочитанных?** Или убираем их из макета (§6.4).
|
||||||
|
3. **Что считать сообщением** при подсчёте: только ответы агента или ещё
|
||||||
|
вызовы инструментов, которые в истории тоже лежат записями.
|
||||||
|
4. **Бейдж — число или точка?** Точка сервер не трогает вообще (§6.2).
|
||||||
|
5. **Где именно каталог клиента** — `~/.agentik/` или системный
|
||||||
|
(«Документы пользователя»). На Android понятие «домашний каталог» своё.
|
||||||
|
|||||||
Reference in New Issue
Block a user