feat: 改进计划生成体验与 AI 提示词
- 生成中状态改为滚动日志列表,底部新增消息自动滚动 - 返回想法池不再清空计划,pool 页面保留"待确认计划"横幅 - 换方案时才清空旧计划 - 提示词补充午餐/晚餐时间窗口约束(午餐11:30-13:00,晚餐17:30-19:30) - get_travel_time 从驾车改为公共交通,阈值从30分钟调整为45分钟
This commit is contained in:
@@ -116,7 +116,8 @@ export default function BlindboxRoomPage() {
|
|||||||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||||||
const [planAccepted, setPlanAccepted] = useState(false);
|
const [planAccepted, setPlanAccepted] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [planStatusMessage, setPlanStatusMessage] = useState("正在分析你们的想法...");
|
const [planStatusMessages, setPlanStatusMessages] = useState<string[]>([]);
|
||||||
|
const planLogRef = useRef<HTMLDivElement>(null);
|
||||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||||
const [activeContract, setActiveContract] = useState<{
|
const [activeContract, setActiveContract] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -286,6 +287,12 @@ export default function BlindboxRoomPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [activeContract?.endTime]);
|
}, [activeContract?.endTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (planLogRef.current) {
|
||||||
|
planLogRef.current.scrollTop = planLogRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [planStatusMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMember && inputRef.current) {
|
if (isMember && inputRef.current) {
|
||||||
const t = setTimeout(() => inputRef.current?.focus(), 300);
|
const t = setTimeout(() => inputRef.current?.focus(), 300);
|
||||||
@@ -343,7 +350,7 @@ export default function BlindboxRoomPage() {
|
|||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setPhase("planning");
|
setPhase("planning");
|
||||||
setError("");
|
setError("");
|
||||||
setPlanStatusMessage(PLAN_STATUS_STEPS[0]);
|
setPlanStatusMessages([PLAN_STATUS_STEPS[0]]);
|
||||||
const payload = {
|
const payload = {
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
userId: profile.id,
|
userId: profile.id,
|
||||||
@@ -352,7 +359,7 @@ export default function BlindboxRoomPage() {
|
|||||||
const stepRef = { current: 0 };
|
const stepRef = { current: 0 };
|
||||||
const fallbackTimer = setInterval(() => {
|
const fallbackTimer = setInterval(() => {
|
||||||
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
|
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
|
||||||
setPlanStatusMessage(PLAN_STATUS_STEPS[stepRef.current]);
|
setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]);
|
||||||
}, 2800);
|
}, 2800);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan/stream", {
|
const res = await fetch("/api/blindbox/plan/stream", {
|
||||||
@@ -381,7 +388,7 @@ export default function BlindboxRoomPage() {
|
|||||||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||||
else if (line.startsWith("data:")) data = line.slice(5).trim();
|
else if (line.startsWith("data:")) data = line.slice(5).trim();
|
||||||
}
|
}
|
||||||
if (eventType === "status") setPlanStatusMessage(data);
|
if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]);
|
||||||
else if (eventType === "plan") {
|
else if (eventType === "plan") {
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
setPlanId(parsed.id);
|
setPlanId(parsed.id);
|
||||||
@@ -916,6 +923,21 @@ export default function BlindboxRoomPage() {
|
|||||||
{error}
|
{error}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{planDays.length > 0 && !planAccepted && (
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setPhase("plan_reveal")}
|
||||||
|
className="flex w-full items-center justify-between rounded-xl bg-purple-600/10 px-4 py-2.5 ring-1 ring-purple-500/30 active:bg-purple-600/20"
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={14} className="text-purple-400" />
|
||||||
|
<span className="text-xs font-bold text-purple-300">有一个待确认的计划</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={14} className="text-purple-400" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1017,23 +1039,42 @@ export default function BlindboxRoomPage() {
|
|||||||
{phase === "planning" && (
|
{phase === "planning" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="planning"
|
key="planning"
|
||||||
className="mt-8 flex flex-col items-center gap-4"
|
className="mt-8 flex flex-col items-center gap-4 w-full px-4"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative flex h-20 w-20 items-center justify-center"
|
className="relative flex h-16 w-16 items-center justify-center"
|
||||||
animate={{ rotate: [0, 360] }}
|
animate={{ rotate: [0, 360] }}
|
||||||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
|
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
|
||||||
<Sparkles size={28} className="relative text-purple-400" />
|
<Sparkles size={24} className="relative text-purple-400" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<p className="text-sm font-bold text-purple-300 animate-pulse">
|
<div className="w-full max-w-sm rounded-xl bg-surface/60 ring-1 ring-border/60 overflow-hidden">
|
||||||
{planStatusMessage}
|
<div
|
||||||
</p>
|
ref={planLogRef}
|
||||||
<p className="text-[11px] text-dim">搜索地点 · 优化路线 · 安排时间</p>
|
className="h-40 overflow-y-auto scrollbar-none p-3 flex flex-col gap-1.5"
|
||||||
|
>
|
||||||
|
{planStatusMessages.map((msg, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={`flex items-center gap-2 text-xs ${i === planStatusMessages.length - 1 ? "text-purple-300 font-medium" : "text-dim"}`}
|
||||||
|
>
|
||||||
|
{i === planStatusMessages.length - 1 ? (
|
||||||
|
<Loader2 size={11} className="shrink-0 animate-spin text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-[10px] text-purple-500">✓</span>
|
||||||
|
)}
|
||||||
|
{msg}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1076,14 +1117,14 @@ export default function BlindboxRoomPage() {
|
|||||||
}, 1500));
|
}, 1500));
|
||||||
}}
|
}}
|
||||||
onRegenerate={() => {
|
onRegenerate={() => {
|
||||||
|
setPlanId(null);
|
||||||
|
setPlanDays([]);
|
||||||
|
setPlanAccepted(false);
|
||||||
setPhase("time_select");
|
setPhase("time_select");
|
||||||
}}
|
}}
|
||||||
onShare={() => setShowPlanShareCard(true)}
|
onShare={() => setShowPlanShareCard(true)}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setPhase("pool");
|
setPhase("pool");
|
||||||
setPlanId(null);
|
|
||||||
setPlanDays([]);
|
|
||||||
setPlanAccepted(false);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
+7
-6
@@ -34,12 +34,13 @@ const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户
|
|||||||
|
|
||||||
规划原则:
|
规划原则:
|
||||||
1. 选择地理位置相近的 POI,最小化总移动距离
|
1. 选择地理位置相近的 POI,最小化总移动距离
|
||||||
2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活)
|
2. 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
||||||
3. 活动之间留出合理的交通时间(15-30分钟)
|
3. 尊重活动的时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
||||||
4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
|
4. 活动之间留出合理的交通时间(15-30分钟)
|
||||||
5. 高低强度活动交替安排,避免连续高体力活动
|
5. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
|
||||||
6. 费用平衡,避免连续安排 premium 级别活动
|
6. 高低强度活动交替安排,避免连续高体力活动
|
||||||
7. 需要预约(needsBooking)的活动,在 reason 中提醒用户提前预约
|
7. 费用平衡,避免连续安排 premium 级别活动
|
||||||
|
8. 需要预约(needsBooking)的活动,在 reason 中提醒用户提前预约
|
||||||
|
|
||||||
返回 JSON 格式:
|
返回 JSON 格式:
|
||||||
{
|
{
|
||||||
|
|||||||
+18
-15
@@ -195,7 +195,7 @@ export interface PlanGenResult {
|
|||||||
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
||||||
- list_ideas: 查看想法池中的所有活动
|
- list_ideas: 查看想法池中的所有活动
|
||||||
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
||||||
- get_travel_time: 查询两点间驾车时间和距离
|
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
|
||||||
- finalize_plan: 提交最终行程方案
|
- finalize_plan: 提交最终行程方案
|
||||||
|
|
||||||
规划流程:
|
规划流程:
|
||||||
@@ -203,13 +203,14 @@ const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下
|
|||||||
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
||||||
3. 为每个活动 search_poi 找到具体地点
|
3. 为每个活动 search_poi 找到具体地点
|
||||||
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
||||||
5. 用 get_travel_time 检查关键路段的交通时间,如果某段超过 30 分钟考虑换更近的地点
|
5. 用 get_travel_time 检查关键路段的公共交通时间,如果某段超过 45 分钟考虑换更近的地点
|
||||||
6. 综合地理位置、时间安排、活动特点,规划最优路线
|
6. 综合地理位置、时间安排、活动特点,规划最优路线
|
||||||
7. 确认无误后 finalize_plan 提交
|
7. 确认无误后 finalize_plan 提交
|
||||||
|
|
||||||
规划原则:
|
规划原则:
|
||||||
- 地理位置相近,最小化移动距离
|
- 地理位置相近,最小化移动距离
|
||||||
- 尊重时间偏好(morning 的活动放上午,dining 在饭点)
|
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
||||||
|
- 尊重时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
||||||
- 活动间留 15-30 分钟交通时间
|
- 活动间留 15-30 分钟交通时间
|
||||||
- 高低强度交替,避免连续高体力活动
|
- 高低强度交替,避免连续高体力活动
|
||||||
- 费用均衡,不连续安排 premium 活动
|
- 费用均衡,不连续安排 premium 活动
|
||||||
@@ -236,6 +237,7 @@ function buildAgentTools(
|
|||||||
taggedIdeas: TaggedIdea[],
|
taggedIdeas: TaggedIdea[],
|
||||||
lat: number,
|
lat: number,
|
||||||
lng: number,
|
lng: number,
|
||||||
|
city: string,
|
||||||
): AgentTool[] {
|
): AgentTool[] {
|
||||||
const listIdeasTool: AgentTool = {
|
const listIdeasTool: AgentTool = {
|
||||||
name: "list_ideas",
|
name: "list_ideas",
|
||||||
@@ -301,7 +303,7 @@ function buildAgentTools(
|
|||||||
|
|
||||||
const getTravelTimeTool: AgentTool = {
|
const getTravelTimeTool: AgentTool = {
|
||||||
name: "get_travel_time",
|
name: "get_travel_time",
|
||||||
description: "查询两点之间的驾车预估时间和距离。用于验证活动之间的交通是否合理",
|
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -318,31 +320,32 @@ function buildAgentTools(
|
|||||||
const dLat = Number(args.dest_lat);
|
const dLat = Number(args.dest_lat);
|
||||||
const dLng = Number(args.dest_lng);
|
const dLng = Number(args.dest_lng);
|
||||||
const apiKey = requireAmapApiKey();
|
const apiKey = requireAmapApiKey();
|
||||||
const url = new URL("https://restapi.amap.com/v5/direction/driving");
|
const url = new URL("https://restapi.amap.com/v5/direction/transit/integrated");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
url.searchParams.set("origin", `${oLng},${oLat}`);
|
url.searchParams.set("origin", `${oLng},${oLat}`);
|
||||||
url.searchParams.set("destination", `${dLng},${dLat}`);
|
url.searchParams.set("destination", `${dLng},${dLat}`);
|
||||||
|
url.searchParams.set("city", city);
|
||||||
url.searchParams.set("show_fields", "cost");
|
url.searchParams.set("show_fields", "cost");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url.toString());
|
const res = await fetch(url.toString());
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.status !== "1" || !data.route?.paths?.length) {
|
if (data.status !== "1" || !data.route?.transits?.length) {
|
||||||
return JSON.stringify({ error: "未找到路线" });
|
return JSON.stringify({ error: "未找到公交路线" });
|
||||||
}
|
}
|
||||||
const path = data.route.paths[0];
|
const transit = data.route.transits[0];
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
distanceKm: Math.round(Number(path.distance) / 100) / 10,
|
distanceKm: Math.round(Number(data.route.distance) / 100) / 10,
|
||||||
durationMin: Math.ceil(Number(path.duration) / 60),
|
durationMin: Math.ceil(Number(transit.duration) / 60),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return JSON.stringify({ error: "路线查询失败" });
|
return JSON.stringify({ error: "路线查询失败" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
progressBefore: () => "正在查询交通时间...",
|
progressBefore: () => "正在查询公共交通时间...",
|
||||||
progressAfter: (_args, result) => {
|
progressAfter: (_args, result) => {
|
||||||
const r = JSON.parse(result);
|
const r = JSON.parse(result);
|
||||||
if (r.error) return `路线查询失败`;
|
if (r.error) return `路线查询失败`;
|
||||||
return `驾车约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
return `公共交通约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,13 +397,13 @@ function buildAgentTools(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runAgentPlanGeneration(
|
async function runAgentPlanGeneration(
|
||||||
room: { lat: number; lng: number },
|
room: { lat: number; lng: number; city: string },
|
||||||
taggedIdeas: TaggedIdea[],
|
taggedIdeas: TaggedIdea[],
|
||||||
availableTime: PlanGenAvailableTime,
|
availableTime: PlanGenAvailableTime,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
): Promise<{ days: FinalizePlanDay[] }> {
|
): Promise<{ days: FinalizePlanDay[] }> {
|
||||||
const at = availableTime;
|
const at = availableTime;
|
||||||
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng);
|
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
|
||||||
|
|
||||||
const userPrompt = `帮我规划行程。
|
const userPrompt = `帮我规划行程。
|
||||||
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
||||||
@@ -632,7 +635,7 @@ export async function runPlanGeneration(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const agentResult = await runAgentPlanGeneration(
|
const agentResult = await runAgentPlanGeneration(
|
||||||
{ lat: room.lat, lng: room.lng },
|
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
|
||||||
taggedIdeas,
|
taggedIdeas,
|
||||||
availableTime,
|
availableTime,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|||||||
Reference in New Issue
Block a user