Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
811 lines
28 KiB
TypeScript
811 lines
28 KiB
TypeScript
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||
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,
|
||
SessionContextMismatchError,
|
||
readPurpose,
|
||
} from "../sessions/metadataStore.js";
|
||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||
import { type LearningOrchestrator } from "../learning/orchestrator.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 = {
|
||
learningOrchestrator: LearningOrchestrator;
|
||
frontendActionCoordinator: FrontendActionCoordinator;
|
||
runState: ChatRunState;
|
||
runtime: OpencodeRuntimeAdapter;
|
||
sessionBridge: ChatSessionBridge;
|
||
sessionMetadataStore: SessionMetadataStore;
|
||
sessionTranscriptStore: SessionTranscriptStore;
|
||
sessionUiStateStore: SessionUiStateStore;
|
||
};
|
||
|
||
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||
sessionId: sessionRecord.sessionId,
|
||
actorKey: sessionRecord.actorKey,
|
||
projectKey: sessionRecord.projectKey,
|
||
});
|
||
|
||
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,
|
||
learningOrchestrator,
|
||
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/responses", baseUrl), {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: credential.authorization,
|
||
"Content-Type": "application/json",
|
||
"X-Project-Id": credential.projectId,
|
||
},
|
||
body: JSON.stringify({
|
||
conversation: record.sessionId,
|
||
model,
|
||
stream: true,
|
||
input: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,自主查询并保存证据和调查报告;每次开始真实步骤或改变路线时发布完整 task progress,可用 Mermaid 调查图向人解释方向、分支和依据,图及 todo 均不约束执行。实际查询资料可通过 task presentation 发布,recorded 仅表示已记录,不等待浏览器展示。数据权限由系统配置,关键数据不足时发布最终报告并正常结束本轮,人工审核仅针对具体工单等业务动作。`,
|
||
}),
|
||
}).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;
|
||
if (trigger && !projectId) {
|
||
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
|
||
return;
|
||
}
|
||
const parentSessionId = parsed.data.parent_session_id?.trim();
|
||
if (parentSessionId && !await sessionMetadataStore.get(authContext, parentSessionId)) {
|
||
res.status(404).json({ message: "session not found" });
|
||
return;
|
||
}
|
||
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();
|
||
if (requestedSessionId && !await sessionMetadataStore.get(authContext, requestedSessionId)) {
|
||
res.status(404).json({ message: "session not found" });
|
||
return;
|
||
}
|
||
let sessionId = requestedSessionId;
|
||
if (!sessionId) {
|
||
try {
|
||
sessionId = (await runtime.createSession(undefined, { actorKey, projectKey })).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(undefined, metadataContext);
|
||
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(undefined, metadataContext);
|
||
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 pendingRefresh = credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
|
||
if (pendingRefresh) subscriber.write(pendingRefresh.type, {
|
||
...pendingRefresh, session_id: sessionRecord.sessionId, request_id: pendingRefresh.requestId,
|
||
});
|
||
|
||
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) => {
|
||
try {
|
||
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,
|
||
{ includeDeleted: true },
|
||
);
|
||
if (!sessionRecord) {
|
||
res.status(204).end();
|
||
return;
|
||
}
|
||
await sessionMetadataStore.remove(sessionRecord);
|
||
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||
await learningOrchestrator.cancelSession(sessionRecord.sessionId);
|
||
await sessionBridge.deleteSession({
|
||
clientSessionId: sessionRecord.sessionId,
|
||
sessionId: sessionRecord.sessionId,
|
||
actorKey: sessionRecord.actorKey,
|
||
projectKey: sessionRecord.projectKey,
|
||
});
|
||
await sessionMetadataStore.purge(sessionRecord);
|
||
activeRuns.delete(sessionRecord.sessionId);
|
||
lastRunStatuses.delete(sessionRecord.sessionId);
|
||
res.status(204).end();
|
||
} catch (error) {
|
||
if (error instanceof SessionContextMismatchError) { res.status(404).json({ message: "session not found" }); return; }
|
||
logger.warn({ err: error, sessionId: req.params.session_id }, "session cleanup incomplete");
|
||
res.status(503).json({ code: "SESSION_CLEANUP_PENDING", message: "Session cleanup could not finish. Retry deleting this session." });
|
||
}
|
||
});
|
||
|
||
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 (sourceSessionId && !sourceSessionRecord) {
|
||
res.status(404).json({ message: "session not found" });
|
||
return;
|
||
}
|
||
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(undefined, { actorKey, projectKey });
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
};
|