[bug][ai-chat] Экспорт чата («Copy chat») теряет то, что показано на экране, когда стрим оборвался #160
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Кратко
Кнопка «Copy chat» (экспорт диалога в Markdown) выгружает не то, что реально показано в окне чата. Если turn ассистента оборвался до завершения (баннер «Lost connection to the server» / dropped stream), то частичный ответ ассистента, видимый на экране (текст плана, вызовы инструментов «Ran tool …»), в выгрузку не попадает — в Markdown остаются только сообщения пользователя.
Пример (см. скриншот в исходном репорте): на экране — два запроса пользователя + ответ ассистента с планом и вызовами инструментов + красный баннер обрыва. В экспорте — только
Messages: 2и два user-сообщения, ответа ассистента и баннера нет.Воспроизведение
Ожидается: в Markdown есть всё, что на экране — сообщения пользователя, частичный ответ ассистента (текст/план + блоки инструментов) и пометка об обрыве.
Фактически: в Markdown только сообщения пользователя; ответ ассистента и баннер отсутствуют.
Корень проблемы
Окно и экспорт читают разные источники данных:
MessageListрендерит «живой» тредuseChat—messages(live), а не persisted-строки. См.apps/client/src/features/ai-chat/components/chat-thread.tsx(L423–L428).handleCopy) строится из persisted-строкmessageRows(запросuseAiChatMessagesQuery) и подмешивает «живой хвост» только при активном стриминге:isStreaming = status === "submitted" || status === "streaming"(chat-thread.tsx L305). При обрыве/ошибке статус перестаёт быть streaming →isStreaming === false→pending = [].liveStateRefпродолжает держать частичный ответ ��ссистента (он на экране): эффект-зеркало пишет{ messages, isStreaming }всегда (chat-thread.tsx L317–L323).apps/server/src/core/ai-chat/ai-chat.service.tsL253–L265), а partial-ответ ассистента при обрыве на участке клиент↔reverse-proxy в БД может не попасть. Поэтому вmessageRowsответа нет, а из live-хвоста его «забирают» только при стриминге.Итог: оборванный turn → ответ существует только в live-треде → экспорт его игнорирует.
Почему нельзя просто убрать гейт
isStreamingid «живых» сообщений и persisted-строк лежат в разных пространствах (сервер генерит собственные id при вставке). Поэтому после нормального завершения turn'а дедуп
!rowIds.has(m.id)перестанет совпадать, и в экспорт попадут дубли user/assistant-сообщений.Предлагаемое решение (WYSIWYG)
Сделать экспорт зеркалом экрана: источником служит live-тред, а persisted-строки используются для обогащения метаданными.
apps/client/src/features/ai-chat/utils/chat-markdown.tslive.messages(что показано на экране). Fallback на persistedrowsтолько если live-тред пуст.id(MaprowsById) и брать из неё токены/usage/error/createdAt, когда совпадение есть. Сообщения текущего/оборванного turn'а просто остаются без token-футера.isStreaming === true.apps/client/src/features/ai-chat/components/chat-thread.tsxliveStateRefполемbanner: string | null(текст изdescribeChatError/stopNotice) и писать его в эффекте-зеркале (+ обновить deps и cleanup).apps/client/src/features/ai-chat/components/ai-chat-window.tsxhandleCopyпередаёт вbuildChatMarkdownlive.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», добавить кейсы.Критерии приёмки
apps/client→pnpm testзелёный; добавлены тесты на WYSIWYG / баннер / оборванный turn.Заметки и риски
isStreaming || interruptedс дедупом по содержимому) — отклонён в пользу WYSIWYG как менее надёжный (эвристический дедуп между разными id-пространствами).canExportтребуетmessageRows.length > 0(ai-chat-window.tsx L234). Если самый первый turn нового чата падает до сохранения даже user-сообщения, кнопка экспорта будет скрыта. Стоит держать в уме.