@ykurilov/agent-chat-attachments 0.80.1 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @ykurilov/agent-chat-attachments
2
2
 
3
+ ## 0.81.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 038b6e2: Исправления аудита клиентской зоны. `useAgentChat` без `key` при смене чата больше не переносит в новый чат дела прежнего: «Стоп», ответ на подтверждение или виджет, передачу поправок и сорвавшуюся голосовую замену; новый чат не остаётся «занятым», повторное открытие того же чата начинается с чистого состояния. Песочница не зовёт хуки приложения, которые пишут данные (`onStateChanged`, `onTurnFinished`, `onModelChosen`), ни в песочном чате, ни в копии чата человека; `newChatGreeting` и `modelMenu` решаются для настоящего админа. Вложения: удаление человеком и чистка брошенных больше не убирают файл, который отправляемое сообщение уже отметило; `confirmUserParts` отказывает `AttachmentRejectedError` (`attachment_not_found` — файла нет или отметка не записана, `attachment_unavailable` — сбой хранилища), а не отмечает молча.
8
+
9
+ ### Как обновить приложение
10
+
11
+ Обновите пакеты SDK одной версией. Со встроенными хранилищами вложений (`createDiskAttachmentStorage`, `createMemoryAttachmentStorage`) ничего менять не нужно. Своё хранилище (`AttachmentStorage`, например S3) должно получить метод `removeUnattached(id): Promise<boolean>` — удалить файл, только если он не прикреплён, атомарно с `markAttached` того же файла; `removeUnattachedOlderThan` должен следовать тому же правилу. Без метода `createAttachments` бросает ошибку при запуске. Проверьте хранилище обновлённым `describeAttachmentStorageContract`. Если песочница полагалась на `onStateChanged`, `onTurnFinished` или `onModelChosen` в песочных чатах — в песочнице они больше не зовутся. [Подробности](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/audit-client.md).
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [038b6e2]
16
+ - Updated dependencies [58204d2]
17
+ - @ykurilov/agent-chat-react@0.81.0
18
+ - @ykurilov/agent-chat-core@0.81.0
19
+
3
20
  ## 0.80.1
4
21
 
5
22
  ### Patch Changes
package/README.md CHANGED
@@ -88,6 +88,9 @@ import { attachmentsClient } from "@ykurilov/agent-chat-attachments/react";
88
88
  Сбор текста останавливается, как только набран потолок символов. Текстовые логи с цветовыми кодами терминала принимаются как текст.
89
89
  - **Хранилище файлов** заменяемое (`AttachmentStorage`): из коробки `createDiskAttachmentStorage` и `createMemoryAttachmentStorage`.
90
90
  Своё (например, S3) проверяется общим набором `describeAttachmentStorageContract` из `@ykurilov/agent-chat-attachments/storage-contract`.
91
+ Неотправленный файл хранилище убирает только условно: `removeUnattached(id)` и `removeUnattachedOlderThan(before)` не трогают файл,
92
+ отметка которого (`markAttached`) уже записана, — проверка и удаление атомарны относительно отметки. Без `removeUnattached`
93
+ `createAttachments` не создаётся. Хранилище на диске даёт эту атомарность в пределах одного процесса сервера.
91
94
  Схема базы чата не меняется, миграции не нужны. Метод `removeAllOfChat(chatId)` удаляет все файлы чата: его вызывает удаление чата из списка чатов.
92
95
  - **Безопасность**: файл привязан к чату и владельцу, чужой и несуществующий файл отвечают одинаково (404). Адрес в хранилище — серверный UUID,
93
96
  имя файла в путь не попадает. Файлы отдаются с `nosniff` и `Content-Security-Policy: sandbox`. Ключи провайдеров в браузер не уходят.
@@ -15399,6 +15399,67 @@ function describeAttachmentStorageContract(createStorage) {
15399
15399
  await storage.remove(saved.id);
15400
15400
  await storage.remove(crypto.randomUUID());
15401
15401
  });
15402
+ it("removeUnattached \u0443\u0431\u0438\u0440\u0430\u0435\u0442 \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0435\u043F\u0440\u0438\u043A\u0440\u0435\u043F\u043B\u0451\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0438 \u0433\u043E\u0432\u043E\u0440\u0438\u0442, \u0443\u0431\u0440\u0430\u043B \u043B\u0438; \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0438 \u043D\u0435-UUID \u2014 false", async () => {
15403
+ const storage = await createStorage();
15404
+ const free = record();
15405
+ const sent = record();
15406
+ for (const item of [free, sent]) await storage.save(item, bytes());
15407
+ await storage.markAttached(sent.id);
15408
+ globalExpect(await storage.removeUnattached(free.id)).toBe(true);
15409
+ globalExpect(await storage.get(free.id)).toBeUndefined();
15410
+ globalExpect(await storage.readBytes(free.id)).toBeUndefined();
15411
+ globalExpect(await storage.removeUnattached(sent.id)).toBe(false);
15412
+ globalExpect((await storage.get(sent.id))?.attached).toBe(true);
15413
+ globalExpect([...await storage.readBytes(sent.id)]).toEqual([1, 2, 3]);
15414
+ globalExpect(await storage.removeUnattached(free.id)).toBe(false);
15415
+ globalExpect(await storage.removeUnattached("../../etc/passwd")).toBe(false);
15416
+ });
15417
+ async function markedThenRead(storage, id) {
15418
+ await storage.markAttached(id);
15419
+ return (await storage.get(id))?.attached === true;
15420
+ }
15421
+ async function expectIntactIfMarked(storage, id, marked) {
15422
+ if (marked) {
15423
+ globalExpect((await storage.get(id))?.attached).toBe(true);
15424
+ globalExpect([...await storage.readBytes(id) ?? []]).toEqual([1, 2, 3]);
15425
+ } else {
15426
+ globalExpect(await storage.get(id)).toBeUndefined();
15427
+ globalExpect(await storage.readBytes(id)).toBeUndefined();
15428
+ }
15429
+ }
15430
+ it("removeUnattached \u043E\u0434\u043D\u043E\u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u0441 \u043E\u0442\u043C\u0435\u0442\u043A\u043E\u0439: \u043E\u0442\u043C\u0435\u0447\u0435\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0446\u0435\u043B, \u0438\u043D\u0430\u0447\u0435 \u0443\u0431\u0440\u0430\u043D \u0446\u0435\u043B\u0438\u043A\u043E\u043C", async () => {
15431
+ const storage = await createStorage();
15432
+ const items = Array.from({ length: 20 }, () => record());
15433
+ for (const item of items) await storage.save(item, bytes());
15434
+ const outcomes = await Promise.all(items.map(async (item) => {
15435
+ const [marked, removed] = await Promise.all([markedThenRead(storage, item.id), storage.removeUnattached(item.id)]);
15436
+ return { item, marked, removed };
15437
+ }));
15438
+ for (const { item, marked, removed } of outcomes) {
15439
+ globalExpect(removed).toBe(!marked);
15440
+ await expectIntactIfMarked(storage, item.id, marked);
15441
+ }
15442
+ });
15443
+ it("removeUnattachedOlderThan \u043E\u0434\u043D\u043E\u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u0441 \u043E\u0442\u043C\u0435\u0442\u043A\u043E\u0439: \u043E\u0442\u043C\u0435\u0447\u0435\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0446\u0435\u043B, \u0438\u043D\u0430\u0447\u0435 \u0443\u0431\u0440\u0430\u043D \u0446\u0435\u043B\u0438\u043A\u043E\u043C", async () => {
15444
+ const storage = await createStorage();
15445
+ const items = Array.from({ length: 20 }, () => record());
15446
+ for (const item of items) await storage.save(item, bytes());
15447
+ const [marks] = await Promise.all([
15448
+ Promise.all(items.map((item) => markedThenRead(storage, item.id))),
15449
+ storage.removeUnattachedOlderThan(/* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z"))
15450
+ ]);
15451
+ for (const [index, item] of items.entries()) await expectIntactIfMarked(storage, item.id, marks[index]);
15452
+ });
15453
+ it("remove \u043E\u0434\u043D\u043E\u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u0441 \u043E\u0442\u043C\u0435\u0442\u043A\u043E\u0439: \u0437\u0430\u043F\u0438\u0441\u0438 \u0431\u0435\u0437 \u0431\u0430\u0439\u0442\u043E\u0432 \u043D\u0435 \u043E\u0441\u0442\u0430\u0451\u0442\u0441\u044F", async () => {
15454
+ const storage = await createStorage();
15455
+ const items = Array.from({ length: 20 }, () => record());
15456
+ for (const item of items) await storage.save(item, bytes());
15457
+ await Promise.all(items.flatMap((item) => [storage.markAttached(item.id), storage.remove(item.id)]));
15458
+ for (const item of items) {
15459
+ const left = await storage.get(item.id);
15460
+ globalExpect(left === void 0 ? void 0 : [...await storage.readBytes(item.id) ?? []]).toEqual(left && [1, 2, 3]);
15461
+ }
15462
+ });
15402
15463
  it("removeAllOfChat \u0443\u0431\u0438\u0440\u0430\u0435\u0442 \u0432\u0441\u0435 \u0444\u0430\u0439\u043B\u044B \u044D\u0442\u043E\u0433\u043E \u0447\u0430\u0442\u0430, \u0438 \u043F\u0440\u0438\u043A\u0440\u0435\u043F\u043B\u0451\u043D\u043D\u044B\u0435 \u0442\u043E\u0436\u0435; \u0447\u0443\u0436\u0438\u0435 \u0447\u0430\u0442\u044B \u0446\u0435\u043B\u044B; \u0433\u043E\u0432\u043E\u0440\u0438\u0442, \u0441\u043A\u043E\u043B\u044C\u043A\u043E \u0443\u0431\u0440\u0430\u043B", async () => {
15403
15464
  const storage = await createStorage();
15404
15465
  const free = record({ chatId: "chat-1" });
@@ -15448,6 +15509,7 @@ function describeAttachmentStorageContract(createStorage) {
15448
15509
  globalExpect(await storage.readBytes(id)).toBeUndefined();
15449
15510
  await storage.remove(id);
15450
15511
  await storage.markAttached(id);
15512
+ globalExpect(await storage.removeUnattached(id)).toBe(false);
15451
15513
  }
15452
15514
  });
15453
15515
  it("\u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 \u0431\u0435\u0437 \u0438\u0437\u0432\u043B\u0435\u0447\u0451\u043D\u043D\u043E\u0433\u043E \u0442\u0435\u043A\u0441\u0442\u0430 \u0445\u0440\u0430\u043D\u0438\u0442\u0441\u044F \u0431\u0435\u0437 \u044D\u0442\u0438\u0445 \u043F\u043E\u043B\u0435\u0439, \u0438\u0441\u0445\u043E\u0434\u043D\u044B\u0439 \u0442\u0438\u043F \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u0435\u0442\u0441\u044F", async () => {