feat(blindbox): AI 计划生成流式进度与渐进式文案
- 新增 runPlanGeneration 与 onProgress 回调 (blindboxPlanGen.ts) - 新增 POST /api/blindbox/plan/stream 推送 SSE 进度事件 - 前端优先走流式接口,实时展示「分析想法→搜索地点→规划路线→快好了」 - 流式失败时回退普通 POST,客户端轮播进度文案作为后备 - 规划阶段 UI 显示 planStatusMessage 替代静态文案
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireMembership } from "@/lib/blindbox";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||
|
||||
interface AvailableTime {
|
||||
date: string;
|
||||
@@ -11,129 +9,6 @@ interface AvailableTime {
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
interface TaggedIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
timeSlot: string;
|
||||
estimatedMinutes: number;
|
||||
searchQuery: string;
|
||||
searchType: string;
|
||||
}
|
||||
|
||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||
morning: ["outdoor", "sports", "culture"],
|
||||
lunch: ["dining"],
|
||||
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"],
|
||||
dinner: ["dining"],
|
||||
evening: ["entertainment", "relaxation"],
|
||||
};
|
||||
|
||||
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
||||
const byCategory = new Map<string, TaggedIdea[]>();
|
||||
for (const idea of ideas) {
|
||||
const list = byCategory.get(idea.category) || [];
|
||||
list.push(idea);
|
||||
byCategory.set(idea.category, list);
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
if (availableHours >= 10) {
|
||||
slots.push("morning", "lunch", "afternoon", "dinner", "evening");
|
||||
} else if (availableHours >= 7) {
|
||||
slots.push("morning", "lunch", "afternoon", "evening");
|
||||
} else if (availableHours >= 5) {
|
||||
slots.push("lunch", "afternoon", "evening");
|
||||
} else {
|
||||
slots.push("afternoon", "evening");
|
||||
}
|
||||
|
||||
const selected: TaggedIdea[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const slot of slots) {
|
||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||
|
||||
let picked: TaggedIdea | null = null;
|
||||
for (const cat of preferredCategories) {
|
||||
const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id));
|
||||
if (pool.length > 0) {
|
||||
picked = pool[Math.floor(Math.random() * pool.length)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||
if (remaining.length > 0) {
|
||||
picked = remaining[Math.floor(Math.random() * remaining.length)];
|
||||
}
|
||||
}
|
||||
|
||||
if (picked) {
|
||||
selected.push(picked);
|
||||
usedIds.add(picked.id);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function searchPois(
|
||||
query: string,
|
||||
searchType: string,
|
||||
anchorLat: number,
|
||||
anchorLng: number,
|
||||
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
if (searchType === "category") {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("radius", "5000");
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
// Text/brand search — bias results to the room's location
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
function mapPois(
|
||||
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
|
||||
) {
|
||||
return pois
|
||||
.filter((p) => p.location)
|
||||
.map((p) => {
|
||||
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = p.business?.rating;
|
||||
return {
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { roomId, userId, availableTime } = await req.json();
|
||||
|
||||
@@ -152,162 +27,12 @@ export const POST = apiHandler(async (req) => {
|
||||
throw new ApiError("请选择有效的可用时间");
|
||||
}
|
||||
|
||||
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room) throw new ApiError("房间不存在", 404);
|
||||
if (!room.lat || !room.lng) {
|
||||
throw new ApiError("请先设置房间位置", 400);
|
||||
}
|
||||
|
||||
const allIdeas = await prisma.blindBoxIdea.findMany({
|
||||
where: { roomId, status: "in_pool", category: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
category: true,
|
||||
timeSlot: true,
|
||||
estimatedMinutes: true,
|
||||
searchQuery: true,
|
||||
searchType: true,
|
||||
},
|
||||
});
|
||||
|
||||
const taggedIdeas: TaggedIdea[] = allIdeas.filter(
|
||||
(i): i is TaggedIdea =>
|
||||
!!i.category && !!i.timeSlot && !!i.searchQuery && !!i.searchType &&
|
||||
typeof i.estimatedMinutes === "number",
|
||||
);
|
||||
|
||||
if (taggedIdeas.length < 2) {
|
||||
throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400);
|
||||
}
|
||||
|
||||
// Split into day configs — "整个周末" generates two separate days
|
||||
const dayConfigs: AvailableTime[] =
|
||||
at.date === "整个周末"
|
||||
? [
|
||||
{ date: "周六", startHour: at.startHour, endHour: at.endHour },
|
||||
{ date: "周日", startHour: at.startHour, endHour: at.endHour },
|
||||
]
|
||||
: [at];
|
||||
|
||||
// Select ideas per day — skip extra days when ideas run out
|
||||
const dayIdeas: TaggedIdea[][] = [];
|
||||
const usedIds = new Set<string>();
|
||||
for (const dayConfig of dayConfigs) {
|
||||
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
|
||||
if (remaining.length < 2) break;
|
||||
const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour);
|
||||
for (const idea of selected) usedIds.add(idea.id);
|
||||
dayIdeas.push(selected);
|
||||
}
|
||||
// Trim to actual days generated (may be fewer than requested for "整个周末")
|
||||
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
|
||||
|
||||
const allSelected = dayIdeas.flat();
|
||||
if (allSelected.length === 0) {
|
||||
throw new ApiError("无法从想法池中选出合适的活动", 400);
|
||||
}
|
||||
|
||||
// Deduplicate search queries across all days
|
||||
const uniqueByQuery = new Map<string, TaggedIdea>();
|
||||
for (const idea of allSelected) {
|
||||
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
|
||||
}
|
||||
|
||||
// Phase 1: search brand/place type queries in parallel
|
||||
const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category");
|
||||
|
||||
const searchResults = await Promise.all(
|
||||
brandPlaceQueries.map(async (idea) => {
|
||||
try {
|
||||
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!);
|
||||
return { query: idea.searchQuery, pois };
|
||||
} catch {
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const candidates: ScheduleContext["candidates"] = {};
|
||||
for (const result of searchResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
}
|
||||
|
||||
// Phase 2: category-type queries anchored to centroid of found POIs
|
||||
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
|
||||
if (catQueries.length > 0) {
|
||||
const allPois = Object.values(candidates).flat();
|
||||
let anchorLat = room.lat;
|
||||
let anchorLng = room.lng;
|
||||
if (allPois.length > 0) {
|
||||
anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length;
|
||||
anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length;
|
||||
}
|
||||
|
||||
const catResults = await Promise.all(
|
||||
catQueries.map(async (idea) => {
|
||||
try {
|
||||
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
|
||||
return { query: idea.searchQuery, pois };
|
||||
} catch {
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of catResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate schedule for each day (parallel AI calls)
|
||||
const schedules = await Promise.all(
|
||||
actualDayConfigs.map((dayConfig, idx) => {
|
||||
const ideas = dayIdeas[idx];
|
||||
const ctx: ScheduleContext = {
|
||||
ideas: ideas.map((i) => ({
|
||||
content: i.content,
|
||||
category: i.category,
|
||||
timeSlot: i.timeSlot,
|
||||
estimatedMinutes: i.estimatedMinutes,
|
||||
searchQuery: i.searchQuery,
|
||||
searchType: i.searchType,
|
||||
})),
|
||||
candidates,
|
||||
userLocation: { lat: room.lat!, lng: room.lng! },
|
||||
availableTime: dayConfig,
|
||||
};
|
||||
return generateSchedule(ctx);
|
||||
}),
|
||||
);
|
||||
|
||||
const days = schedules
|
||||
.map((schedule, idx) =>
|
||||
schedule
|
||||
? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary }
|
||||
: null,
|
||||
)
|
||||
.filter((d) => d !== null);
|
||||
|
||||
if (days.length === 0) {
|
||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||
}
|
||||
|
||||
const plan = await prisma.weekendPlan.create({
|
||||
data: {
|
||||
roomId,
|
||||
userId,
|
||||
planData: JSON.stringify({
|
||||
days,
|
||||
selectedIdeaIds: allSelected.map((i) => i.id),
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await runPlanGeneration(roomId, userId!, at);
|
||||
|
||||
return NextResponse.json({
|
||||
id: plan.id,
|
||||
days,
|
||||
createdAt: plan.createdAt,
|
||||
id: result.id,
|
||||
days: result.days,
|
||||
createdAt: result.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,7 +64,6 @@ function computeEndTime(planData: string, now: Date): Date | null {
|
||||
base.setHours(h, m, 0, 0);
|
||||
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
||||
|
||||
// If computed end time is in the past, it's for next week
|
||||
if (base.getTime() < now.getTime()) {
|
||||
base.setDate(base.getDate() + 7);
|
||||
}
|
||||
@@ -355,6 +79,7 @@ export const PATCH = apiHandler(async (req) => {
|
||||
requireUserId(userId);
|
||||
if (!planId) throw new ApiError("planId 不能为空");
|
||||
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||
if (!plan) throw new ApiError("计划不存在", 404);
|
||||
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
||||
@@ -389,6 +114,8 @@ export const GET = apiHandler(async (req) => {
|
||||
const userId = searchParams.get("userId");
|
||||
requireUserId(userId);
|
||||
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
|
||||
if (mode === "latest") {
|
||||
const roomId = searchParams.get("roomId");
|
||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Shared plan generation logic for blindbox weekend plans.
|
||||
* Supports optional progress callback for streaming UX.
|
||||
*/
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export interface PlanGenAvailableTime {
|
||||
date: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
interface TaggedIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
timeSlot: string;
|
||||
estimatedMinutes: number;
|
||||
searchQuery: string;
|
||||
searchType: string;
|
||||
}
|
||||
|
||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||
morning: ["outdoor", "sports", "culture"],
|
||||
lunch: ["dining"],
|
||||
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"],
|
||||
dinner: ["dining"],
|
||||
evening: ["entertainment", "relaxation"],
|
||||
};
|
||||
|
||||
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
||||
const byCategory = new Map<string, TaggedIdea[]>();
|
||||
for (const idea of ideas) {
|
||||
const list = byCategory.get(idea.category) || [];
|
||||
list.push(idea);
|
||||
byCategory.set(idea.category, list);
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
if (availableHours >= 10) {
|
||||
slots.push("morning", "lunch", "afternoon", "dinner", "evening");
|
||||
} else if (availableHours >= 7) {
|
||||
slots.push("morning", "lunch", "afternoon", "evening");
|
||||
} else if (availableHours >= 5) {
|
||||
slots.push("lunch", "afternoon", "evening");
|
||||
} else {
|
||||
slots.push("afternoon", "evening");
|
||||
}
|
||||
|
||||
const selected: TaggedIdea[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const slot of slots) {
|
||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||
let picked: TaggedIdea | null = null;
|
||||
for (const cat of preferredCategories) {
|
||||
const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id));
|
||||
if (pool.length > 0) {
|
||||
picked = pool[Math.floor(Math.random() * pool.length)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!picked) {
|
||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||
if (remaining.length > 0) {
|
||||
picked = remaining[Math.floor(Math.random() * remaining.length)];
|
||||
}
|
||||
}
|
||||
if (picked) {
|
||||
selected.push(picked);
|
||||
usedIds.add(picked.id);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function searchPois(
|
||||
query: string,
|
||||
searchType: string,
|
||||
anchorLat: number,
|
||||
anchorLng: number,
|
||||
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
if (searchType === "category") {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("radius", "5000");
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
function mapPois(
|
||||
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
|
||||
) {
|
||||
return pois
|
||||
.filter((p) => p.location)
|
||||
.map((p) => {
|
||||
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = p.business?.rating;
|
||||
return {
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const PLAN_PROGRESS_MESSAGES = {
|
||||
analyzing: "正在分析你们的想法...",
|
||||
searching: "正在搜索地点...",
|
||||
planning: "正在规划路线...",
|
||||
planningDay: (day: string) => `正在规划${day}...`,
|
||||
almostDone: "快好了...",
|
||||
} as const;
|
||||
|
||||
export interface PlanGenResult {
|
||||
id: string;
|
||||
days: { date: string; items: unknown[]; summary: string }[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function runPlanGeneration(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<PlanGenResult> {
|
||||
const at = availableTime;
|
||||
|
||||
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room) throw new ApiError("房间不存在", 404);
|
||||
if (!room.lat || !room.lng) {
|
||||
throw new ApiError("请先设置房间位置", 400);
|
||||
}
|
||||
|
||||
onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing);
|
||||
|
||||
const allIdeas = await prisma.blindBoxIdea.findMany({
|
||||
where: { roomId, status: "in_pool", category: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
category: true,
|
||||
timeSlot: true,
|
||||
estimatedMinutes: true,
|
||||
searchQuery: true,
|
||||
searchType: true,
|
||||
},
|
||||
});
|
||||
|
||||
const taggedIdeas: TaggedIdea[] = allIdeas.filter(
|
||||
(i): i is TaggedIdea =>
|
||||
!!i.category &&
|
||||
!!i.timeSlot &&
|
||||
!!i.searchQuery &&
|
||||
!!i.searchType &&
|
||||
typeof i.estimatedMinutes === "number",
|
||||
);
|
||||
|
||||
if (taggedIdeas.length < 2) {
|
||||
throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400);
|
||||
}
|
||||
|
||||
const dayConfigs: PlanGenAvailableTime[] =
|
||||
at.date === "整个周末"
|
||||
? [
|
||||
{ date: "周六", startHour: at.startHour, endHour: at.endHour },
|
||||
{ date: "周日", startHour: at.startHour, endHour: at.endHour },
|
||||
]
|
||||
: [at];
|
||||
|
||||
const dayIdeas: TaggedIdea[][] = [];
|
||||
const usedIds = new Set<string>();
|
||||
for (const dayConfig of dayConfigs) {
|
||||
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
|
||||
if (remaining.length < 2) break;
|
||||
const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour);
|
||||
for (const idea of selected) usedIds.add(idea.id);
|
||||
dayIdeas.push(selected);
|
||||
}
|
||||
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
|
||||
|
||||
const allSelected = dayIdeas.flat();
|
||||
if (allSelected.length === 0) {
|
||||
throw new ApiError("无法从想法池中选出合适的活动", 400);
|
||||
}
|
||||
|
||||
const uniqueByQuery = new Map<string, TaggedIdea>();
|
||||
for (const idea of allSelected) {
|
||||
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
|
||||
}
|
||||
|
||||
onProgress?.(PLAN_PROGRESS_MESSAGES.searching);
|
||||
|
||||
const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category");
|
||||
const searchResults = await Promise.all(
|
||||
brandPlaceQueries.map(async (idea) => {
|
||||
try {
|
||||
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!);
|
||||
return { query: idea.searchQuery, pois };
|
||||
} catch {
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const candidates: ScheduleContext["candidates"] = {};
|
||||
for (const result of searchResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
}
|
||||
|
||||
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
|
||||
if (catQueries.length > 0) {
|
||||
const allPois = Object.values(candidates).flat();
|
||||
let anchorLat = room.lat;
|
||||
let anchorLng = room.lng;
|
||||
if (allPois.length > 0) {
|
||||
anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length;
|
||||
anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length;
|
||||
}
|
||||
const catResults = await Promise.all(
|
||||
catQueries.map(async (idea) => {
|
||||
try {
|
||||
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
|
||||
return { query: idea.searchQuery, pois };
|
||||
} catch {
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const result of catResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(PLAN_PROGRESS_MESSAGES.planning);
|
||||
|
||||
const schedules = await Promise.all(
|
||||
actualDayConfigs.map((dayConfig, idx) => {
|
||||
onProgress?.(PLAN_PROGRESS_MESSAGES.planningDay(dayConfig.date));
|
||||
const ideas = dayIdeas[idx];
|
||||
const ctx: ScheduleContext = {
|
||||
ideas: ideas.map((i) => ({
|
||||
content: i.content,
|
||||
category: i.category,
|
||||
timeSlot: i.timeSlot,
|
||||
estimatedMinutes: i.estimatedMinutes,
|
||||
searchQuery: i.searchQuery,
|
||||
searchType: i.searchType,
|
||||
})),
|
||||
candidates,
|
||||
userLocation: { lat: room.lat!, lng: room.lng! },
|
||||
availableTime: dayConfig,
|
||||
};
|
||||
return generateSchedule(ctx);
|
||||
}),
|
||||
);
|
||||
|
||||
onProgress?.(PLAN_PROGRESS_MESSAGES.almostDone);
|
||||
|
||||
const days = schedules
|
||||
.map((schedule, idx) =>
|
||||
schedule
|
||||
? {
|
||||
date: actualDayConfigs[idx].date,
|
||||
items: schedule.items,
|
||||
summary: schedule.summary,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
|
||||
if (days.length === 0) {
|
||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||
}
|
||||
|
||||
const plan = await prisma.weekendPlan.create({
|
||||
data: {
|
||||
roomId,
|
||||
userId,
|
||||
planData: JSON.stringify({
|
||||
days,
|
||||
selectedIdeaIds: allSelected.map((i) => i.id),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
days,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user