151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
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<string, ActiveRun>;
|
|
lastRunStatuses: Map<string, RunStatus>;
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
};
|