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;