feat: AI 辅助修改行程(自然语言调整 + 单活动替代推荐)

- 新增 refinePlan / suggestAlternativeItems 到 ai.ts
- 新增 POST /api/blindbox/plan/refine(整体行程调整)
- 新增 POST /api/blindbox/plan/suggest-item(单活动 AI 替代 + POI 搜索)
- BlindboxPlan 底部新增自然语言输入框(方案 A)
- 编辑 modal 内新增 AI 推荐替代方案卡片(方案 B)
- export searchPois 供 suggest-item 路由复用
This commit is contained in:
2026-03-02 12:29:21 +08:00
parent 4e6a3e007c
commit 04a45c4894
6 changed files with 283 additions and 3 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { refinePlan } from "@/lib/ai";
export const POST = apiHandler(async (req) => {
const { userId, instruction, days } = await req.json();
requireUserId(userId);
if (!instruction?.trim()) throw new ApiError("指令不能为空", 400);
if (!Array.isArray(days) || days.length === 0) throw new ApiError("days 无效", 400);
const newDays = await refinePlan(days, instruction);
if (!newDays) throw new ApiError("AI 调整失败,请重试", 500);
return NextResponse.json({ days: newDays });
});
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { suggestAlternativeItems } from "@/lib/ai";
import { searchPois } from "@/lib/blindboxPlanGen";
export const POST = apiHandler(async (req) => {
const { activity, time, location } = await req.json();
if (!activity) throw new ApiError("activity 不能为空", 400);
const alts = await suggestAlternativeItems(activity, time ?? "");
if (!alts) throw new ApiError("AI 推荐失败,请重试", 500);
// Parse location "lng,lat"
let anchorLat = 31.23, anchorLng = 121.47; // default Shanghai
if (location) {
const [lng, lat] = location.split(",").map(Number);
if (!isNaN(lat) && !isNaN(lng)) { anchorLat = lat; anchorLng = lng; }
}
// Parallel POI search with per-item fallback
const results = await Promise.all(
alts.map(async (alt) => {
try {
const pois = await searchPois(alt.searchQuery, "place", anchorLat, anchorLng);
const top = pois[0];
if (top) {
return {
activity: alt.activity,
poi: top.name,
address: top.address,
lat: top.lat,
lng: top.lng,
reason: alt.reason,
};
}
} catch { /* ignore, use fallback */ }
return {
activity: alt.activity,
poi: alt.searchQuery,
address: "",
lat: 0,
lng: 0,
reason: alt.reason,
};
}),
);
return NextResponse.json({ suggestions: results });
});
+19
View File
@@ -627,6 +627,24 @@ export default function BlindboxRoomPage() {
}
}, [planId, profile, planDays, planAccepted, toast]);
const handleRefine = useCallback(async (instruction: string) => {
if (!profile || !planDays.length) return;
const prevDays = planDays;
try {
const res = await fetch("/api/blindbox/plan/refine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, instruction, days: planDays }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
const data = await res.json();
await handlePlanDaysChange(data.days);
} catch (e) {
setPlanDays(prevDays);
toast.show(e instanceof Error ? e.message : "AI 调整失败");
}
}, [profile, planDays, handlePlanDaysChange, toast]);
/** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */
const handleLeaveOrDelete = async () => {
if (!confirmLeave) {
@@ -1112,6 +1130,7 @@ export default function BlindboxRoomPage() {
accepted={planAccepted}
regenerating={generating}
onDaysChange={handlePlanDaysChange}
onRefine={handleRefine}
location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined}
onAccept={async () => {
setPlanAccepted(true);