feat(blindbox): AI 计划生成流式进度与渐进式文案
- 新增 runPlanGeneration 与 onProgress 回调 (blindboxPlanGen.ts) - 新增 POST /api/blindbox/plan/stream 推送 SSE 进度事件 - 前端优先走流式接口,实时展示「分析想法→搜索地点→规划路线→快好了」 - 流式失败时回退普通 POST,客户端轮播进度文案作为后备 - 规划阶段 UI 显示 planStatusMessage 替代静态文案
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user