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>
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import api from "@/lib/api-client";
|
|
import { IPagination } from "@/lib/types.ts";
|
|
import {
|
|
IAiChat,
|
|
IAiChatListParams,
|
|
IAiChatMessageRow,
|
|
IAiChatMessagesParams,
|
|
IAiRole,
|
|
IAiRoleCreate,
|
|
IAiRoleUpdate,
|
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
|
|
/**
|
|
* Per-user AI chat CRUD. The server uses POST for reads (its convention) and
|
|
* wraps every (non-stream) response in `{ data }` via the global transform
|
|
* interceptor, which the axios client unwraps to the body — so we read `.data`
|
|
* (mirroring `comment-service`). The `/ai-chat/stream` endpoint is consumed by
|
|
* the AI SDK `useChat` transport directly, not here.
|
|
*/
|
|
|
|
/** List the current user's chats (most recent first, paginated). */
|
|
export async function getAiChats(
|
|
params: IAiChatListParams,
|
|
): Promise<IPagination<IAiChat>> {
|
|
const req = await api.post<IPagination<IAiChat>>("/ai-chat/chats", params);
|
|
return req.data;
|
|
}
|
|
|
|
/** Fetch a chat's messages (oldest first, paginated). */
|
|
export async function getAiChatMessages(
|
|
params: IAiChatMessagesParams,
|
|
): Promise<IPagination<IAiChatMessageRow>> {
|
|
const req = await api.post<IPagination<IAiChatMessageRow>>(
|
|
"/ai-chat/messages",
|
|
params,
|
|
);
|
|
return req.data;
|
|
}
|
|
|
|
/** Rename a chat. */
|
|
export async function renameAiChat(data: {
|
|
chatId: string;
|
|
title: string;
|
|
}): Promise<void> {
|
|
await api.post("/ai-chat/rename", data);
|
|
}
|
|
|
|
/** Soft-delete a chat. */
|
|
export async function deleteAiChat(chatId: string): Promise<void> {
|
|
await api.post("/ai-chat/delete", { chatId });
|
|
}
|
|
|
|
/**
|
|
* Export a chat to Markdown (#183). The server renders the transcript from the
|
|
* persisted rows (the DB is the single source of truth — including an
|
|
* interrupted turn's in-progress row, persisted upfront + per step), so the
|
|
* client just copies the returned string. `lang` localizes the few fixed
|
|
* role/tool labels; defaults to English server-side when omitted.
|
|
*/
|
|
export async function exportAiChat(
|
|
chatId: string,
|
|
lang?: string,
|
|
): Promise<string> {
|
|
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
|
|
chatId,
|
|
lang,
|
|
});
|
|
return req.data.markdown;
|
|
}
|
|
|
|
/**
|
|
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
|
* member (for the chat-creation picker); create/update/delete are admin-only
|
|
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
|
*/
|
|
|
|
/** List the workspace's agent roles. */
|
|
export async function getAiRoles(): Promise<IAiRole[]> {
|
|
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
|
return req.data;
|
|
}
|
|
|
|
/** Create a role (admin). */
|
|
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
|
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
|
return req.data;
|
|
}
|
|
|
|
/** Update a role (admin). */
|
|
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
|
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
|
return req.data;
|
|
}
|
|
|
|
/** Soft-delete a role (admin). */
|
|
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
|
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
|
|
id,
|
|
});
|
|
return req.data;
|
|
}
|