init
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
|
||||
const payloadSchema = z.object({
|
||||
message: z.string().min(1).max(10000),
|
||||
conversation_id: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export const buildChatRouter = (
|
||||
sessionBridge: ChatSessionBridge,
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
) => {
|
||||
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: string, data: Record<string, unknown>) =>
|
||||
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
// 先把 opencode 的 Part 结构压平成前端当前消费的 SSE 语义。
|
||||
const collectTextContent = (parts: Part[]) =>
|
||||
parts
|
||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
const collectToolCalls = (parts: Part[]) =>
|
||||
parts
|
||||
.filter((part): part is Extract<Part, { type: "tool" }> => part.type === "tool")
|
||||
.map((part) => ({
|
||||
tool: part.tool,
|
||||
params: part.state.input,
|
||||
}));
|
||||
|
||||
const getErrorMessage = (error: {
|
||||
name: string;
|
||||
data?: { message?: string };
|
||||
}) => error.data?.message ?? error.name;
|
||||
Reference in New Issue
Block a user