Files
TJWaterAgent/dist/routes/chat.js
T
2026-05-18 17:12:25 +08:00

107 lines
4.4 KiB
JavaScript

import { Router } from "express";
import { z } from "zod";
import { logger } from "../logger.js";
const payloadSchema = z.object({
message: z.string().min(1).max(10000),
conversation_id: z.string().max(128).optional(),
});
export const buildChatRouter = (sessionBridge, runtime) => {
const chatRouter = Router();
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
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 { binding, requestContext, created } = await sessionBridge.resolve({
conversationId: parsed.data.conversation_id,
accessToken,
projectId,
traceId,
});
logger.info({
conversationId: requestContext.conversationId,
sessionId: binding.sessionId,
created,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
}, "processing chat request");
// 当前先走“发送 prompt 后回读最近消息”的兼容实现。
// 后续切到真正的 opencode 事件流时,只需要替换这里的取数方式。
const messages = await runtime.sendPrompt(binding.sessionId, parsed.data.message);
const assistantMessage = messages.find((message) => message.info.role === "assistant");
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");
const conversationId = requestContext.conversationId;
const parts = assistantMessage?.parts ?? [];
const textContent = collectTextContent(parts);
if (textContent) {
res.write(toSse("token", {
conversationId,
content: textContent,
}));
}
for (const toolCall of collectToolCalls(parts)) {
res.write(toSse("tool_call", {
conversationId,
tool: toolCall.tool,
params: toolCall.params,
}));
}
if (assistantMessage?.info.role === "assistant" && assistantMessage.info.error) {
res.write(toSse("error", {
conversationId,
message: getErrorMessage(assistantMessage.info.error),
detail: assistantMessage.info.error.name,
}));
}
else if (!assistantMessage) {
res.write(toSse("error", {
conversationId,
message: "assistant response unavailable",
detail: "no assistant message found after prompt",
}));
}
else {
res.write(toSse("done", { conversationId }));
}
res.end();
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
res.status(500).json({
message: "chat stream failed",
detail,
});
}
});
return chatRouter;
};
const toSse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
// 先把 opencode 的 Part 结构压平成前端当前消费的 SSE 语义。
const collectTextContent = (parts) => parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
const collectToolCalls = (parts) => parts
.filter((part) => part.type === "tool")
.map((part) => ({
tool: part.tool,
params: part.state.input,
}));
const getErrorMessage = (error) => error.data?.message ?? error.name;