AI-чат: персистентная история как источник истины — серверный экспорт и возобновление до конца шага #183
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?
Проблема
История чата с агентом сейчас живёт в нескольких несогласованных парадигмах: чат и экспорт — в одной (состояние в памяти браузера), сервер и контекст агента — в другой. Отсюда «странное поведение» (экспорт то копирует историю, то нет) и хрупкость при сбоях.
Хочу: история персистентна сразу после получения данных (пришёл шаг от модели / инструмент вернул результат — записали; коалесинг в пределах десятков секунд допустим), и эта же история синхронно доступна и как экспорт чата, и как контекст агенту при следующем запуске. Если ход агента оборвался по любой причине (отмена, обрыв сети, рестарт процесса, глюк LLM-бэкенда) — то, что уже было наработано, сохранено, экспортируется и видно следующему запуску («продолжай» не начинает с нуля). Единый источник истины — БД.
Как устроено сейчас (по факту из кода)
Часть инвариантов уже есть, и это важно зафиксировать, чтобы не переделывать лишнее:
AiChatService.stream()зовётaiChatMessageRepo.findRecent(chatId, ws, 50); клиентский payload истории игнорируется. То есть на сервере БД уже авторитетна для контекста.consumeStream()дренит поток независимо от сокета, поэтомуonError/onAbortуспевают записатьbuildPartialAssistantRecord(...).Реальные дыры:
capturedStepsиinProgressTextживут только в памяти процесса.onStepFinishлишь копит их в памяти. Если процесс умирает в середине хода (OOM, рестарт пода, kill) — ответ ассистента теряется целиком.insert.AiChatMessageRepo(insert / findByChat / findRecent) — нетupdate. Это техническая причина, почему всё пишется «в конце».useChat(in-memory стрим) + react-query кэш строк +liveThreadRef(нереактивный ref для экспорта). Экспорт (buildChatMarkdown) — WYSIWYG из памяти: приоритетliveThreadRef, фолбэк на строки.liveThreadRefчистится при размонтировании треда (переключение чата) → экспорт падает на строки → если рефетч не приехал → пусто/устаревшее. Это и есть «щас копируется, щас нет».res.hijack()), фонового исполнения нет. Блокирует автономных агентов в принципе (на будущее).Диагноз: проблема не в том, что БД не источник истины, а в том, что (а) на клиенте источников три и экспорт читает не из БД; (б) на сервере durability — гранулярностью хода, а не шага; (в) ран привязан к сокету.
Объём этой задачи
Делаем две вещи (обе серверные, БД — источник истины). Автономию сейчас НЕ строим, но архитектуру кладём так, чтобы ей не противоречить.
A. Возобновление при падении — до конца шага
idиstatus='streaming'; на каждомonStepFinishапдейтится содержимым всех завершённых шагов (текст + tool calls + tool results). Терминальные колбэки (onFinish/onError/onAbort) только финализируютstatus(completed/error/aborted).flushAssistant(capturedSteps, status)— функцию от «сколько шагов накоплено», чтобы будущий фоновый воркер мог звать тот же путь.AiChatMessageRepo: добавитьupdate(id, patch)/ upsert.statusвai_chat_messages(старые строки = NULL = трактуем какcompleted). На старте сервера — sweep «висящих»streaming-строк вaborted.findRecent+rowToUiMessageуже читаютmetadata.parts, так что инкрементальная строка для контекста следующего «продолжай» работает без изменений.B. Правильный серверный экспорт
POST /ai-chat/export→ рендерит markdown из строкai_chat_messages(портbuildChatMarkdownна сервер, источник — только БД). Благодаря пункту A в БД теперь лежит и незавершённый ход (до последнего шага) → серверный экспорт полнее прежнего клиентского.liveThreadRefудаляется. Это закрывает «копируется/не копируется».lang.Что НЕ делаем сейчас, но не ломаем (автономия — на будущее)
flushAssistantкак чистая функция от накопленных шагов → переиспользуется будущим BullMQ-воркером.status— это и есть проекция, которую позже будет поддерживать слойai_chat_runs/ событий.consumeStreamнезависимо от сокета — не усиливаем привязку к HTTP.Критерии приёмки
statusоборванной строки =aborted(илиerror).status) корректно отображаются и экспортируются.flushAssistantпокрыт тестом как чистая функция; путь записи не зависит от наличия живого сокета.Затронутые файлы (ориентир)
apps/server/src/core/ai-chat/ai-chat.service.ts— шаговая персистентность,flushAssistant, финализация статуса.apps/server/src/database/repos/ai-chat/ai-chat-message.repo.ts—update/upsert.apps/server/src/database/migrations/<new>-ai-chat-message-status.ts— колонкаstatus+ sweep.apps/server/src/core/ai-chat/ai-chat.controller.ts— эндпоинтPOST /ai-chat/export.buildChatMarkdown(новый util) + клиентский экспорт вapps/client/src/features/ai-chat/components/ai-chat-window.tsx/utils/chat-markdown.ts(удалениеliveThreadRef-гибрида).Ghost referenced this issue2026-06-25 11:03:55 +03:00
Ghost referenced this issue2026-06-25 23:50:19 +03:00
Ghost referenced this issue2026-06-28 04:26:20 +03:00
Ghost referenced this issue2026-06-28 22:18:11 +03:00
Ghost referenced this issue2026-06-29 01:06:47 +03:00