feat(blindbox): AI 计划生成流式进度与渐进式文案

- 新增 runPlanGeneration 与 onProgress 回调 (blindboxPlanGen.ts)
- 新增 POST /api/blindbox/plan/stream 推送 SSE 进度事件
- 前端优先走流式接口,实时展示「分析想法→搜索地点→规划路线→快好了」
- 流式失败时回退普通 POST,客户端轮播进度文案作为后备
- 规划阶段 UI 显示 planStatusMessage 替代静态文案
This commit is contained in:
2026-02-27 17:37:40 +08:00
parent 9aee4f0e9b
commit 2d49744dd0
5 changed files with 673 additions and 297 deletions
+78
View File
@@ -0,0 +1,78 @@
import { requireMembership } from "@/lib/blindbox";
import { requireUserId } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
function encodeSSE(event: string, data: string): string {
return `event: ${event}\ndata: ${data}\n\n`;
}
export async function POST(req: Request): Promise<Response> {
let roomId: string;
let userId: string;
let availableTime: { date: string; startHour: number; endHour: number };
try {
const body = await req.json();
roomId = body.roomId;
userId = body.userId;
availableTime = body.availableTime;
requireUserId(userId);
if (!roomId) {
return new Response(
JSON.stringify({ error: "roomId 不能为空" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
await requireMembership(roomId, userId);
const at = availableTime;
if (
!at?.date ||
typeof at.startHour !== "number" ||
typeof at.endHour !== "number" ||
at.endHour <= at.startHour
) {
return new Response(
JSON.stringify({ error: "请选择有效的可用时间" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
} catch (e) {
const message = e instanceof Error ? e.message : "请求参数错误";
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const push = (event: string, data: string) => {
controller.enqueue(encoder.encode(encodeSSE(event, data)));
};
try {
const result = await runPlanGeneration(roomId!, userId!, availableTime!, (message) => {
push("status", message);
});
push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt }));
} catch (e) {
const message = e instanceof Error ? e.message : "生成计划失败";
push("error", message);
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}