refactor(chat): centralize session persistence

This commit is contained in:
2026-06-10 19:29:42 +08:00
parent cf6cada538
commit fa2c28c1c0
4 changed files with 227 additions and 77 deletions
+51 -77
View File
@@ -36,6 +36,7 @@ import {
type ActiveRun,
type RunStatus,
type StreamSubscriber,
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
@@ -67,12 +68,6 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0),
});
const sessionStateSchema = z.object({
title: z.string().max(120).optional(),
is_title_manually_edited: z.boolean().optional(),
messages: z.array(z.unknown()).default([]),
});
const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>();
@@ -80,6 +75,20 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
export const buildForkedSessionUiState = (
sourceState: { messages?: unknown[] } | null | undefined,
input: {
keepMessageCount: number;
targetSessionId: string;
},
) => ({
sessionId: input.targetSessionId,
isTitleManuallyEdited: false,
messages: Array.isArray(sourceState?.messages)
? sourceState.messages.slice(0, input.keepMessageCount)
: [],
});
const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
@@ -274,71 +283,6 @@ export const buildChatRouter = (
res.on("close", cleanup);
});
chatRouter.put("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const parsed = sessionStateSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const { record } = await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId,
userId,
});
const nextRecord = await sessionMetadataStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}),
});
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages,
});
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext: {
actorKey,
clientSessionId: nextRecord.sessionId,
projectId,
projectKey,
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
userId,
},
sessionId: nextRecord.sessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: nextRecord.sessionId },
"post-save learning failed",
);
});
}
res.json({
id: nextRecord.sessionId,
title: nextRecord.title ?? "新对话",
created_at: nextRecord.createdAt,
updated_at: nextRecord.updatedAt,
status: nextRecord.status,
session_id: nextRecord.sessionId,
});
});
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const title =
@@ -469,7 +413,7 @@ export const buildChatRouter = (
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId && parsed.data.keep_message_count > 0) {
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
@@ -485,12 +429,24 @@ export const buildChatRouter = (
},
parsed.data.keep_message_count,
);
if (sourceSessionRecord?.title) {
await sessionMetadataStore.touch(targetSessionRecord, {
title: sourceSessionRecord.title,
});
}
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord))
: null;
const forkTitle = sourceSessionRecord?.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
targetSessionRecord,
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
}),
);
logger.info(
{
@@ -789,6 +745,11 @@ export const buildChatRouter = (
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
}
for (const subscriber of activeRun.subscribers) {
@@ -876,6 +837,19 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
}
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext,
sessionId: clientSessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"stream-completed learning failed",
);
});
}
}
} finally {
if (abortController.signal.aborted) {