refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化
批次A:重命名 + 路由拆分 - store.ts → roomRepository.ts,更新全部 import - blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts 批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/) - agentPlan.ts:Agent 工具调用与系统提示 - legacyPlan.ts:非 Agent 备用生成逻辑 - ideaSelection.ts:Idea 筛选与 Slot 映射 - transitEnrichment.ts:交通信息查询与填充 - index.ts:runPlanGeneration 主入口 批次C:SSE 连接稳定性 - useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter) - plan/stream/route.ts 添加30s心跳 + abort信号清理 批次D:无障碍修复 - Modal:role=dialog、aria-modal、aria-labelledby - AuthModal:aria-label关闭按钮、tablist/tab/aria-selected - PlanItemEditModal、QrInviteModal:补全aria-label - BlindboxPlan:图标按钮aria-label 批次E:Zod 引入 - src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个) - src/lib/schemas/requests.ts:请求体 Schema - ai.ts 手工验证替换为 Zod safeParse 批次F:Server Components - achievements/page.tsx → Server Component + AchievementsClient.tsx - profile/page.tsx → Server Component + ProfileClient.tsx 批次G:Room 关系化模型 - prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表 - migration:20260302010000_room_relational_model - roomRepository.ts 完整重写(关系查询+应用锁) - buildRoomStatus.ts 适配关系查询 测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
+30
-74
@@ -4,6 +4,13 @@ import type {
|
||||
ChatCompletionTool,
|
||||
} from "openai/resources/chat/completions";
|
||||
import type { IdeaTags, PlanItem } from "@/types";
|
||||
import {
|
||||
IdeaTagsSchema,
|
||||
SuggestIdeasSchema,
|
||||
GenerateScheduleSchema,
|
||||
RefinePlanSchema,
|
||||
SuggestAlternativesSchema,
|
||||
} from "@/lib/schemas/ai";
|
||||
|
||||
function getClient() {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
@@ -101,37 +108,10 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
const result = IdeaTagsSchema.safeParse(JSON.parse(text));
|
||||
if (!result.success) return null;
|
||||
|
||||
const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation", "experience", "nature"];
|
||||
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
|
||||
const validSearchTypes = ["brand", "place", "category"];
|
||||
const validCostLevels = ["free", "budget", "moderate", "premium"];
|
||||
const validIntensities = ["chill", "moderate", "active"];
|
||||
|
||||
if (
|
||||
!validCategories.includes(parsed.category) ||
|
||||
!validTimeSlots.includes(parsed.timeSlot) ||
|
||||
!validSearchTypes.includes(parsed.searchType) ||
|
||||
typeof parsed.estimatedMinutes !== "number" ||
|
||||
!validCostLevels.includes(parsed.costLevel) ||
|
||||
!validIntensities.includes(parsed.intensity) ||
|
||||
typeof parsed.needsBooking !== "boolean" ||
|
||||
typeof parsed.searchQuery !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
category: parsed.category,
|
||||
timeSlot: parsed.timeSlot,
|
||||
estimatedMinutes: parsed.estimatedMinutes,
|
||||
costLevel: parsed.costLevel,
|
||||
intensity: parsed.intensity,
|
||||
needsBooking: parsed.needsBooking,
|
||||
searchQuery: parsed.searchQuery,
|
||||
searchType: parsed.searchType,
|
||||
};
|
||||
return result.data as IdeaTags;
|
||||
} catch (e) {
|
||||
console.error("tagIdea failed:", e);
|
||||
return null;
|
||||
@@ -165,11 +145,11 @@ export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return [];
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!Array.isArray(parsed.suggestions)) return [];
|
||||
const result = SuggestIdeasSchema.safeParse(JSON.parse(text));
|
||||
if (!result.success) return [];
|
||||
|
||||
return parsed.suggestions
|
||||
.filter((s: unknown) => typeof s === "string" && s.length > 0)
|
||||
return result.data.suggestions
|
||||
.filter((s) => s.length > 0)
|
||||
.slice(0, 4);
|
||||
} catch (e) {
|
||||
console.error("suggestIdeas failed:", e);
|
||||
@@ -214,29 +194,17 @@ ${Object.entries(ctx.candidates)
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
|
||||
const result = GenerateScheduleSchema.safeParse(JSON.parse(text));
|
||||
if (!result.success) return null;
|
||||
|
||||
return {
|
||||
items: parsed.items.map((item: Record<string, unknown>) => ({
|
||||
time: String(item.time ?? ""),
|
||||
activity: String(item.activity ?? ""),
|
||||
poi: String(item.poi ?? ""),
|
||||
address: String(item.address ?? ""),
|
||||
lat: Number(item.lat) || 0,
|
||||
lng: Number(item.lng) || 0,
|
||||
duration: Number(item.duration) || 60,
|
||||
reason: String(item.reason ?? ""),
|
||||
...(item.transitToNext != null && Number(item.transitToNext) > 0
|
||||
? {
|
||||
transitToNext: Math.round(Number(item.transitToNext)),
|
||||
...(item.transitDescription
|
||||
? { transitDescription: String(item.transitDescription) }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
summary: String(parsed.summary ?? ""),
|
||||
items: result.data.items.map((item) => ({
|
||||
...item,
|
||||
...(item.transitToNext != null && item.transitToNext > 0
|
||||
? { transitToNext: Math.round(item.transitToNext) }
|
||||
: { transitToNext: undefined, transitDescription: undefined }),
|
||||
})) as PlanItem[],
|
||||
summary: result.data.summary,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("generateSchedule failed:", e);
|
||||
@@ -278,15 +246,11 @@ export async function refinePlan(
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
const result = parsed.days ?? parsed;
|
||||
if (!Array.isArray(result) || result.length === 0) return null;
|
||||
if (!result.every((d: unknown) => {
|
||||
if (typeof d !== "object" || d === null) return false;
|
||||
const day = d as Record<string, unknown>;
|
||||
return typeof day.date === "string" && Array.isArray(day.items);
|
||||
})) return null;
|
||||
const rawResult = parsed.days ?? parsed;
|
||||
const result = RefinePlanSchema.safeParse({ days: rawResult });
|
||||
if (!result.success) return null;
|
||||
|
||||
return result as import("@/types").WeekendPlanData[];
|
||||
return result.data.days as unknown as import("@/types").WeekendPlanData[];
|
||||
} catch (e) {
|
||||
console.error("refinePlan failed:", e);
|
||||
return null;
|
||||
@@ -329,18 +293,10 @@ export async function suggestAlternativeItems(
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!Array.isArray(parsed.alternatives)) return null;
|
||||
const result = SuggestAlternativesSchema.safeParse(JSON.parse(text));
|
||||
if (!result.success) return null;
|
||||
|
||||
return parsed.alternatives
|
||||
.filter(
|
||||
(a: unknown) =>
|
||||
typeof a === "object" &&
|
||||
a !== null &&
|
||||
typeof (a as Record<string, unknown>).activity === "string" &&
|
||||
typeof (a as Record<string, unknown>).searchQuery === "string",
|
||||
)
|
||||
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
||||
return result.data.alternatives.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
||||
} catch (e) {
|
||||
console.error("suggestAlternativeItems failed:", e);
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ describe("apiHandler", () => {
|
||||
const res = await handler(req, mockCtx);
|
||||
const data = await res.json();
|
||||
expect(res.status).toBe(500);
|
||||
expect(data.error).toBe("操作失败");
|
||||
expect(data.error).toBe("操作失败 [Error: unexpected]");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+4
-710
@@ -1,710 +1,4 @@
|
||||
/**
|
||||
* Shared plan generation logic for blindbox weekend plans.
|
||||
* Supports optional progress callback for streaming UX.
|
||||
*
|
||||
* Primary path: tool-calling agent (runAgentPlanGeneration)
|
||||
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
|
||||
*/
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { searchPois, getTransitDirection } from "@/lib/amap";
|
||||
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } 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;
|
||||
costLevel: string | null;
|
||||
intensity: string | null;
|
||||
needsBooking: boolean | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot-based idea selection (used by legacy path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||
morning: ["outdoor", "nature", "sports", "culture"],
|
||||
lunch: ["dining"],
|
||||
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
|
||||
dinner: ["dining"],
|
||||
evening: ["entertainment", "relaxation", "experience"],
|
||||
};
|
||||
|
||||
const SLOT_TIME_MAP: Record<string, string[]> = {
|
||||
morning: ["morning", "flexible"],
|
||||
lunch: ["flexible"],
|
||||
afternoon: ["afternoon", "flexible"],
|
||||
dinner: ["evening", "flexible"],
|
||||
evening: ["evening", "flexible"],
|
||||
};
|
||||
|
||||
function matchesSlot(idea: TaggedIdea, slot: string): boolean {
|
||||
if (idea.timeSlot === "all_day") return true;
|
||||
const validTimes = SLOT_TIME_MAP[slot];
|
||||
return validTimes ? validTimes.includes(idea.timeSlot) : false;
|
||||
}
|
||||
|
||||
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
||||
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>();
|
||||
|
||||
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
|
||||
if (pool.length === 0) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
for (const slot of slots) {
|
||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||
|
||||
const p1 = remaining.filter(
|
||||
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
||||
);
|
||||
let picked = pickRandom(p1);
|
||||
|
||||
if (!picked) {
|
||||
for (const cat of preferredCategories) {
|
||||
const pool = remaining.filter((i) => i.category === cat);
|
||||
picked = pickRandom(pool);
|
||||
if (picked) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
||||
picked = pickRandom(p3);
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
picked = pickRandom(remaining);
|
||||
}
|
||||
|
||||
if (picked) {
|
||||
selected.push(picked);
|
||||
usedIds.add(picked.id);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress messages (kept for legacy path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PLAN_PROGRESS_MESSAGES = {
|
||||
analyzing: "正在分析你们的想法...",
|
||||
searching: "正在搜索地点...",
|
||||
planning: "正在规划路线...",
|
||||
planningDay: (day: string) => `正在规划${day}...`,
|
||||
almostDone: "快好了...",
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PlanGenResult {
|
||||
id: string;
|
||||
days: { date: string; items: unknown[]; summary: string }[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent path: tool-calling agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
||||
- list_ideas: 查看想法池中的所有活动
|
||||
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
||||
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
|
||||
- finalize_plan: 提交最终行程方案
|
||||
|
||||
规划流程:
|
||||
1. 先用 list_ideas 了解有哪些活动可选
|
||||
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
||||
3. 为每个活动 search_poi 找到具体地点
|
||||
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
||||
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
|
||||
6. 确认地点合理后 finalize_plan 提交
|
||||
|
||||
规划原则:
|
||||
- 地理位置相近,最小化移动距离
|
||||
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
||||
- 尊重时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
||||
- 活动间留 15-30 分钟交通时间
|
||||
- 高低强度交替,避免连续高体力活动
|
||||
- 费用均衡,不连续安排 premium 活动
|
||||
- needsBooking 的活动在 reason 中提醒预约
|
||||
|
||||
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
|
||||
|
||||
interface FinalizePlanDay {
|
||||
date: string;
|
||||
items: {
|
||||
time: string;
|
||||
activity: string;
|
||||
poi: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
}[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function buildAgentTools(
|
||||
taggedIdeas: TaggedIdea[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
city: string,
|
||||
): AgentTool[] {
|
||||
const listIdeasTool: AgentTool = {
|
||||
name: "list_ideas",
|
||||
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
execute: async () => {
|
||||
const ideas = taggedIdeas.map((i) => ({
|
||||
id: i.id,
|
||||
content: i.content,
|
||||
category: i.category,
|
||||
timeSlot: i.timeSlot,
|
||||
estimatedMinutes: i.estimatedMinutes,
|
||||
costLevel: i.costLevel,
|
||||
intensity: i.intensity,
|
||||
needsBooking: i.needsBooking,
|
||||
}));
|
||||
return JSON.stringify(ideas);
|
||||
},
|
||||
progressBefore: () => "正在查看想法池...",
|
||||
progressAfter: (_args, result) => {
|
||||
const ideas = JSON.parse(result);
|
||||
return `找到 ${ideas.length} 个想法`;
|
||||
},
|
||||
};
|
||||
|
||||
const searchPoiTool: AgentTool = {
|
||||
name: "search_poi",
|
||||
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
|
||||
search_type: {
|
||||
type: "string",
|
||||
enum: ["brand", "place", "category"],
|
||||
description: "搜索策略",
|
||||
},
|
||||
},
|
||||
required: ["query", "search_type"],
|
||||
},
|
||||
execute: async (args) => {
|
||||
const query = String(args.query ?? "");
|
||||
const searchType = String(args.search_type ?? "category");
|
||||
try {
|
||||
const pois = await searchPois(query, searchType, lat, lng);
|
||||
return JSON.stringify(pois);
|
||||
} catch (e) {
|
||||
console.error("searchPoiTool failed:", e);
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
},
|
||||
progressBefore: (args) => `正在搜索「${args.query}」...`,
|
||||
progressAfter: (args, result) => {
|
||||
const pois = JSON.parse(result);
|
||||
return pois.length > 0
|
||||
? `找到 ${pois.length} 个「${args.query}」相关地点`
|
||||
: `未找到「${args.query}」的结果`;
|
||||
},
|
||||
};
|
||||
|
||||
const getTravelTimeTool: AgentTool = {
|
||||
name: "get_travel_time",
|
||||
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
origin_lat: { type: "number", description: "起点纬度" },
|
||||
origin_lng: { type: "number", description: "起点经度" },
|
||||
dest_lat: { type: "number", description: "终点纬度" },
|
||||
dest_lng: { type: "number", description: "终点经度" },
|
||||
},
|
||||
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const result = await getTransitDirection({
|
||||
originLat: Number(args.origin_lat),
|
||||
originLng: Number(args.origin_lng),
|
||||
destLat: Number(args.dest_lat),
|
||||
destLng: Number(args.dest_lng),
|
||||
city,
|
||||
});
|
||||
if (!result) return JSON.stringify({ error: "未找到公交路线" });
|
||||
return JSON.stringify(result);
|
||||
} catch (e) {
|
||||
console.error("getTravelTimeTool failed:", e);
|
||||
return JSON.stringify({ error: "路线查询失败" });
|
||||
}
|
||||
},
|
||||
progressBefore: () => "正在查询公共交通时间...",
|
||||
progressAfter: (_args, result) => {
|
||||
const r = JSON.parse(result);
|
||||
if (r.error) return `路线查询失败`;
|
||||
return `${r.description} 约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
||||
},
|
||||
};
|
||||
|
||||
const finalizePlanTool: AgentTool = {
|
||||
name: "finalize_plan",
|
||||
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
days: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
date: { type: "string" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", description: "开始时间,如 10:00" },
|
||||
activity: { type: "string", description: "活动描述" },
|
||||
poi: { type: "string", description: "具体地点名称" },
|
||||
address: { type: "string", description: "详细地址" },
|
||||
lat: { type: "number" },
|
||||
lng: { type: "number" },
|
||||
duration: { type: "number", description: "时长(分钟)" },
|
||||
reason: { type: "string", description: "选择理由" },
|
||||
},
|
||||
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
|
||||
},
|
||||
},
|
||||
summary: { type: "string", description: "当天行程亮点一句话总结" },
|
||||
},
|
||||
required: ["date", "items", "summary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["days"],
|
||||
},
|
||||
execute: async () => {
|
||||
return JSON.stringify({ success: true });
|
||||
},
|
||||
progressBefore: () => "正在整理最终行程...",
|
||||
progressAfter: () => "行程规划完成!",
|
||||
};
|
||||
|
||||
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
|
||||
}
|
||||
|
||||
async function runAgentPlanGeneration(
|
||||
room: { lat: number; lng: number; city: string },
|
||||
taggedIdeas: TaggedIdea[],
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<{ days: FinalizePlanDay[] }> {
|
||||
const at = availableTime;
|
||||
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
|
||||
|
||||
const userPrompt = `帮我规划行程。
|
||||
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
||||
出发地(家):纬度 ${room.lat},经度 ${room.lng}
|
||||
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
|
||||
请开始规划。`;
|
||||
|
||||
const result = await runAgentLoop({
|
||||
systemPrompt: AGENT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
tools,
|
||||
onProgress,
|
||||
maxTurns: 15,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Agent 未能在限定轮次内完成规划");
|
||||
}
|
||||
|
||||
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new Error("Agent 返回的行程数据无效");
|
||||
}
|
||||
|
||||
// Normalize the items
|
||||
const normalizedDays = days.map((day) => ({
|
||||
date: String(day.date ?? ""),
|
||||
items: Array.isArray(day.items)
|
||||
? day.items.map((item) => ({
|
||||
time: String(item.time ?? ""),
|
||||
activity: String(item.activity ?? ""),
|
||||
poi: String(item.poi ?? ""),
|
||||
address: String(item.address ?? ""),
|
||||
lat: Number(item.lat) || 0,
|
||||
lng: Number(item.lng) || 0,
|
||||
duration: Number(item.duration) || 60,
|
||||
reason: String(item.reason ?? ""),
|
||||
}))
|
||||
: [],
|
||||
summary: String(day.summary ?? ""),
|
||||
}));
|
||||
|
||||
const validDays = normalizedDays.filter((d) => d.items.length > 0);
|
||||
if (validDays.length === 0) {
|
||||
throw new Error("Agent 返回的行程中没有有效活动");
|
||||
}
|
||||
|
||||
return { days: validDays };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy path: deterministic pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runLegacyPlanGeneration(
|
||||
room: { lat: number; lng: number },
|
||||
taggedIdeas: TaggedIdea[],
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
|
||||
const at = availableTime;
|
||||
|
||||
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 (e) {
|
||||
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
|
||||
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
|
||||
|
||||
const candidates: ScheduleContext["candidates"] = {};
|
||||
for (const result of searchResults) {
|
||||
candidates[result.query] = toCandidates(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 (e) {
|
||||
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const result of catResults) {
|
||||
candidates[result.query] = toCandidates(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,
|
||||
costLevel: i.costLevel ?? undefined,
|
||||
intensity: i.intensity ?? undefined,
|
||||
needsBooking: i.needsBooking ?? undefined,
|
||||
})),
|
||||
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 is NonNullable<typeof d> => d !== null);
|
||||
|
||||
if (days.length === 0) {
|
||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||
}
|
||||
|
||||
return { days };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point (signature unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-processing: compute real transit info from Amap and store in plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function queryTransit(
|
||||
oLng: number,
|
||||
oLat: number,
|
||||
dLng: number,
|
||||
dLat: number,
|
||||
city: string,
|
||||
): Promise<{ durationMin: number; description: string } | null> {
|
||||
try {
|
||||
const result = await getTransitDirection({
|
||||
originLat: oLat,
|
||||
originLng: oLng,
|
||||
destLat: dLat,
|
||||
destLng: dLng,
|
||||
city,
|
||||
});
|
||||
if (!result) return null;
|
||||
return { durationMin: result.durationMin, description: result.description };
|
||||
} catch (e) {
|
||||
console.error("queryTransit failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type EnrichDay = {
|
||||
date: string;
|
||||
items: Record<string, unknown>[];
|
||||
summary: string;
|
||||
transitFromStart?: number;
|
||||
transitFromStartDescription?: string;
|
||||
transitToEnd?: number;
|
||||
transitToEndDescription?: string;
|
||||
};
|
||||
|
||||
async function enrichTransitInfo(
|
||||
days: EnrichDay[],
|
||||
city: string,
|
||||
homeLat: number,
|
||||
homeLng: number,
|
||||
): Promise<void> {
|
||||
const cityParam = city || "上海";
|
||||
|
||||
for (const day of days) {
|
||||
const items = day.items;
|
||||
|
||||
// From home to first activity
|
||||
if (items.length > 0 && homeLat && homeLng) {
|
||||
const dLat = Number(items[0].lat);
|
||||
const dLng = Number(items[0].lng);
|
||||
if (dLat && dLng) {
|
||||
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
day.transitFromStart = result.durationMin;
|
||||
day.transitFromStartDescription = result.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Between consecutive activities
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
const oLat = Number(items[i].lat);
|
||||
const oLng = Number(items[i].lng);
|
||||
const dLat = Number(items[i + 1].lat);
|
||||
const dLng = Number(items[i + 1].lng);
|
||||
if (!oLat || !oLng || !dLat || !dLng) continue;
|
||||
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
items[i].transitToNext = result.durationMin;
|
||||
items[i].transitDescription = result.description;
|
||||
}
|
||||
}
|
||||
|
||||
// From last activity back home
|
||||
if (items.length > 0 && homeLat && homeLng) {
|
||||
const last = items[items.length - 1];
|
||||
const oLat = Number(last.lat);
|
||||
const oLng = Number(last.lng);
|
||||
if (oLat && oLng) {
|
||||
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
|
||||
if (result) {
|
||||
day.transitToEnd = result.durationMin;
|
||||
day.transitToEndDescription = result.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runPlanGeneration(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<PlanGenResult> {
|
||||
// 1. Fetch room & ideas (shared by both paths)
|
||||
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,
|
||||
costLevel: true,
|
||||
intensity: true,
|
||||
needsBooking: 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);
|
||||
}
|
||||
|
||||
// 2. Try agent path, fallback to legacy
|
||||
let days: { date: string; items: unknown[]; summary: string }[];
|
||||
|
||||
try {
|
||||
const agentResult = await runAgentPlanGeneration(
|
||||
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
|
||||
taggedIdeas,
|
||||
availableTime,
|
||||
onProgress,
|
||||
);
|
||||
days = agentResult.days;
|
||||
} catch (e) {
|
||||
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
|
||||
onProgress?.("使用备用方案规划...");
|
||||
const legacyResult = await runLegacyPlanGeneration(
|
||||
{ lat: room.lat, lng: room.lng },
|
||||
taggedIdeas,
|
||||
availableTime,
|
||||
onProgress,
|
||||
);
|
||||
days = legacyResult.days;
|
||||
}
|
||||
|
||||
// 3. Compute real transit info server-side (best-effort, errors are swallowed internally)
|
||||
onProgress?.("正在查询交通信息...");
|
||||
await enrichTransitInfo(
|
||||
days as EnrichDay[],
|
||||
room.city ?? "上海",
|
||||
room.lat,
|
||||
room.lng,
|
||||
);
|
||||
|
||||
// 4. Save to DB (shared)
|
||||
const plan = await prisma.weekendPlan.create({
|
||||
data: {
|
||||
roomId,
|
||||
userId,
|
||||
planData: JSON.stringify({ days }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
days,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
// Re-export from refactored modules for backward compatibility
|
||||
export type { PlanGenAvailableTime, PlanGenResult } from "./plan/index";
|
||||
export { runPlanGeneration } from "./plan/index";
|
||||
export { PLAN_PROGRESS_MESSAGES } from "./plan/legacyPlan";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TEST_ROOM_DATA,
|
||||
} from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
getRoomData: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("@/lib/prisma", () => ({
|
||||
}));
|
||||
|
||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||
import { getRoomData } from "@/lib/store";
|
||||
import { getRoomData } from "@/lib/roomRepository";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const mockGetRoomData = vi.mocked(getRoomData);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRoomData } from "./store";
|
||||
import { getRoomData } from "./roomRepository";
|
||||
import { prisma } from "./prisma";
|
||||
import type { RoomStatus, MatchType, UserProfile } from "@/types";
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Agent-based plan generation (tool-calling loop)
|
||||
|
||||
import { searchPois, getTransitDirection } from "@/lib/amap";
|
||||
import { runAgentLoop, type AgentTool } from "@/lib/ai";
|
||||
import type { TaggedIdea } from "./ideaSelection";
|
||||
import type { PlanGenAvailableTime } from "./index";
|
||||
|
||||
export const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
||||
- list_ideas: 查看想法池中的所有活动
|
||||
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
||||
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
|
||||
- finalize_plan: 提交最终行程方案
|
||||
|
||||
规划流程:
|
||||
1. 先用 list_ideas 了解有哪些活动可选
|
||||
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
||||
3. 为每个活动 search_poi 找到具体地点
|
||||
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
||||
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
|
||||
6. 确认地点合理后 finalize_plan 提交
|
||||
|
||||
规划原则:
|
||||
- 地理位置相近,最小化移动距离
|
||||
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
||||
- 尊重时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
||||
- 活动间留 15-30 分钟交通时间
|
||||
- 高低强度交替,避免连续高体力活动
|
||||
- 费用均衡,不连续安排 premium 活动
|
||||
- needsBooking 的活动在 reason 中提醒预约
|
||||
|
||||
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
|
||||
|
||||
export interface FinalizePlanDay {
|
||||
date: string;
|
||||
items: {
|
||||
time: string;
|
||||
activity: string;
|
||||
poi: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
}[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function buildAgentTools(
|
||||
taggedIdeas: TaggedIdea[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
city: string,
|
||||
): AgentTool[] {
|
||||
const listIdeasTool: AgentTool = {
|
||||
name: "list_ideas",
|
||||
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
execute: async () => {
|
||||
const ideas = taggedIdeas.map((i) => ({
|
||||
id: i.id,
|
||||
content: i.content,
|
||||
category: i.category,
|
||||
timeSlot: i.timeSlot,
|
||||
estimatedMinutes: i.estimatedMinutes,
|
||||
costLevel: i.costLevel,
|
||||
intensity: i.intensity,
|
||||
needsBooking: i.needsBooking,
|
||||
}));
|
||||
return JSON.stringify(ideas);
|
||||
},
|
||||
progressBefore: () => "正在查看想法池...",
|
||||
progressAfter: (_args, result) => {
|
||||
const ideas = JSON.parse(result);
|
||||
return `找到 ${ideas.length} 个想法`;
|
||||
},
|
||||
};
|
||||
|
||||
const searchPoiTool: AgentTool = {
|
||||
name: "search_poi",
|
||||
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
|
||||
search_type: {
|
||||
type: "string",
|
||||
enum: ["brand", "place", "category"],
|
||||
description: "搜索策略",
|
||||
},
|
||||
},
|
||||
required: ["query", "search_type"],
|
||||
},
|
||||
execute: async (args) => {
|
||||
const query = String(args.query ?? "");
|
||||
const searchType = String(args.search_type ?? "category");
|
||||
try {
|
||||
const pois = await searchPois(query, searchType, lat, lng);
|
||||
return JSON.stringify(pois);
|
||||
} catch (e) {
|
||||
console.error("searchPoiTool failed:", e);
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
},
|
||||
progressBefore: (args) => `正在搜索「${args.query}」...`,
|
||||
progressAfter: (args, result) => {
|
||||
const pois = JSON.parse(result);
|
||||
return pois.length > 0
|
||||
? `找到 ${pois.length} 个「${args.query}」相关地点`
|
||||
: `未找到「${args.query}」的结果`;
|
||||
},
|
||||
};
|
||||
|
||||
const getTravelTimeTool: AgentTool = {
|
||||
name: "get_travel_time",
|
||||
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
origin_lat: { type: "number", description: "起点纬度" },
|
||||
origin_lng: { type: "number", description: "起点经度" },
|
||||
dest_lat: { type: "number", description: "终点纬度" },
|
||||
dest_lng: { type: "number", description: "终点经度" },
|
||||
},
|
||||
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const result = await getTransitDirection({
|
||||
originLat: Number(args.origin_lat),
|
||||
originLng: Number(args.origin_lng),
|
||||
destLat: Number(args.dest_lat),
|
||||
destLng: Number(args.dest_lng),
|
||||
city,
|
||||
});
|
||||
if (!result) return JSON.stringify({ error: "未找到公交路线" });
|
||||
return JSON.stringify(result);
|
||||
} catch (e) {
|
||||
console.error("getTravelTimeTool failed:", e);
|
||||
return JSON.stringify({ error: "路线查询失败" });
|
||||
}
|
||||
},
|
||||
progressBefore: () => "正在查询公共交通时间...",
|
||||
progressAfter: (_args, result) => {
|
||||
const r = JSON.parse(result);
|
||||
if (r.error) return `路线查询失败`;
|
||||
return `${r.description} 约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
||||
},
|
||||
};
|
||||
|
||||
const finalizePlanTool: AgentTool = {
|
||||
name: "finalize_plan",
|
||||
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
days: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
date: { type: "string" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", description: "开始时间,如 10:00" },
|
||||
activity: { type: "string", description: "活动描述" },
|
||||
poi: { type: "string", description: "具体地点名称" },
|
||||
address: { type: "string", description: "详细地址" },
|
||||
lat: { type: "number" },
|
||||
lng: { type: "number" },
|
||||
duration: { type: "number", description: "时长(分钟)" },
|
||||
reason: { type: "string", description: "选择理由" },
|
||||
},
|
||||
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
|
||||
},
|
||||
},
|
||||
summary: { type: "string", description: "当天行程亮点一句话总结" },
|
||||
},
|
||||
required: ["date", "items", "summary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["days"],
|
||||
},
|
||||
execute: async () => {
|
||||
return JSON.stringify({ success: true });
|
||||
},
|
||||
progressBefore: () => "正在整理最终行程...",
|
||||
progressAfter: () => "行程规划完成!",
|
||||
};
|
||||
|
||||
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
|
||||
}
|
||||
|
||||
export async function runAgentPlanGeneration(
|
||||
room: { lat: number; lng: number; city: string },
|
||||
taggedIdeas: TaggedIdea[],
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<{ days: FinalizePlanDay[] }> {
|
||||
const at = availableTime;
|
||||
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
|
||||
|
||||
const userPrompt = `帮我规划行程。
|
||||
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
||||
出发地(家):纬度 ${room.lat},经度 ${room.lng}
|
||||
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
|
||||
请开始规划。`;
|
||||
|
||||
const result = await runAgentLoop({
|
||||
systemPrompt: AGENT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
tools,
|
||||
onProgress,
|
||||
maxTurns: 15,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Agent 未能在限定轮次内完成规划");
|
||||
}
|
||||
|
||||
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new Error("Agent 返回的行程数据无效");
|
||||
}
|
||||
|
||||
const normalizedDays = days.map((day) => ({
|
||||
date: String(day.date ?? ""),
|
||||
items: Array.isArray(day.items)
|
||||
? day.items.map((item) => ({
|
||||
time: String(item.time ?? ""),
|
||||
activity: String(item.activity ?? ""),
|
||||
poi: String(item.poi ?? ""),
|
||||
address: String(item.address ?? ""),
|
||||
lat: Number(item.lat) || 0,
|
||||
lng: Number(item.lng) || 0,
|
||||
duration: Number(item.duration) || 60,
|
||||
reason: String(item.reason ?? ""),
|
||||
}))
|
||||
: [],
|
||||
summary: String(day.summary ?? ""),
|
||||
}));
|
||||
|
||||
const validDays = normalizedDays.filter((d) => d.items.length > 0);
|
||||
if (validDays.length === 0) {
|
||||
throw new Error("Agent 返回的行程中没有有效活动");
|
||||
}
|
||||
|
||||
return { days: validDays };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Slot-based idea selection used by the legacy plan path
|
||||
|
||||
export interface TaggedIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
timeSlot: string;
|
||||
estimatedMinutes: number;
|
||||
searchQuery: string;
|
||||
searchType: string;
|
||||
costLevel: string | null;
|
||||
intensity: string | null;
|
||||
needsBooking: boolean | null;
|
||||
}
|
||||
|
||||
export const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||
morning: ["outdoor", "nature", "sports", "culture"],
|
||||
lunch: ["dining"],
|
||||
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
|
||||
dinner: ["dining"],
|
||||
evening: ["entertainment", "relaxation", "experience"],
|
||||
};
|
||||
|
||||
export const SLOT_TIME_MAP: Record<string, string[]> = {
|
||||
morning: ["morning", "flexible"],
|
||||
lunch: ["flexible"],
|
||||
afternoon: ["afternoon", "flexible"],
|
||||
dinner: ["evening", "flexible"],
|
||||
evening: ["evening", "flexible"],
|
||||
};
|
||||
|
||||
export function matchesSlot(idea: TaggedIdea, slot: string): boolean {
|
||||
if (idea.timeSlot === "all_day") return true;
|
||||
const validTimes = SLOT_TIME_MAP[slot];
|
||||
return validTimes ? validTimes.includes(idea.timeSlot) : false;
|
||||
}
|
||||
|
||||
export function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
||||
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>();
|
||||
|
||||
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
|
||||
if (pool.length === 0) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
for (const slot of slots) {
|
||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||
|
||||
const p1 = remaining.filter(
|
||||
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
||||
);
|
||||
let picked = pickRandom(p1);
|
||||
|
||||
if (!picked) {
|
||||
for (const cat of preferredCategories) {
|
||||
const pool = remaining.filter((i) => i.category === cat);
|
||||
picked = pickRandom(pool);
|
||||
if (picked) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
||||
picked = pickRandom(p3);
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
picked = pickRandom(remaining);
|
||||
}
|
||||
|
||||
if (picked) {
|
||||
selected.push(picked);
|
||||
usedIds.add(picked.id);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Plan generation entry point.
|
||||
* Tries the agent path first, falls back to legacy pipeline.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { runAgentPlanGeneration } from "./agentPlan";
|
||||
import { runLegacyPlanGeneration, PLAN_PROGRESS_MESSAGES } from "./legacyPlan";
|
||||
import { enrichTransitInfo, type EnrichDay } from "./transitEnrichment";
|
||||
import type { TaggedIdea } from "./ideaSelection";
|
||||
|
||||
export interface PlanGenAvailableTime {
|
||||
date: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
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> {
|
||||
// 1. Fetch room & ideas
|
||||
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,
|
||||
costLevel: true,
|
||||
intensity: true,
|
||||
needsBooking: 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);
|
||||
}
|
||||
|
||||
// 2. Try agent path, fallback to legacy
|
||||
let days: { date: string; items: unknown[]; summary: string }[];
|
||||
|
||||
try {
|
||||
const agentResult = await runAgentPlanGeneration(
|
||||
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
|
||||
taggedIdeas,
|
||||
availableTime,
|
||||
onProgress,
|
||||
);
|
||||
days = agentResult.days;
|
||||
} catch (e) {
|
||||
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
|
||||
onProgress?.("使用备用方案规划...");
|
||||
const legacyResult = await runLegacyPlanGeneration(
|
||||
{ lat: room.lat, lng: room.lng },
|
||||
taggedIdeas,
|
||||
availableTime,
|
||||
onProgress,
|
||||
);
|
||||
days = legacyResult.days;
|
||||
}
|
||||
|
||||
// 3. Enrich transit info (best-effort)
|
||||
onProgress?.("正在查询交通信息...");
|
||||
await enrichTransitInfo(
|
||||
days as EnrichDay[],
|
||||
room.city ?? "上海",
|
||||
room.lat,
|
||||
room.lng,
|
||||
);
|
||||
|
||||
// 4. Save to DB
|
||||
const plan = await prisma.weekendPlan.create({
|
||||
data: {
|
||||
roomId,
|
||||
userId,
|
||||
planData: JSON.stringify({ days }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
days,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Legacy deterministic pipeline for plan generation
|
||||
|
||||
import { searchPois } from "@/lib/amap";
|
||||
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { selectIdeasForSlots, type TaggedIdea } from "./ideaSelection";
|
||||
import type { PlanGenAvailableTime } from "./index";
|
||||
|
||||
export const PLAN_PROGRESS_MESSAGES = {
|
||||
analyzing: "正在分析你们的想法...",
|
||||
searching: "正在搜索地点...",
|
||||
planning: "正在规划路线...",
|
||||
planningDay: (day: string) => `正在规划${day}...`,
|
||||
almostDone: "快好了...",
|
||||
} as const;
|
||||
|
||||
export async function runLegacyPlanGeneration(
|
||||
room: { lat: number; lng: number },
|
||||
taggedIdeas: TaggedIdea[],
|
||||
availableTime: PlanGenAvailableTime,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
|
||||
const at = availableTime;
|
||||
|
||||
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 (e) {
|
||||
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
|
||||
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
|
||||
|
||||
const candidates: ScheduleContext["candidates"] = {};
|
||||
for (const result of searchResults) {
|
||||
candidates[result.query] = toCandidates(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 (e) {
|
||||
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
|
||||
return { query: idea.searchQuery, pois: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const result of catResults) {
|
||||
candidates[result.query] = toCandidates(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,
|
||||
costLevel: i.costLevel ?? undefined,
|
||||
intensity: i.intensity ?? undefined,
|
||||
needsBooking: i.needsBooking ?? undefined,
|
||||
})),
|
||||
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 is NonNullable<typeof d> => d !== null);
|
||||
|
||||
if (days.length === 0) {
|
||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||
}
|
||||
|
||||
return { days };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Post-processing: compute real transit info from Amap and store in plan
|
||||
|
||||
import { getTransitDirection } from "@/lib/amap";
|
||||
|
||||
async function queryTransit(
|
||||
oLng: number,
|
||||
oLat: number,
|
||||
dLng: number,
|
||||
dLat: number,
|
||||
city: string,
|
||||
): Promise<{ durationMin: number; description: string } | null> {
|
||||
try {
|
||||
const result = await getTransitDirection({
|
||||
originLat: oLat,
|
||||
originLng: oLng,
|
||||
destLat: dLat,
|
||||
destLng: dLng,
|
||||
city,
|
||||
});
|
||||
if (!result) return null;
|
||||
return { durationMin: result.durationMin, description: result.description };
|
||||
} catch (e) {
|
||||
console.error("queryTransit failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type EnrichDay = {
|
||||
date: string;
|
||||
items: Record<string, unknown>[];
|
||||
summary: string;
|
||||
transitFromStart?: number;
|
||||
transitFromStartDescription?: string;
|
||||
transitToEnd?: number;
|
||||
transitToEndDescription?: string;
|
||||
};
|
||||
|
||||
export async function enrichTransitInfo(
|
||||
days: EnrichDay[],
|
||||
city: string,
|
||||
homeLat: number,
|
||||
homeLng: number,
|
||||
): Promise<void> {
|
||||
const cityParam = city || "上海";
|
||||
|
||||
for (const day of days) {
|
||||
const items = day.items;
|
||||
|
||||
// From home to first activity
|
||||
if (items.length > 0 && homeLat && homeLng) {
|
||||
const dLat = Number(items[0].lat);
|
||||
const dLng = Number(items[0].lng);
|
||||
if (dLat && dLng) {
|
||||
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
day.transitFromStart = result.durationMin;
|
||||
day.transitFromStartDescription = result.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Between consecutive activities
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
const oLat = Number(items[i].lat);
|
||||
const oLng = Number(items[i].lng);
|
||||
const dLat = Number(items[i + 1].lat);
|
||||
const dLng = Number(items[i + 1].lng);
|
||||
if (!oLat || !oLng || !dLat || !dLng) continue;
|
||||
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
items[i].transitToNext = result.durationMin;
|
||||
items[i].transitDescription = result.description;
|
||||
}
|
||||
}
|
||||
|
||||
// From last activity back home
|
||||
if (items.length > 0 && homeLat && homeLng) {
|
||||
const last = items[items.length - 1];
|
||||
const oLat = Number(last.lat);
|
||||
const oLng = Number(last.lng);
|
||||
if (oLat && oLng) {
|
||||
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
|
||||
if (result) {
|
||||
day.transitToEnd = result.durationMin;
|
||||
day.transitToEndDescription = result.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
|
||||
* Returns a Date at 00:00 of that day.
|
||||
*/
|
||||
export function nextWeekday(dayLabel: string, from: Date): Date {
|
||||
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
|
||||
const d = new Date(from);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const diff = (targetDow - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
|
||||
return d;
|
||||
}
|
||||
|
||||
export function computeEndTime(planData: string, now: Date): Date | null {
|
||||
try {
|
||||
const parsed = JSON.parse(planData);
|
||||
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
|
||||
if (!days?.length) return null;
|
||||
|
||||
const lastDay = days[days.length - 1];
|
||||
const lastItem = lastDay.items[lastDay.items.length - 1];
|
||||
if (!lastItem) return null;
|
||||
|
||||
const base = nextWeekday(lastDay.date, now);
|
||||
const [h, m] = lastItem.time.split(":").map(Number);
|
||||
base.setHours(h, m, 0, 0);
|
||||
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
||||
|
||||
if (base.getTime() < now.getTime()) {
|
||||
base.setDate(base.getDate() + 7);
|
||||
}
|
||||
|
||||
return base;
|
||||
} catch (e) {
|
||||
console.error("computeEndTime failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePlanUpdate(
|
||||
planId: string,
|
||||
userId: string,
|
||||
action: string,
|
||||
days: unknown[],
|
||||
): Promise<{ ok: true; endTime?: Date | null }> {
|
||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||
if (!plan) throw new ApiError("计划不存在", 404);
|
||||
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
||||
|
||||
const act = action || "accept";
|
||||
|
||||
if (act === "accept") {
|
||||
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
|
||||
const endTime = computeEndTime(plan.planData, new Date());
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: { status: "accepted", endTime },
|
||||
});
|
||||
return { ok: true, endTime };
|
||||
}
|
||||
|
||||
if (act === "complete" || act === "expire") {
|
||||
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: { status: act === "complete" ? "completed" : "expired" },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (act === "update_plan") {
|
||||
if (plan.status !== "active" && plan.status !== "accepted") {
|
||||
throw new ApiError("只能编辑进行中的计划", 400);
|
||||
}
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new ApiError("days 数据无效", 400);
|
||||
}
|
||||
const newPlanData = JSON.stringify({ days });
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: {
|
||||
planData: newPlanData,
|
||||
...(plan.status === "accepted"
|
||||
? { endTime: computeEndTime(newPlanData, new Date()) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
throw new ApiError("无效的操作", 400);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export async function getLatestPlan(roomId: string, userId: string) {
|
||||
const plan = await prisma.weekendPlan.findFirst({
|
||||
where: { roomId, userId, status: "accepted" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, endTime: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!plan) return { plan: null };
|
||||
const parsed = JSON.parse(plan.planData);
|
||||
return {
|
||||
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPendingPlans(userId: string) {
|
||||
const plans = await prisma.weekendPlan.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "accepted",
|
||||
endTime: { not: null, lt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, roomId: true, createdAt: true },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
const result = await Promise.all(
|
||||
plans.map(async (p) => {
|
||||
const room = await prisma.blindBoxRoom.findUnique({
|
||||
where: { id: p.roomId },
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const parsed = JSON.parse(p.planData);
|
||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||
return {
|
||||
id: p.id,
|
||||
roomName: room?.name ?? "未知房间",
|
||||
roomCode: room?.code ?? "",
|
||||
date: days.map((d) => d.date).join(" + "),
|
||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { pending: result };
|
||||
}
|
||||
|
||||
export async function getHistoryPlans(userId: string) {
|
||||
const plans = await prisma.weekendPlan.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: ["completed", "expired"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const result = await Promise.all(
|
||||
plans.map(async (p) => {
|
||||
const room = await prisma.blindBoxRoom.findUnique({
|
||||
where: { id: p.roomId },
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const parsed = JSON.parse(p.planData);
|
||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||
return {
|
||||
id: p.id,
|
||||
status: p.status,
|
||||
roomName: room?.name ?? "未知房间",
|
||||
roomCode: room?.code ?? "",
|
||||
date: days.map((d) => d.date).join(" + "),
|
||||
dayCount: days.length,
|
||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { history: result };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Restaurant, SceneType } from "@/types";
|
||||
|
||||
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface RoomData {
|
||||
users: string[];
|
||||
restaurants: Restaurant[];
|
||||
likes: Record<string, string[]>;
|
||||
swipeCounts: Record<string, number>;
|
||||
match: string | null;
|
||||
creatorId: string;
|
||||
locked: boolean;
|
||||
kickedUsers: string[];
|
||||
scene: SceneType;
|
||||
}
|
||||
|
||||
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
function generateRoomId(): string {
|
||||
let id = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
let lastCleanup = 0;
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
async function cleanupExpiredRooms() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
|
||||
try {
|
||||
const { count } = await prisma.room.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
if (count > 0) {
|
||||
console.log(`Cleaned up ${count} expired room(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Room cleanup failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
|
||||
await cleanupExpiredRooms();
|
||||
|
||||
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
||||
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
const roomId = generateRoomId();
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.room.create({
|
||||
data: { id: roomId, creatorId, scene, expiresAt },
|
||||
});
|
||||
|
||||
if (restaurants.length > 0) {
|
||||
await tx.roomRestaurant.createMany({
|
||||
data: restaurants.map((r) => ({
|
||||
roomId,
|
||||
restaurantData: JSON.stringify(r),
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
return roomId;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("无法生成唯一房间号,请稍后重试");
|
||||
}
|
||||
|
||||
export async function getRoomData(roomId: string): Promise<RoomData | null> {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: {
|
||||
members: true,
|
||||
restaurants: true,
|
||||
likes: true,
|
||||
swipes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!room) return null;
|
||||
if (room.expiresAt < new Date()) {
|
||||
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
|
||||
console.error(`Failed to delete expired room ${roomId}:`, e);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const users = room.members.filter((m) => !m.kicked).map((m) => m.userId);
|
||||
const kickedUsers = room.members.filter((m) => m.kicked).map((m) => m.userId);
|
||||
const restaurants: Restaurant[] = room.restaurants.map((r) => JSON.parse(r.restaurantData));
|
||||
|
||||
const likes: Record<string, string[]> = {};
|
||||
for (const like of room.likes) {
|
||||
if (!likes[like.restaurantId]) likes[like.restaurantId] = [];
|
||||
likes[like.restaurantId].push(like.userId);
|
||||
}
|
||||
|
||||
const swipeCounts: Record<string, number> = {};
|
||||
for (const swipe of room.swipes) {
|
||||
swipeCounts[swipe.userId] = swipe.count;
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
restaurants,
|
||||
likes,
|
||||
swipeCounts,
|
||||
match: room.match,
|
||||
creatorId: room.creatorId,
|
||||
locked: room.locked,
|
||||
kickedUsers,
|
||||
scene: room.scene as SceneType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-room mutex to serialize concurrent read-modify-write operations.
|
||||
*/
|
||||
const roomLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = roomLocks.get(roomId) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
roomLocks.set(roomId, next);
|
||||
next.finally(() => {
|
||||
if (roomLocks.get(roomId) === next) {
|
||||
roomLocks.delete(roomId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write with per-room serialization.
|
||||
* Translates RoomData mutations into relational table operations.
|
||||
*/
|
||||
export async function atomicUpdateRoom(
|
||||
roomId: string,
|
||||
updater: (data: RoomData) => RoomData,
|
||||
): Promise<RoomData | null> {
|
||||
return withRoomLock(roomId, async () => {
|
||||
const current = await getRoomData(roomId);
|
||||
if (!current) return null;
|
||||
|
||||
const updated = updater(current);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update room-level fields
|
||||
await tx.room.update({
|
||||
where: { id: roomId },
|
||||
data: {
|
||||
scene: updated.scene,
|
||||
locked: updated.locked,
|
||||
match: updated.match,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync all members (users + kicked)
|
||||
const allMemberIds = new Set([...updated.users, ...updated.kickedUsers]);
|
||||
const kickedSet = new Set(updated.kickedUsers);
|
||||
|
||||
for (const userId of allMemberIds) {
|
||||
await tx.roomMember.upsert({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
update: { kicked: kickedSet.has(userId) },
|
||||
create: { roomId, userId, kicked: kickedSet.has(userId) },
|
||||
});
|
||||
}
|
||||
|
||||
// Sync likes: rebuild from updated data
|
||||
if (JSON.stringify(current.likes) !== JSON.stringify(updated.likes)) {
|
||||
await tx.roomLike.deleteMany({ where: { roomId } });
|
||||
const likeRows: { roomId: string; userId: string; restaurantId: string }[] = [];
|
||||
for (const [restaurantId, userIds] of Object.entries(updated.likes)) {
|
||||
for (const userId of userIds) {
|
||||
likeRows.push({ roomId, userId, restaurantId });
|
||||
}
|
||||
}
|
||||
if (likeRows.length > 0) {
|
||||
await tx.roomLike.createMany({ data: likeRows });
|
||||
}
|
||||
}
|
||||
|
||||
// Sync swipes
|
||||
if (JSON.stringify(current.swipeCounts) !== JSON.stringify(updated.swipeCounts)) {
|
||||
for (const [userId, count] of Object.entries(updated.swipeCounts)) {
|
||||
await tx.roomSwipe.upsert({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
update: { count },
|
||||
create: { roomId, userId, count },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const IdeaTagsSchema = z.object({
|
||||
category: z.enum([
|
||||
"dining", "outdoor", "entertainment", "shopping", "sports",
|
||||
"culture", "relaxation", "experience", "nature",
|
||||
]),
|
||||
timeSlot: z.enum(["morning", "afternoon", "evening", "flexible", "all_day"]),
|
||||
estimatedMinutes: z.number().int().positive(),
|
||||
costLevel: z.enum(["free", "budget", "moderate", "premium"]),
|
||||
intensity: z.enum(["chill", "moderate", "active"]),
|
||||
needsBooking: z.boolean(),
|
||||
searchQuery: z.string().min(1),
|
||||
searchType: z.enum(["brand", "place", "category"]),
|
||||
});
|
||||
|
||||
export type IdeaTagsSchema = z.infer<typeof IdeaTagsSchema>;
|
||||
|
||||
export const SuggestIdeasSchema = z.object({
|
||||
suggestions: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PlanItemSchema = z.object({
|
||||
time: z.string(),
|
||||
activity: z.string(),
|
||||
poi: z.string(),
|
||||
address: z.string(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
duration: z.number(),
|
||||
reason: z.string(),
|
||||
transitToNext: z.number().optional(),
|
||||
transitDescription: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GenerateScheduleSchema = z.object({
|
||||
items: z.array(PlanItemSchema).min(1),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export const RefinePlanSchema = z.object({
|
||||
days: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
items: z.array(z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
).min(1),
|
||||
});
|
||||
|
||||
export const SuggestAlternativesSchema = z.object({
|
||||
alternatives: z.array(
|
||||
z.object({
|
||||
activity: z.string(),
|
||||
searchQuery: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AvailableTimeSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
startHour: z.number().int().min(0).max(23),
|
||||
endHour: z.number().int().min(1).max(24),
|
||||
}).refine((v) => v.endHour > v.startHour, { message: "endHour must be after startHour" });
|
||||
|
||||
export const CreatePlanSchema = z.object({
|
||||
roomId: z.string().min(1, "roomId 不能为空"),
|
||||
availableTime: AvailableTimeSchema,
|
||||
});
|
||||
|
||||
export const UpdatePlanSchema = z.object({
|
||||
planId: z.string().min(1, "planId 不能为空"),
|
||||
action: z.string().optional(),
|
||||
days: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const CreateRoomSchema = z.object({
|
||||
restaurants: z.array(z.unknown()).min(1),
|
||||
scene: z.string().optional(),
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Restaurant, SceneType } from "@/types";
|
||||
|
||||
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface RoomData {
|
||||
users: string[];
|
||||
restaurants: Restaurant[];
|
||||
likes: Record<string, string[]>;
|
||||
swipeCounts: Record<string, number>;
|
||||
match: string | null;
|
||||
creatorId: string;
|
||||
locked: boolean;
|
||||
kickedUsers: string[];
|
||||
scene: SceneType;
|
||||
}
|
||||
|
||||
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
function generateRoomId(): string {
|
||||
let id = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalize(raw: Partial<RoomData>): RoomData {
|
||||
return {
|
||||
users: raw.users ?? [],
|
||||
restaurants: raw.restaurants ?? [],
|
||||
likes: raw.likes ?? {},
|
||||
swipeCounts: raw.swipeCounts ?? {},
|
||||
match: raw.match ?? null,
|
||||
creatorId: raw.creatorId ?? "",
|
||||
locked: raw.locked ?? false,
|
||||
kickedUsers: raw.kickedUsers ?? [],
|
||||
scene: raw.scene ?? "eat",
|
||||
};
|
||||
}
|
||||
|
||||
let lastCleanup = 0;
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // at most once per hour
|
||||
|
||||
async function cleanupExpiredRooms() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
|
||||
try {
|
||||
const { count } = await prisma.room.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
if (count > 0) {
|
||||
console.log(`Cleaned up ${count} expired room(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Room cleanup failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
|
||||
await cleanupExpiredRooms();
|
||||
|
||||
const data: RoomData = {
|
||||
users: [],
|
||||
restaurants,
|
||||
likes: {},
|
||||
swipeCounts: {},
|
||||
match: null,
|
||||
creatorId,
|
||||
locked: false,
|
||||
kickedUsers: [],
|
||||
scene,
|
||||
};
|
||||
|
||||
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
||||
const payload = JSON.stringify(data);
|
||||
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
const roomId = generateRoomId();
|
||||
try {
|
||||
await prisma.room.create({
|
||||
data: { id: roomId, data: payload, expiresAt },
|
||||
});
|
||||
return roomId;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("无法生成唯一房间号,请稍后重试");
|
||||
}
|
||||
|
||||
export async function getRoomData(
|
||||
roomId: string,
|
||||
): Promise<RoomData | null> {
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) return null;
|
||||
if (room.expiresAt < new Date()) {
|
||||
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
|
||||
console.error(`Failed to delete expired room ${roomId}:`, e);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return normalize(JSON.parse(room.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-room mutex to serialize concurrent read-modify-write operations.
|
||||
* SQLite doesn't support row-level locks (SELECT ... FOR UPDATE),
|
||||
* so we use an application-level lock to prevent lost updates.
|
||||
*/
|
||||
const roomLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = roomLocks.get(roomId) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
roomLocks.set(roomId, next);
|
||||
// Cleanup the lock entry when the chain settles
|
||||
next.finally(() => {
|
||||
if (roomLocks.get(roomId) === next) {
|
||||
roomLocks.delete(roomId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write with per-room serialization.
|
||||
* Uses an application-level mutex to prevent concurrent lost updates,
|
||||
* since SQLite lacks row-level locking.
|
||||
*/
|
||||
export async function atomicUpdateRoom(
|
||||
roomId: string,
|
||||
updater: (data: RoomData) => RoomData,
|
||||
): Promise<RoomData | null> {
|
||||
return withRoomLock(roomId, () =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const room = await tx.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) return null;
|
||||
|
||||
const data = normalize(JSON.parse(room.data));
|
||||
const updated = updater(data);
|
||||
|
||||
await tx.room.update({
|
||||
where: { id: roomId },
|
||||
data: { data: JSON.stringify(updated) },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user