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:
@@ -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 });
|
||||||
|
});
|
||||||
@@ -627,6 +627,24 @@ export default function BlindboxRoomPage() {
|
|||||||
}
|
}
|
||||||
}, [planId, profile, planDays, planAccepted, toast]);
|
}, [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). */
|
/** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */
|
||||||
const handleLeaveOrDelete = async () => {
|
const handleLeaveOrDelete = async () => {
|
||||||
if (!confirmLeave) {
|
if (!confirmLeave) {
|
||||||
@@ -1112,6 +1130,7 @@ export default function BlindboxRoomPage() {
|
|||||||
accepted={planAccepted}
|
accepted={planAccepted}
|
||||||
regenerating={generating}
|
regenerating={generating}
|
||||||
onDaysChange={handlePlanDaysChange}
|
onDaysChange={handlePlanDaysChange}
|
||||||
|
onRefine={handleRefine}
|
||||||
location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined}
|
location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined}
|
||||||
onAccept={async () => {
|
onAccept={async () => {
|
||||||
setPlanAccepted(true);
|
setPlanAccepted(true);
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ import Button from "@/components/Button";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import type { WeekendPlanData, PlanItem } from "@/types";
|
import type { WeekendPlanData, PlanItem } from "@/types";
|
||||||
|
|
||||||
|
interface AltSuggestion {
|
||||||
|
activity: string;
|
||||||
|
poi: string;
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BlindboxPlanProps {
|
interface BlindboxPlanProps {
|
||||||
days: WeekendPlanData[];
|
days: WeekendPlanData[];
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
@@ -49,6 +58,7 @@ interface BlindboxPlanProps {
|
|||||||
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
||||||
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
||||||
location?: string;
|
location?: string;
|
||||||
|
onRefine?: (instruction: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function guessCategory(activity: string): string | null {
|
function guessCategory(activity: string): string | null {
|
||||||
@@ -265,6 +275,7 @@ export default function BlindboxPlan({
|
|||||||
regenerating,
|
regenerating,
|
||||||
onDaysChange,
|
onDaysChange,
|
||||||
location,
|
location,
|
||||||
|
onRefine,
|
||||||
}: BlindboxPlanProps) {
|
}: BlindboxPlanProps) {
|
||||||
const [dayIndex, setDayIndex] = useState(0);
|
const [dayIndex, setDayIndex] = useState(0);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -275,6 +286,10 @@ export default function BlindboxPlan({
|
|||||||
|
|
||||||
const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null);
|
const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null);
|
||||||
const [draft, setDraft] = useState<PlanItem | null>(null);
|
const [draft, setDraft] = useState<PlanItem | null>(null);
|
||||||
|
const [refineInput, setRefineInput] = useState("");
|
||||||
|
const [refining, setRefining] = useState(false);
|
||||||
|
const [suggestingAlt, setSuggestingAlt] = useState(false);
|
||||||
|
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
@@ -314,6 +329,7 @@ export default function BlindboxPlan({
|
|||||||
onDaysChange(newDays);
|
onDaysChange(newDays);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
setAltSuggestions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-day move: immediately call onDaysChange
|
// Cross-day move: immediately call onDaysChange
|
||||||
@@ -338,6 +354,34 @@ export default function BlindboxPlan({
|
|||||||
setDraft(null);
|
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;
|
if (!currentDay) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -446,6 +490,27 @@ export default function BlindboxPlan({
|
|||||||
|
|
||||||
{/* Fixed bottom bar — actions + day navigation */}
|
{/* Fixed bottom bar — actions + day navigation */}
|
||||||
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
|
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
|
||||||
|
{/* Refine input (Plan A) */}
|
||||||
|
{onRefine && (
|
||||||
|
<div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2">
|
||||||
|
<input
|
||||||
|
value={refineInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleRefine}
|
||||||
|
disabled={!refineInput.trim() || refining}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl bg-purple-600/15 text-purple-400 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{refining ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Day navigation */}
|
{/* Day navigation */}
|
||||||
{days.length > 1 && (
|
{days.length > 1 && (
|
||||||
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
|
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
|
||||||
@@ -513,13 +578,13 @@ export default function BlindboxPlan({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit item modal */}
|
{/* Edit item modal */}
|
||||||
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); }} variant="sheet">
|
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet">
|
||||||
{draft && editingItem && (
|
{draft && editingItem && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingItem(null); setDraft(null); }}
|
onClick={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }}
|
||||||
className="text-muted active:text-foreground"
|
className="text-muted active:text-foreground"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -568,6 +633,38 @@ export default function BlindboxPlan({
|
|||||||
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
|
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* AI 推荐替代 (Plan B) */}
|
||||||
|
{altSuggestions.length === 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSuggestAlt}
|
||||||
|
disabled={suggestingAlt}
|
||||||
|
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{suggestingAlt
|
||||||
|
? <><Loader2 size={12} className="animate-spin" /> 正在推荐...</>
|
||||||
|
: <><Sparkles size={12} /> AI 推荐替代方案</>}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[11px] font-medium text-muted">选一个替代方案</span>
|
||||||
|
{altSuggestions.map((alt, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft({ ...draft, ...alt });
|
||||||
|
setAltSuggestions([]);
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold text-heading">{alt.activity}</p>
|
||||||
|
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
|
||||||
|
<p className="text-[10px] text-dim italic">{alt.reason}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim">取消</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{days.length > 1 && (
|
{days.length > 1 && (
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-[11px] font-medium text-muted">移动到其他天</span>
|
<span className="text-[11px] font-medium text-muted">移动到其他天</span>
|
||||||
|
|||||||
+101
@@ -230,6 +230,107 @@ ${Object.entries(ctx.candidates)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// refinePlan: adjust an existing plan based on a natural-language instruction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const REFINE_PLAN_SYSTEM_PROMPT = `你是一个周末行程调整助手。根据用户指令修改现有行程。规则:
|
||||||
|
1. 只改用户明确要求的部分,其余原样保留
|
||||||
|
2. 时间安排合理,活动间留交通时间
|
||||||
|
3. 若需新增活动,poi/address 可留空字符串,lat/lng 填 0
|
||||||
|
4. 严格按输入的 JSON 结构输出完整 days 数组`;
|
||||||
|
|
||||||
|
export async function refinePlan(
|
||||||
|
days: import("@/types").WeekendPlanData[],
|
||||||
|
instruction: string,
|
||||||
|
): Promise<import("@/types").WeekendPlanData[] | null> {
|
||||||
|
try {
|
||||||
|
const client = getClient();
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: REFINE_PLAN_SYSTEM_PROMPT },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `当前行程:\n${JSON.stringify(days)}\n\n用户指令:${instruction}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
temperature: 0.5,
|
||||||
|
max_tokens: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.choices[0]?.message?.content;
|
||||||
|
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;
|
||||||
|
|
||||||
|
return result as import("@/types").WeekendPlanData[];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// suggestAlternativeItems: recommend 3 alternatives for a single activity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SUGGEST_ALT_SYSTEM_PROMPT = `你是一个周末活动推荐助手。根据当前活动推荐 3 个类似但不同的替代方案,用于替换行程中的该活动。
|
||||||
|
|
||||||
|
每个替代方案需要包含:
|
||||||
|
- activity: 活动描述(5-15字,口语化)
|
||||||
|
- searchQuery: 高德地图搜索关键词
|
||||||
|
- reason: 推荐理由(一句话)
|
||||||
|
|
||||||
|
只返回 JSON:{ "alternatives": [{ "activity", "searchQuery", "reason" }] }`;
|
||||||
|
|
||||||
|
export async function suggestAlternativeItems(
|
||||||
|
activity: string,
|
||||||
|
time: string,
|
||||||
|
): Promise<Array<{ activity: string; searchQuery: string; reason: string }> | null> {
|
||||||
|
try {
|
||||||
|
const client = getClient();
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SUGGEST_ALT_SYSTEM_PROMPT },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `当前活动:${activity}${time ? `(时间:${time})` : ""}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.choices[0]?.message?.content;
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!Array.isArray(parsed.alternatives)) 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 }>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Generic tool-calling agent loop
|
// Generic tool-calling agent loop
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
|
|||||||
// POI search (shared by both paths)
|
// POI search (shared by both paths)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function searchPois(
|
export async function searchPois(
|
||||||
query: string,
|
query: string,
|
||||||
searchType: string,
|
searchType: string,
|
||||||
anchorLat: number,
|
anchorLat: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user