[bug][ai-chat] Экспорт чата («Copy chat») теряет то, что показано на экране, когда стрим оборвался #160

Closed
opened 2026-06-24 14:23:55 +03:00 by Ghost · 0 comments

Кратко

Кнопка «Copy chat» (экспорт диалога в Markdown) выгружает не то, что реально показано в окне чата. Если turn ассистента оборвался до завершения (баннер «Lost connection to the server» / dropped stream), то частичный ответ ассистента, видимый на экране (текст плана, вызовы инструментов «Ran tool …»), в выгрузку не попадает — в Markdown остаются только сообщения пользователя.

Пример (см. скриншот в исходном репорте): на экране — два запроса пользователя + ответ ассистента с планом и вызовами инструментов + красный баннер обрыва. В экспорте — только Messages: 2 и два user-сообщения, ответа ассистента и баннера нет.

Воспроизведение

  1. Открыть AI-chat, отправить запрос, на который агент отвечает стримингом (с вызовами инструментов).
  2. Спровоцировать/дождаться обрыва стрима до завершения ответа (reverse-proxy buffering/timeout, разрыв соединения) — появляется баннер «Lost connection to the server».
  3. Нажать «Copy chat» (иконка копирования в шапке окна).
  4. Вставить буфер обмена.

Ожидается: в Markdown есть всё, что на экране — сообщения пользователя, частичный ответ ассистента (текст/план + блоки инструментов) и пометка об обрыве.

Фактически: в Markdown только сообщения пользователя; ответ ассистента и баннер отсутствуют.

Корень проблемы

Окно и экспорт читают разные источники данных:

  • На экране MessageList рендерит «живой» тред useChatmessages (live), а не persisted-строки. См. apps/client/src/features/ai-chat/components/chat-thread.tsx (L423–L428).
  • Экспорт (handleCopy) строится из persisted-строк messageRows (запрос useAiChatMessagesQuery) и подмешивает «живой хвост» только при активном стриминге:
// apps/client/src/features/ai-chat/components/ai-chat-window.tsx (L258–L268)
const live = liveThreadRef.current;
const rowIds = new Set(messageRows.map((r) => r.id));
const pending = live.isStreaming      // <-- хвост берётся только во время стрима
  ? live.messages.filter((m) => !rowIds.has(m.id)).map((m) => ({ /* ... */ }))
  : [];
  • isStreaming = status === "submitted" || status === "streaming" (chat-thread.tsx L305). При обрыве/ошибке статус перестаёт быть streaming → isStreaming === falsepending = [].
  • При этом liveStateRef продолжает держать частичный ответ ��ссистента (он на экране): эффект-зеркало пишет { messages, isStreaming } всегда (chat-thread.tsx L317–L323).
  • На сервере user-сообщение сохраняется до контакта с моделью (apps/server/src/core/ai-chat/ai-chat.service.ts L253–L265), а partial-ответ ассистента при обрыве на участке клиент↔reverse-proxy в БД может не попасть. Поэтому в messageRows ответа нет, а из live-хвоста его «забирают» только при стриминге.

Итог: оборванный turn → ответ существует только в live-треде → экспорт его игнорирует.

Почему нельзя просто убрать гейт isStreaming

id «живых» сообщений и persisted-строк лежат в разных пространствах (сервер генерит собственные id при вставке). Поэтому после нормального завершения turn'а дедуп !rowIds.has(m.id) перестанет совпадать, и в экспорт попадут дубли user/assistant-сообщений.

Предлагаемое решение (WYSIWYG)

Сделать экспорт зеркалом экрана: источником служит live-тред, а persisted-строки используются для обогащения метаданными.

apps/client/src/features/ai-chat/utils/chat-markdown.ts

  • Источник сообщений — live.messages (что показано на экране). Fallback на persisted rows только если live-тред пуст.
  • Для каждого сообщения подтягивать persisted-строку по id (Map rowsById) и брать из неё токены/usage/error/createdAt, когда совпадение есть. Сообщения текущего/оборванного turn'а просто остаются без token-футера.
  • Последнее сообщение ассистента помечать «still generating», только если isStreaming === true.
  • В конце добавлять текст экранного баннера (ошибка/обрыв/stop).

apps/client/src/features/ai-chat/components/chat-thread.tsx

  • Расширить liveStateRef полем banner: string | null (текст из describeChatError/stopNotice) и писать его в эффекте-зеркале (+ обновить deps и cleanup).

apps/client/src/features/ai-chat/components/ai-chat-window.tsx

  • handleCopy передаёт в buildChatMarkdown live.messages, rowsById, rows (fallback), isStreaming, banner; обновить тип liveThreadRef.

Затронутые файлы

  • apps/client/src/features/ai-chat/utils/chat-markdown.ts — новая логика построения (live-first + обогащение + баннер).
  • apps/client/src/features/ai-chat/components/chat-thread.tsxliveStateRef + banner.
  • apps/client/src/features/ai-chat/components/ai-chat-window.tsxhandleCopy + тип liveThreadRef.
  • apps/client/src/features/ai-chat/utils/chat-markdown.test.ts — обновить тесты блока «pending», добавить кейсы.

Критерии приёмки

  • Экспорт после обрыва стрима содержит частичный ответ ассистента (текст/план + блоки инструментов), видимый на экране.
  • В экспорт добавляется строка-баннер об обрыве/ошибке/остановке.
  • Нормально завершённый чат экспортируется без дублей (как раньше): токены по сообщениям, total tokens, таймстемпы сохраняются.
  • При пустом live-треде работает fallback на persisted-строки (формат не меняется).
  • apps/clientpnpm test зелёный; добавлены тесты на WYSIWYG / баннер / оборванный turn.

Заметки и риски

  • Рассматривался более узкий вариант (подмешивать live-хвост при isStreaming || interrupted с дедупом по содержимому) — отклонён в пользу WYSIWYG как менее надёжный (эвристический дедуп между разными id-пространствами).
  • Смежный момент вне scope этого бага: canExport требует messageRows.length > 0 (ai-chat-window.tsx L234). Если самый первый turn нового чата падает до сохранения даже user-сообщения, кнопка экспорта будет скрыта. Стоит держать в уме.
## Кратко Кнопка **«Copy chat»** (экспорт диалога в Markdown) выгружает не то, что реально показано в окне чата. Если turn ассистента оборвался до завершения (баннер «Lost connection to the server» / dropped stream), то частичный ответ ассистента, видимый на экране (текст плана, вызовы инструментов «Ran tool …»), **в выгрузку не попадает** — в Markdown остаются только сообщения пользователя. Пример (см. скриншот в исходном репорте): на экране — два запроса пользователя + ответ ассистента с планом и вызовами инструментов + красный баннер обрыва. В экспорте — только `Messages: 2` и два user-сообщения, ответа ассистента и баннера нет. ## Воспроизведение 1. Открыть AI-chat, отправить запрос, на который агент отвечает стримингом (с вызовами инструментов). 2. Спровоцировать/дождаться обрыва стрима до завершения ответа (reverse-proxy buffering/timeout, разрыв соединения) — появляется баннер «Lost connection to the server». 3. Нажать «Copy chat» (иконка копирования в шапке окна). 4. Вставить буфер обмена. **Ожидается:** в Markdown есть всё, что на экране — сообщения пользователя, частичный ответ ассистента (текст/план + блоки инструментов) и пометка об обрыве. **Фактически:** в Markdown только сообщения пользователя; ответ ассистента и баннер отсутствуют. ## Корень проблемы Окно и экспорт читают **разные источники данных**: - На экране `MessageList` рендерит «живой» тред `useChat` — `messages` (live), а не persisted-строки. См. `apps/client/src/features/ai-chat/components/chat-thread.tsx` (L423–L428). - Экспорт (`handleCopy`) строится из **persisted-строк** `messageRows` (запрос `useAiChatMessagesQuery`) и подмешивает «живой хвост» только при активном стриминге: ```ts // apps/client/src/features/ai-chat/components/ai-chat-window.tsx (L258–L268) const live = liveThreadRef.current; const rowIds = new Set(messageRows.map((r) => r.id)); const pending = live.isStreaming // <-- хвост берётся только во время стрима ? live.messages.filter((m) => !rowIds.has(m.id)).map((m) => ({ /* ... */ })) : []; ``` - `isStreaming = status === "submitted" || status === "streaming"` (chat-thread.tsx L305). При обрыве/ошибке статус перестаёт быть streaming → `isStreaming === false` → `pending = []`. - При этом `liveStateRef` продолжает держать частичный ответ ��ссистента (он на экране): эффект-зеркало пишет `{ messages, isStreaming }` **всегда** (chat-thread.tsx L317–L323). - На сервере user-сообщение сохраняется **до** контакта с моделью (`apps/server/src/core/ai-chat/ai-chat.service.ts` L253–L265), а partial-ответ ассистента при обрыве на участке клиент↔reverse-proxy в БД может не попасть. Поэтому в `messageRows` ответа нет, а из live-хвоста его «забирают» только при стриминге. **Итог:** оборванный turn → ответ существует только в live-треде → экспорт его игнорирует. ### Почему нельзя просто убрать гейт `isStreaming` id «живых» сообщений и persisted-строк лежат в **разных пространствах** (сервер генерит собственные id при вставке). Поэтому после нормального завершения turn'а дедуп `!rowIds.has(m.id)` перестанет совпадать, и в экспорт попадут **дубли** user/assistant-сообщений. ## Предлагаемое решение (WYSIWYG) Сделать экспорт зеркалом экрана: источником служит **live-тред**, а persisted-строки используются для обогащения метаданными. **`apps/client/src/features/ai-chat/utils/chat-markdown.ts`** - Источник сообщений — `live.messages` (что показано на экране). Fallback на persisted `rows` только если live-тред пуст. - Для каждого сообщения подтягивать persisted-строку по `id` (Map `rowsById`) и брать из неё токены/usage/error/`createdAt`, когда совпадение есть. Сообщения текущего/оборванного turn'а просто остаются без token-футера. - Последнее сообщение ассистента помечать «still generating», только если `isStreaming === true`. - В конце добавлять текст экранного баннера (ошибка/обрыв/stop). **`apps/client/src/features/ai-chat/components/chat-thread.tsx`** - Расширить `liveStateRef` полем `banner: string | null` (текст из `describeChatError`/`stopNotice`) и писать его в эффекте-зеркале (+ обновить deps и cleanup). **`apps/client/src/features/ai-chat/components/ai-chat-window.tsx`** - `handleCopy` передаёт в `buildChatMarkdown` `live.messages`, `rowsById`, `rows` (fallback), `isStreaming`, `banner`; обновить тип `liveThreadRef`. ## Затронутые файлы - `apps/client/src/features/ai-chat/utils/chat-markdown.ts` — новая логика построения (live-first + обогащение + баннер). - `apps/client/src/features/ai-chat/components/chat-thread.tsx` — `liveStateRef` + `banner`. - `apps/client/src/features/ai-chat/components/ai-chat-window.tsx` — `handleCopy` + тип `liveThreadRef`. - `apps/client/src/features/ai-chat/utils/chat-markdown.test.ts` — обновить тесты блока «pending», добавить кейсы. ## Критерии приёмки - [ ] Экспорт после обрыва стрима содержит частичный ответ ассистента (текст/план + блоки инструментов), видимый на экране. - [ ] В экспорт добавляется строка-баннер об обрыве/ошибке/остановке. - [ ] Нормально завершённый чат экспортируется **без дублей** (как раньше): токены по сообщениям, total tokens, таймстемпы сохраняются. - [ ] При пустом live-треде работает fallback на persisted-строки (формат не меняется). - [ ] `apps/client` → `pnpm test` зелёный; добавлены тесты на WYSIWYG / баннер / оборванный turn. ## Заметки и риски - Рассматривался более узкий вариант (подмешивать live-хвост при `isStreaming || interrupted` с дедупом по содержимому) — отклонён в пользу WYSIWYG как менее надёжный (эвристический дедуп между разными id-пространствами). - Смежный момент вне scope этого бага: `canExport` требует `messageRows.length > 0` (ai-chat-window.tsx L234). Если самый первый turn нового чата падает до сохранения даже user-сообщения, кнопка экспорта будет скрыта. Стоит держать в уме.
Ghost added the bug label 2026-06-24 14:23:55 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#160