AI-чат: персистентная история как источник истины — серверный экспорт и возобновление до конца шага #183

Closed
opened 2026-06-25 04:42:37 +03:00 by Ghost · 0 comments

Проблема

История чата с агентом сейчас живёт в нескольких несогласованных парадигмах: чат и экспорт — в одной (состояние в памяти браузера), сервер и контекст агента — в другой. Отсюда «странное поведение» (экспорт то копирует историю, то нет) и хрупкость при сбоях.

Хочу: история персистентна сразу после получения данных (пришёл шаг от модели / инструмент вернул результат — записали; коалесинг в пределах десятков секунд допустим), и эта же история синхронно доступна и как экспорт чата, и как контекст агенту при следующем запуске. Если ход агента оборвался по любой причине (отмена, обрыв сети, рестарт процесса, глюк LLM-бэкенда) — то, что уже было наработано, сохранено, экспортируется и видно следующему запуску («продолжай» не начинает с нуля). Единый источник истины — БД.

Как устроено сейчас (по факту из кода)

Часть инвариантов уже есть, и это важно зафиксировать, чтобы не переделывать лишнее:

  • Контекст модели строится из БД, а не из клиента. AiChatService.stream() зовёт aiChatMessageRepo.findRecent(chatId, ws, 50); клиентский payload истории игнорируется. То есть на сервере БД уже авторитетна для контекста.
  • User-сообщение пишется синхронно до вызова LLM — не теряется.
  • Частичный ответ сохраняется даже при обрыве/отмене/ошибке. consumeStream() дренит поток независимо от сокета, поэтому onError/onAbort успевают записать buildPartialAssistantRecord(...).

Реальные дыры:

  1. Гранулярность durability — «весь ход», а не «шаг». До терминальных колбэков capturedSteps и inProgressText живут только в памяти процесса. onStepFinish лишь копит их в памяти. Если процесс умирает в середине хода (OOM, рестарт пода, kill) — ответ ассистента теряется целиком.
  2. Результат инструмента не пишется в момент возврата. Он копится в памяти и пишется только в конце хода. А инструменты правят страницы Docmost (реальные сайд-эффекты): «инструмент отредактировал страницу → процесс умер → в истории чата об этом ничего» — реальный сценарий. Требование «инструмент вернул — записали» сейчас не выполняется.
  3. Репозиторий умеет только insert. AiChatMessageRepo (insert / findByChat / findRecent) — нет update. Это техническая причина, почему всё пишется «в конце».
  4. Клиент — три представления одной истории. useChat (in-memory стрим) + react-query кэш строк + liveThreadRef (нереактивный ref для экспорта). Экспорт (buildChatMarkdown) — WYSIWYG из памяти: приоритет liveThreadRef, фолбэк на строки. liveThreadRef чистится при размонтировании треда (переключение чата) → экспорт падает на строки → если рефетч не приехал → пусто/устаревшее. Это и есть «щас копируется, щас нет».
  5. Цикл агента привязан к HTTP-запросу (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 / событий.
  • Серверный экспорт из БД (без браузера) — autonomy-friendly по определению.
  • Персистентность уже идёт в consumeStream независимо от сокета — не усиливаем привязку к HTTP.

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

  • При kill процесса сервера в середине многошагового хода после рестарта в чате видны все завершённые шаги (текст + результаты инструментов) до точки падения; 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.tsupdate/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-гибрида).
## Проблема История чата с агентом сейчас живёт в нескольких несогласованных парадигмах: чат и экспорт — в одной (состояние в памяти браузера), сервер и контекст агента — в другой. Отсюда «странное поведение» (экспорт то копирует историю, то нет) и хрупкость при сбоях. Хочу: история **персистентна сразу после получения данных** (пришёл шаг от модели / инструмент вернул результат — записали; коалесинг в пределах десятков секунд допустим), и эта же история **синхронно** доступна и как экспорт чата, и как контекст агенту при следующем запуске. Если ход агента оборвался по любой причине (отмена, обрыв сети, рестарт процесса, глюк LLM-бэкенда) — то, что уже было наработано, сохранено, экспортируется и видно следующему запуску («продолжай» не начинает с нуля). Единый источник истины — БД. ## Как устроено сейчас (по факту из кода) Часть инвариантов уже есть, и это важно зафиксировать, чтобы не переделывать лишнее: - **Контекст модели строится из БД, а не из клиента.** `AiChatService.stream()` зовёт `aiChatMessageRepo.findRecent(chatId, ws, 50)`; клиентский payload истории игнорируется. То есть на сервере БД уже авторитетна для контекста. - **User-сообщение пишется синхронно до вызова LLM** — не теряется. - **Частичный ответ сохраняется даже при обрыве/отмене/ошибке.** `consumeStream()` дренит поток независимо от сокета, поэтому `onError`/`onAbort` успевают записать `buildPartialAssistantRecord(...)`. Реальные дыры: 1. **Гранулярность durability — «весь ход», а не «шаг».** До терминальных колбэков `capturedSteps` и `inProgressText` живут только в памяти процесса. `onStepFinish` лишь копит их в памяти. Если процесс умирает в середине хода (OOM, рестарт пода, kill) — ответ ассистента теряется целиком. 2. **Результат инструмента не пишется в момент возврата.** Он копится в памяти и пишется только в конце хода. А инструменты правят страницы Docmost (реальные сайд-эффекты): «инструмент отредактировал страницу → процесс умер → в истории чата об этом ничего» — реальный сценарий. Требование «инструмент вернул — записали» сейчас не выполняется. 3. **Репозиторий умеет только `insert`.** `AiChatMessageRepo` (insert / findByChat / findRecent) — нет `update`. Это техническая причина, почему всё пишется «в конце». 4. **Клиент — три представления одной истории.** `useChat` (in-memory стрим) + react-query кэш строк + `liveThreadRef` (нереактивный ref для экспорта). Экспорт (`buildChatMarkdown`) — WYSIWYG из памяти: приоритет `liveThreadRef`, фолбэк на строки. `liveThreadRef` чистится при размонтировании треда (переключение чата) → экспорт падает на строки → если рефетч не приехал → пусто/устаревшее. Это и есть «щас копируется, щас нет». 5. **Цикл агента привязан к HTTP-запросу** (`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` / событий. - Серверный экспорт из БД (без браузера) — autonomy-friendly по определению. - Персистентность уже идёт в `consumeStream` независимо от сокета — не усиливаем привязку к HTTP. ## Критерии приёмки - [ ] При kill процесса сервера в середине многошагового хода после рестарта в чате видны все завершённые шаги (текст + результаты инструментов) до точки падения; `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 closed this issue 2026-06-25 12:40:36 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#183