import { type Router } from "express"; import { z } from "zod"; import type { InteractionRequestStore } from "../sessions/interaction-request-store.js"; import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js"; import { getLocalAgentContext } from "../context/localContext.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { logger } from "../logger.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js"; import { type ActiveRun, type RunStatus, cancelBackendTodos, completeBackendProgress, updateLastAssistantMessage, } from "./chatUiState.js"; const abortPayloadSchema = z.object({ session_id: z.string().max(128), }); type RegisterAuxiliaryRoutesOptions = { interactionRequestStore?: InteractionRequestStore; runtime?: OpencodeRuntimeAdapter; activeRuns: Map; lastRunStatuses: Map; sessionBridge: ChatSessionBridge; sessionMetadataStore: SessionMetadataStore; sessionUiStateStore: SessionUiStateStore; frontendActionCoordinator: FrontendActionCoordinator; }; const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({ sessionId: record.sessionId, actorKey: record.actorKey, projectKey: record.projectKey, }); export const registerChatAuxiliaryRoutes = ( chatRouter: Router, { interactionRequestStore, runtime, activeRuns, lastRunStatuses, sessionBridge, sessionMetadataStore, sessionUiStateStore, frontendActionCoordinator, }: RegisterAuxiliaryRoutesOptions, ) => { chatRouter.post("/abort", async (req, res) => { const parsed = abortPayloadSchema.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 userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, ); if (!sessionRecord) { res.status(204).end(); return; } frontendActionCoordinator.cancelSession(sessionRecord.sessionId); const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId); if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true }); activeRuns.get(sessionRecord.sessionId)?.controller.abort(); await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord)); const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord)); if (pendingInteraction?.delivery && runtime && interactionRequestStore) { const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id); const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id); if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.interaction_id, { expected_revision: latest.delivery.revision, status, }); } activeRuns.get(sessionRecord.sessionId)?.inputs?.close(); const binding = await sessionBridge.abort({ clientSessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId, }); const run = activeRuns.get(sessionRecord.sessionId); if (run && run.status === "running") { run.status = "aborted"; lastRunStatuses.set(parsed.data.session_id, "aborted"); run.controller.abort(); run.messages = updateLastAssistantMessage(run.messages, (message) => ({ ...message, content: typeof message.content === "string" && message.content.trim() ? message.content : "⚠️ **请求已中断**", isError: true, progress: completeBackendProgress(message.progress), todos: cancelBackendTodos(message.todos), })); const currentState = await sessionUiStateStore.read( toSessionUiStateContext(sessionRecord), ); await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), { sessionId: sessionRecord.sessionId, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, messages: run.messages, }); for (const subscriber of run.subscribers) { subscriber.write("error", { session_id: parsed.data.session_id, message: "请求已中断", }); } // The generation route emits its terminal Response after cleanup and // closes subscribers. Closing here would truncate the Responses stream. } logger.info( { clientSessionId: parsed.data.session_id, sessionId: binding?.sessionId ?? parsed.data.session_id, }, "aborted chat session by client request", ); res.status(202).json({ session_id: parsed.data.session_id, aborted: true, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); logger.error({ err: error }, "chat abort failed"); res.status(500).json({ message: "chat abort failed", detail, }); } }); };