重构会话管理功能,由后端 opencode 发放 sessionId,后端做 scope

This commit is contained in:
2026-05-21 15:41:46 +08:00
parent 7e63d38cf5
commit 5d80961930
20 changed files with 816 additions and 390 deletions
+121 -43
View File
@@ -2,17 +2,18 @@ import { Router } from "express";
import { z } from "zod";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type SessionHistoryStore } from "../history/store.js";
import { logger } from "../logger.js";
import { MemoryStore } from "../memory/store.js";
import { type ConversationStore } from "../conversations/store.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { toActorKey } from "../utils/fileStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
generateSessionTitle,
getConversationTurnStats,
} from "./chatSession.js";
import {
collectTextContent,
@@ -31,6 +32,11 @@ const abortPayloadSchema = z.object({
session_id: z.string().max(128),
});
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
@@ -39,12 +45,48 @@ const forkPayloadSchema = z.object({
export const buildChatRouter = (
sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter,
conversationStore: ConversationStore,
memoryStore: MemoryStore,
sessionHistoryStore: SessionHistoryStore,
learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver,
) => {
const chatRouter = Router();
chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.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);
const { record, created } = await conversationStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId: parsed.data.session_id,
userId,
});
res.status(created ? 201 : 200).json({
session_id: record.sessionId,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
});
});
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
const renderRef = req.params.renderRef?.trim();
const userId = req.header("x-user-id")?.trim();
@@ -99,20 +141,8 @@ export const buildChatRouter = (
}
try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const binding = await sessionBridge.abort({
clientSessionId: parsed.data.session_id,
accessToken,
projectId,
traceId,
userId,
});
if (!binding) {
@@ -124,8 +154,6 @@ export const buildChatRouter = (
{
clientSessionId: parsed.data.session_id,
sessionId: binding.sessionId,
traceId,
projectId,
},
"aborted chat session by client request",
);
@@ -154,37 +182,69 @@ export const buildChatRouter = (
}
try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const { binding, requestContext } = await sessionBridge.fork({
clientSessionId: parsed.data.session_id,
accessToken,
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sourceClientSessionId = parsed.data.session_id?.trim();
const sourceConversation = sourceClientSessionId
? await conversationStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceClientSessionId,
)
: null;
const { record: targetConversation } = await conversationStore.ensure({
actorKey,
parentSessionId: sourceClientSessionId,
projectId,
traceId,
keepMessageCount: parsed.data.keep_message_count,
projectKey,
userId,
});
const nextClientSessionId = targetConversation.sessionId;
if (sourceClientSessionId && parsed.data.keep_message_count > 0) {
await sessionHistoryStore.cloneThread(
{
actorKey,
clientSessionId: sourceClientSessionId,
projectKey,
sessionId: sourceClientSessionId,
},
{
actorKey,
clientSessionId: nextClientSessionId,
projectKey,
sessionId: nextClientSessionId,
},
parsed.data.keep_message_count,
);
if (sourceConversation?.title) {
await conversationStore.touch(targetConversation, {
title: sourceConversation.title,
});
}
}
logger.info(
{
sourceClientSessionId: parsed.data.session_id,
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
clientSessionId: nextClientSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
},
"forked chat session",
);
res.status(200).json({
session_id: requestContext.clientSessionId,
session_id: nextClientSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -214,20 +274,38 @@ export const buildChatRouter = (
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const { record: conversation, created: conversationCreated } =
await conversationStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: parsed.data.session_id,
userId,
});
const activeConversation = await conversationStore.touch(conversation);
const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: parsed.data.session_id,
clientSessionId: activeConversation.sessionId,
accessToken,
projectId,
traceId,
userId,
});
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId,
};
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
logger.info(
{
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
created,
created: created || conversationCreated,
model: parsed.data.model,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
@@ -260,6 +338,7 @@ export const buildChatRouter = (
memoryStore,
requestContext.actorKey,
requestContext.projectKey,
recentTurns,
parsed.data.message,
);
const streamResult = await streamPromptResponse({
@@ -285,23 +364,21 @@ export const buildChatRouter = (
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const existingSessionTitle = sessionBridge.getSessionTitle(binding.sessionId);
const existingSessionTitle = activeConversation.title;
let sessionTitle = existingSessionTitle;
const { userMessageCount, assistantMessageCount } =
await getConversationTurnStats(runtime, binding.sessionId);
const shouldGenerateTitle =
userMessageCount <= 3 &&
assistantMessageCount >= 1;
const shouldGenerateTitle = recentTurns.length <= 1;
if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId,
latestUserMessage: parsed.data.message,
fallbackTitle: existingSessionTitle,
});
if (sessionTitle !== existingSessionTitle) {
sessionBridge.setSessionTitle(binding.sessionId, sessionTitle);
}
}
const nextConversation = await conversationStore.touch(activeConversation, {
...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle }
: {}),
});
if (!streamClosed && !res.writableEnded && !res.destroyed) {
if (
shouldGenerateTitle &&
@@ -321,18 +398,19 @@ export const buildChatRouter = (
assistantMessage: assistantText,
model: parsed.data.model,
requestContext,
sessionId: binding.sessionId,
sessionId: clientSessionId,
toolCallCount: streamResult.toolCallCount,
userMessage: parsed.data.message,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: binding.sessionId },
{ err: error, sessionId: clientSessionId },
"post-turn learning failed",
);
});
}
}
} finally {
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);