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
+75 -16
View File
@@ -51,6 +51,13 @@ interface RoomInfo {
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
const PLAN_STATUS_STEPS = [
"正在分析你们的想法...",
"正在搜索地点...",
"正在规划路线...",
"快好了...",
];
const IDEA_INSPIRATIONS = [
"去城市最高楼看日落",
"挑战一人做一道菜",
@@ -109,6 +116,7 @@ export default function BlindboxRoomPage() {
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false);
const [planStatusMessage, setPlanStatusMessage] = useState("正在分析你们的想法...");
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
const [activeContract, setActiveContract] = useState<{
id: string;
@@ -335,30 +343,81 @@ export default function BlindboxRoomPage() {
setGenerating(true);
setPhase("planning");
setError("");
setPlanStatusMessage(PLAN_STATUS_STEPS[0]);
const payload = {
roomId: room.id,
userId: profile.id,
availableTime: timeConfig,
};
const stepRef = { current: 0 };
const fallbackTimer = setInterval(() => {
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
setPlanStatusMessage(PLAN_STATUS_STEPS[stepRef.current]);
}, 2800);
try {
const res = await fetch("/api/blindbox/plan", {
const res = await fetch("/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: room.id,
userId: profile.id,
availableTime: timeConfig,
}),
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "生成失败");
}
const data = await res.json();
setPlanId(data.id);
setPlanDays(data.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("无法读取响应");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
let eventType = "";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) eventType = line.slice(6).trim();
else if (line.startsWith("data:")) data = line.slice(5).trim();
}
if (eventType === "status") setPlanStatusMessage(data);
else if (eventType === "plan") {
const parsed = JSON.parse(data);
setPlanId(parsed.id);
setPlanDays(parsed.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} else if (eventType === "error") {
setError(data || "生成计划失败");
setPhase("pool");
}
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "生成计划失败");
setPhase("pool");
try {
const res = await fetch("/api/blindbox/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "生成失败");
}
const data = await res.json();
setPlanId(data.id);
setPlanDays(data.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} catch (fallbackErr) {
setError(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败");
setPhase("pool");
}
} finally {
clearInterval(fallbackTimer);
setGenerating(false);
}
}, [generating, profile, room]);
@@ -961,7 +1020,7 @@ export default function BlindboxRoomPage() {
<Sparkles size={28} className="relative text-purple-400" />
</motion.div>
<p className="text-sm font-bold text-purple-300 animate-pulse">
AI ...
{planStatusMessage}
</p>
<p className="text-[11px] text-dim"> · 线 · </p>
</motion.div>