Files
next-tjwater-agent/src/routes/chatSessionRoutes.ts
T

772 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type Router } from "express";
import { z } from "zod";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { agentModelOptions } from "../chat/models.js";
import { config } from "../config.js";
import {
getLocalAgentContext,
getTrustedProductCredential,
} from "../context/localContext.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
type SessionMetadataStore,
type SessionRecord,
type SessionPurpose,
readPurpose,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
import { buildForkedSessionUiState } from "./chatSession.js";
import { type StreamSubscriber } from "./chatUiState.js";
import { agentTriggerContextSchema } from "./agentTrigger.js";
import {
bindOperationalTaskCredential,
clearOperationalTaskLease,
operationalTaskClient,
releaseOperationalTaskCredential,
} from "../product/operational-task-client.js";
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
trigger_context: agentTriggerContextSchema.optional(),
});
const taskExecutionPayloadSchema = z.object({
task_id: z.string().uuid(),
run_id: z.string().uuid(),
execution_request_id: z.string().uuid(),
workflow: z.string().trim().min(1).max(120).optional(),
});
const sessionListQuerySchema = z.object({
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
task_id: z.string().uuid().optional(),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
});
type RegisterChatSessionRoutesOptions = {
frontendActionCoordinator: FrontendActionCoordinator;
runState: ChatRunState;
runtime: OpencodeRuntimeAdapter;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionTranscriptStore: SessionTranscriptStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
export const registerChatSessionRoutes = (
chatRouter: Router,
options: RegisterChatSessionRoutesOptions,
) => {
const {
frontendActionCoordinator,
runState,
runtime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
} = options;
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
const startTaskExecution = (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
model: string,
workflow?: string,
) => {
queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
void fetch(new URL("/api/v1/agent/chat/stream", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
session_id: record.sessionId,
model,
message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,依据当前工单或人工请求动态规划 execution-plan@2;按实际选择的路线更新计划、证据和报告,数据权限不足时先申请并等待审核。`,
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
clearOperationalTaskLease(record.sessionId);
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
}
}).catch((error) => {
clearOperationalTaskLease(record.sessionId);
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
});
});
};
const prepareTaskExecution = async (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
input: z.infer<typeof taskExecutionPayloadSchema>,
) => {
bindOperationalTaskCredential(record.sessionId, credential);
try {
const taskContext = await operationalTaskClient.getAgentContext(record.sessionId, record.taskId!);
const task = taskContext.task && typeof taskContext.task === "object"
? taskContext.task as Record<string, unknown>
: null;
const activeRun = task?.active_run && typeof task.active_run === "object"
? task.active_run as Record<string, unknown>
: null;
if (
!activeRun
|| activeRun.run_id !== input.run_id
|| activeRun.execution_request_id !== input.execution_request_id
|| typeof activeRun.revision !== "number"
|| typeof activeRun.status !== "string"
) {
throw new Error("task context did not include the requested active run");
}
if (activeRun.status === "queued") {
await operationalTaskClient.claimTask(record.sessionId, record.taskId!, input.run_id, {
requestId: input.execution_request_id,
expectedRevision: activeRun.revision,
sourceSessionId: record.sessionId,
});
return "running";
}
if (
["claimed", "running"].includes(activeRun.status)
&& activeRun.agent_session_id === record.sessionId
) {
return "running";
}
throw new Error(`task run is not executable from this session (${activeRun.status})`);
} finally {
releaseOperationalTaskCredential(record.sessionId);
}
};
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 authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const trigger = parsed.data.trigger_context;
const purpose: SessionPurpose = trigger ? "task_execution" : "ordinary_chat";
const taskId = trigger?.subject_id;
if (trigger) {
const existingTriggeredSession = await sessionMetadataStore.findByTrigger(
{ actorKey, projectId, projectKey, userId },
trigger.trigger_id,
);
if (existingTriggeredSession) {
res.status(200).json({
session_id: existingTriggeredSession.sessionId,
created_at: existingTriggeredSession.createdAt,
updated_at: existingTriggeredSession.updatedAt,
status: existingTriggeredSession.status,
title: existingTriggeredSession.title,
parent_session_id: existingTriggeredSession.parentSessionId,
created: false,
event_duplicate: true,
trigger_id: trigger.trigger_id,
});
return;
}
}
const requestedSessionId = parsed.data.session_id?.trim();
let sessionId = requestedSessionId;
if (!sessionId) {
try {
sessionId = (await runtime.createSession()).id;
} catch (error) {
sessionId = sessionBridge.createClientSessionId();
logger.warn(
{ err: error, sessionId },
"runtime unavailable while creating chat session; using local degraded session",
);
}
}
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId,
purpose,
taskId,
trigger: trigger ? {
triggerId: trigger.trigger_id,
source: trigger.source,
subjectType: trigger.subject_type,
subjectId: trigger.subject_id,
workflow: trigger.workflow,
occurredAt: trigger.occurred_at,
} : undefined,
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,
created,
event_duplicate: false,
trigger_id: record.triggerId,
purpose: readPurpose(record),
task_id: record.taskId,
});
});
chatRouter.post("/task/:task_id/collaboration/session", async (req, res) => {
const taskId = z.string().uuid().safeParse(req.params.task_id);
if (!taskId.success) {
res.status(400).json({ message: "valid task_id is required" });
return;
}
const authContext = getLocalAgentContext(req);
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task collaboration requires product credentials" });
return;
}
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByTask(
metadataContext,
taskId.data,
"task_collaboration",
);
const validationSessionId = existing?.sessionId ?? `task-access-${crypto.randomUUID()}`;
bindOperationalTaskCredential(validationSessionId, credential);
try {
await operationalTaskClient.getAgentContext(validationSessionId, taskId.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task access validation failed",
});
return;
} finally {
releaseOperationalTaskCredential(validationSessionId);
}
if (existing) {
res.json({
session_id: existing.sessionId,
purpose: readPurpose(existing),
task_id: existing.taskId,
created: false,
status: existing.status,
});
return;
}
const runtimeSession = await runtime.createSession();
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_collaboration",
taskId: taskId.data,
});
const titled = await sessionMetadataStore.touch(record, { title: "任务协作" });
res.status(201).json({
session_id: titled.sessionId,
purpose: readPurpose(titled),
task_id: titled.taskId,
created: true,
status: titled.status,
});
});
chatRouter.post("/task-executions", async (req, res) => {
const parsed = taskExecutionPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten() });
return;
}
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task execution requires product credentials" });
return;
}
const authContext = getLocalAgentContext(req);
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByRequest(metadataContext, parsed.data.execution_request_id);
if (existing) {
if (
existing.taskId !== parsed.data.task_id
|| existing.runId !== parsed.data.run_id
|| readPurpose(existing) !== "task_execution"
) {
res.status(409).json({ message: "request_id is already bound to another task" });
return;
}
if (activeRuns.get(existing.sessionId)?.status === "running") {
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: existing.modelId,
});
return;
}
const taskModel = existing.modelId;
if (!taskModel) {
res.status(409).json({ message: "task execution session has no model binding" });
return;
}
try {
await prepareTaskExecution(existing, credential, parsed.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 409).json({
message: error instanceof Error ? error.message : "task execution recovery failed",
});
return;
}
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: taskModel,
});
startTaskExecution(existing, credential, taskModel, parsed.data.workflow);
return;
}
let taskModel: string;
try {
taskModel = await runtime.resolveAvailableModel([
config.OPERATIONAL_TASK_MODEL ?? config.OPENCODE_MODEL,
...agentModelOptions.map((option) => option.id),
]);
} catch (error) {
res.status(503).json({
message: error instanceof Error ? error.message : "no operational task model is available",
});
return;
}
const runtimeSession = await runtime.createSession();
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_execution",
taskId: parsed.data.task_id,
runId: parsed.data.run_id,
requestId: parsed.data.execution_request_id,
modelId: taskModel,
});
try {
await prepareTaskExecution(record, credential, parsed.data);
} catch (error) {
await sessionMetadataStore.remove(record);
clearOperationalTaskLease(record.sessionId);
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task claim failed",
});
return;
}
res.status(202).json({
session_id: record.sessionId,
task_id: record.taskId,
request_id: record.requestId,
run_id: record.runId,
purpose: readPurpose(record),
created: true,
status: "running",
model: taskModel,
});
startTaskExecution(record, credential, taskModel, parsed.data.workflow);
});
chatRouter.get("/sessions", async (req, res) => {
const query = sessionListQuerySchema.safeParse(req.query);
if (!query.success) {
res.status(400).json({ message: "invalid session filter", detail: query.error.flatten() });
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
}, {
purpose: query.data.purpose ?? "ordinary_chat",
taskId: query.data.task_id,
});
res.json({
sessions: records.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
purpose: readPurpose(record),
task_id: record.taskId,
trigger: record.triggerWorkflow === "water_quality_complaint" ? {
subject_id: record.triggerSubjectId,
workflow: record.triggerWorkflow,
occurred_at: record.triggerOccurredAt,
} : undefined,
})),
});
});
chatRouter.get("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
purpose: readPurpose(sessionRecord),
task_id: sessionRecord.taskId,
});
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: readPurpose(sessionRecord) === "task_collaboration"
? state?.messages ?? []
: run?.messages ?? state?.messages ?? [],
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
}),
);
if (!run || run.status !== "running") {
res.end();
return;
}
const subscriber: StreamSubscriber = {
write: (event, data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
run.subscribers.add(subscriber);
const cleanup = () => {
run.subscribers.delete(subscriber);
};
req.on("close", cleanup);
res.on("close", cleanup);
});
chatRouter.patch("/session/:session_id/title", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited
: undefined;
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, {
title,
});
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
},
);
}
res.json({
id: nextSessionRecord.sessionId,
title: nextSessionRecord.title,
updated_at: nextSessionRecord.updatedAt,
});
});
chatRouter.delete("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(204).end();
return;
}
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord);
res.status(204).end();
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceSessionId,
)
: null;
if (sourceSessionRecord && readPurpose(sourceSessionRecord) !== "ordinary_chat") {
res.status(409).json({ message: "task sessions cannot be forked into ordinary chat history" });
return;
}
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
projectId,
projectKey,
sessionId: forkSession.id,
userId,
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
}
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(
{
sourceSessionId: parsed.data.session_id,
sessionId: nextSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
},
"forked chat session",
);
res.status(200).json({
session_id: nextSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat fork failed");
res.status(500).json({
message: "chat fork failed",
detail,
});
}
});
};