From 04a45c4894f8f03d753b1ddebe2b1a1cfa59bb6a Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 2 Mar 2026 12:29:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E8=BE=85=E5=8A=A9=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E8=A1=8C=E7=A8=8B=EF=BC=88=E8=87=AA=E7=84=B6=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E8=B0=83=E6=95=B4=20+=20=E5=8D=95=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E6=8E=A8=E8=8D=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 路由复用 --- src/app/api/blindbox/plan/refine/route.ts | 14 +++ .../api/blindbox/plan/suggest-item/route.ts | 49 +++++++++ src/app/blindbox/[code]/page.tsx | 19 ++++ src/components/BlindboxPlan.tsx | 101 +++++++++++++++++- src/lib/ai.ts | 101 ++++++++++++++++++ src/lib/blindboxPlanGen.ts | 2 +- 6 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 src/app/api/blindbox/plan/refine/route.ts create mode 100644 src/app/api/blindbox/plan/suggest-item/route.ts diff --git a/src/app/api/blindbox/plan/refine/route.ts b/src/app/api/blindbox/plan/refine/route.ts new file mode 100644 index 0000000..bc0814e --- /dev/null +++ b/src/app/api/blindbox/plan/refine/route.ts @@ -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 }); +}); diff --git a/src/app/api/blindbox/plan/suggest-item/route.ts b/src/app/api/blindbox/plan/suggest-item/route.ts new file mode 100644 index 0000000..0fe2e84 --- /dev/null +++ b/src/app/api/blindbox/plan/suggest-item/route.ts @@ -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 }); +}); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index cdcba38..5310857 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -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); diff --git a/src/components/BlindboxPlan.tsx b/src/components/BlindboxPlan.tsx index 37e4528..429affd 100644 --- a/src/components/BlindboxPlan.tsx +++ b/src/components/BlindboxPlan.tsx @@ -38,6 +38,15 @@ import Button from "@/components/Button"; import Modal from "@/components/Modal"; import type { WeekendPlanData, PlanItem } from "@/types"; +interface AltSuggestion { + activity: string; + poi: string; + address: string; + lat: number; + lng: number; + reason: string; +} + interface BlindboxPlanProps { days: WeekendPlanData[]; onAccept: () => void; @@ -49,6 +58,7 @@ interface BlindboxPlanProps { onDaysChange?: (newDays: WeekendPlanData[]) => void; /** "lng,lat" 格式,用于 POI 搜索附近优先 */ location?: string; + onRefine?: (instruction: string) => Promise; } function guessCategory(activity: string): string | null { @@ -265,6 +275,7 @@ export default function BlindboxPlan({ regenerating, onDaysChange, location, + onRefine, }: BlindboxPlanProps) { const [dayIndex, setDayIndex] = useState(0); const scrollRef = useRef(null); @@ -275,6 +286,10 @@ export default function BlindboxPlan({ const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null); const [draft, setDraft] = useState(null); + const [refineInput, setRefineInput] = useState(""); + const [refining, setRefining] = useState(false); + const [suggestingAlt, setSuggestingAlt] = useState(false); + const [altSuggestions, setAltSuggestions] = useState([]); useEffect(() => { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); @@ -314,6 +329,7 @@ export default function BlindboxPlan({ onDaysChange(newDays); setEditingItem(null); setDraft(null); + setAltSuggestions([]); } // Cross-day move: immediately call onDaysChange @@ -338,6 +354,34 @@ export default function BlindboxPlan({ setDraft(null); } + async function handleRefine() { + if (!refineInput.trim() || refining || !onRefine) return; + setRefining(true); + try { + await onRefine(refineInput.trim()); + setRefineInput(""); + } finally { + setRefining(false); + } + } + + async function handleSuggestAlt() { + if (!draft || suggestingAlt) return; + setSuggestingAlt(true); + try { + const res = await fetch("/api/blindbox/plan/suggest-item", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activity: draft.activity, time: draft.time, location }), + }); + if (!res.ok) return; + const data = await res.json(); + setAltSuggestions(data.suggestions ?? []); + } finally { + setSuggestingAlt(false); + } + } + if (!currentDay) return null; return ( @@ -446,6 +490,27 @@ export default function BlindboxPlan({ {/* Fixed bottom bar — actions + day navigation */}
+ {/* Refine input (Plan A) */} + {onRefine && ( +
+ setRefineInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleRefine(); }} + placeholder="告诉 AI 你想怎么改..." + disabled={refining} + className="h-9 flex-1 rounded-xl bg-surface px-3 text-sm outline-none ring-1 ring-border focus:ring-purple-600 disabled:opacity-50" + /> + +
+ )} + {/* Day navigation */} {days.length > 1 && (
@@ -513,13 +578,13 @@ export default function BlindboxPlan({
{/* Edit item modal */} - { setEditingItem(null); setDraft(null); }} variant="sheet"> + { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet"> {draft && editingItem && (

编辑活动

+ ) : ( +
+ 选一个替代方案 + {altSuggestions.map((alt, i) => ( + + ))} + +
+ )} + {days.length > 1 && (