feat(ai-chat): persistent history as source of truth — step durability + server export (#183) #186
Reference in New Issue
Block a user
Delete Branch "feat/ai-chat-persistent-history"
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?
Closes #183. Отдельный PR (не часть батча #185).
История AI-чата жила в несогласованных парадигмах (стрим в памяти + клиентский экспорт vs БД-как-контекст) — отсюда хрупкий экспорт и потеря ответа ассистента при смерти процесса в середине хода. Делаем БД единственным источником истины.
A. Шаговая durability (сервер)
statusвai_chat_messages(миграция; NULL = legacy = completed). Ассистентская строка теперь создаётся сразу какstatus:'streaming'и апдейтится на каждомonStepFinish(текст + tool calls + tool RESULTS), затем один раз финализируется в completed/error/aborted на терминальном колбэке. Смерть процесса в середине хода сохраняет все завершённые шаги; sweep на старте (OnModuleInit→sweepStreaming) переводит висящиеstreaming→aborted. Путь записи больше не зависит от живого сокета.flushAssistant(steps, inProgressText, status, extra?)строит payload (metadata.parts байт-в-байт как старый билдер) — будущий фоновый воркер сможет звать тот же путь.AiChatMessageRepo:update,sweepStreaming,findAllByChat.consumeStreamdrain, close-once внешних MCP-клиентов, SSE-heartbeat — сохранены.B. Серверный экспорт
chat-markdown.util.tsрендерит markdown ТОЛЬКО из строк БД (порт клиентского билдера). Благодаря A в экспорт попадает и незавершённый ход (до последнего шага, с пометкой «still generating»).POST /ai-chat/export(owner-gated черезassertOwnedChat, workspace-scoped).langпринимает полный locale-таг (en-US/ru-RU) и нормализуется на сервере (normalizeLang).handleCopyзовёт эндпоинт;canExport = !!activeChatId. Весь гибридliveThreadRef/liveStateRef/onLiveContentChange/hasLiveContent(и клиентский chat-markdown util + тест) удалён — сервер теперь авторитетен.Найденный и исправленный баг (через браузерное тестирование)
Строгий
@IsIn(['en','ru'])наlangотклонял реальныйi18n.languageклиента (en-US) с 400. Юнит-тесты (передавали 'en'/'ru' напрямую) это не ловили — поймал прогон через реальный клиент в браузере. Исправлено: DTO принимает строку, сервер нормализует; добавлен регресс-тест на locale-таги.Проверки
Не мержу.
🤖 Generated with Claude Code
The chat lived in inconsistent paradigms (in-memory stream + client export vs. DB-as-context), which made export flaky and lost the assistant answer if the process died mid-turn. Make the DB the single source of truth. A. STEP-GRANULAR DURABILITY (server) - ai_chat_messages gains a nullable `status` column (migration; NULL = legacy = completed). The assistant row is now INSERTED UPFRONT as `status:'streaming'` and UPDATEd on every onStepFinish with all finished steps (text + tool calls + tool RESULTS), then finalized once to completed/error/aborted on the terminal callback. So a process death mid-turn keeps every finished step; a startup sweep (OnModuleInit → sweepStreaming) flips any dangling 'streaming' row to 'aborted'. The write path no longer depends on a live socket. - Pure exported `flushAssistant(steps, inProgressText, status, extra?)` builds the persist payload (metadata.parts byte-identical to the old builder), so a future background worker can call the same path. AiChatMessageRepo gains `update`, `sweepStreaming`, and `findAllByChat`. - consumeStream drain, external-MCP client close-once, SSE heartbeat preserved. B. SERVER-SIDE EXPORT - New pure `chat-markdown.util.ts` renders Markdown from DB rows ONLY (server port of the client builder). Because A persists the in-progress row, the export now includes an interrupted turn up to its last finished step (flagged "still generating"). `POST /ai-chat/export` (owner-gated via assertOwnedChat, workspace-scoped) returns it; `lang` accepts a full client locale tag ('en-US'/'ru-RU') and is normalized server-side (normalizeLang) — a strict @IsIn(['en','ru']) DTO rejected the real client's i18n.language with a 400, caught in real-browser testing. - Client: handleCopy calls the endpoint; `canExport = !!activeChatId`. The whole liveThreadRef/liveStateRef/onLiveContentChange/hasLiveContent hybrid (and the client chat-markdown util + test) is removed — the server is now authoritative. Tests: flushAssistant unit (status shapes + parts parity), chat-markdown.util unit (incl. legacy NULL-status + interrupted note + ru + normalizeLang locale tags), controller export wiring + owner-gate, integration update/sweepStreaming. Verified: server build + 318 ai-chat unit + 3 integration; client tsc + 157 ai-chat unit; and END-TO-END in a real browser — a chat turn persists mid-stream and the Copy button exports the DB-sourced markdown (showing the in-progress row), HTTP 200 after the locale fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ghost referenced this pull request2026-06-25 12:40:20 +03:00