diff --git a/src/app/api/blindbox/suggest/route.ts b/src/app/api/blindbox/suggest/route.ts new file mode 100644 index 0000000..9b7c96c --- /dev/null +++ b/src/app/api/blindbox/suggest/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { suggestIdeas } from "@/lib/ai"; + +export const GET = apiHandler(async (req) => { + const { searchParams } = new URL(req.url); + const roomId = searchParams.get("roomId"); + const userId = searchParams.get("userId"); + + requireUserId(userId); + if (!roomId) throw new ApiError("roomId 不能为空"); + + await requireMembership(roomId, userId!); + + const recentIdeas = await prisma.blindBoxIdea.findMany({ + where: { roomId, status: "in_pool" }, + orderBy: { createdAt: "desc" }, + take: 10, + select: { content: true }, + }); + + if (recentIdeas.length < 2) { + return NextResponse.json({ suggestions: [], source: "none" }); + } + + const suggestions = await suggestIdeas(recentIdeas.map((i) => i.content)); + + if (suggestions.length === 0) { + return NextResponse.json({ suggestions: [], source: "none" }); + } + + return NextResponse.json({ suggestions, source: "ai" }); +}); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 938f4bf..fd78c23 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -20,6 +20,8 @@ import { Sparkles, ClipboardCheck, ChevronRight, + Lightbulb, + Shuffle, } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; @@ -49,6 +51,32 @@ interface RoomInfo { type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; +const IDEA_INSPIRATIONS = [ + "去城市最高楼看日落", + "挑战一人做一道菜", + "找一家从没去过的店", + "逛一个从没去过的街区", + "去公园野餐", + "看一场午夜电影", + "在家做一顿异国料理", + "骑车去郊外探险", + "去二手市场淘宝", + "去博物馆逛半天", + "试一项没玩过的运动", + "随机坐公交到终点站", + "一起画画或做手工", + "找个天台看星星", + "去一家评分最低的餐厅", + "穿最好看的衣服去拍照", + "交换手机玩一个小时", + "一起去做志愿者", +]; + +function pickRandom(arr: T[], n: number): T[] { + const shuffled = [...arr].sort(() => Math.random() - 0.5); + return shuffled.slice(0, n); +} + export default function BlindboxRoomPage() { const { code } = useParams<{ code: string }>(); const router = useRouter(); @@ -61,6 +89,9 @@ export default function BlindboxRoomPage() { const [input, setInput] = useState(""); const [submitting, setSubmitting] = useState(false); + const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4)); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static"); const [poolCount, setPoolCount] = useState(0); const [myIdeas, setMyIdeas] = useState([]); const [drawnHistory, setDrawnHistory] = useState([]); @@ -146,6 +177,35 @@ export default function BlindboxRoomPage() { } catch { /* ignore */ } }, [room]); + const fetchSuggestions = useCallback(async () => { + const p = getCachedProfile(); + if (!room || !p) return; + setSuggestionsLoading(true); + try { + const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${p.id}`); + if (res.ok) { + const data = await res.json(); + if (data.suggestions?.length > 0) { + setSuggestions(data.suggestions); + setSuggestionsSource("ai"); + setSuggestionsLoading(false); + return; + } + } + } catch { /* ignore */ } + setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); + setSuggestionsSource("static"); + setSuggestionsLoading(false); + }, [room]); + + const refreshSuggestions = useCallback(() => { + if (suggestionsSource === "ai") { + fetchSuggestions(); + } else { + setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); + } + }, [suggestionsSource, fetchSuggestions]); + const fetchAcceptedPlan = useCallback(async () => { const p = getCachedProfile(); if (!room || !p) return; @@ -167,8 +227,9 @@ export default function BlindboxRoomPage() { if (isMember && room) { fetchIdeas(); fetchAcceptedPlan(); + fetchSuggestions(); } - }, [isMember, room, fetchIdeas, fetchAcceptedPlan]); + }, [isMember, room, fetchIdeas, fetchAcceptedPlan, fetchSuggestions]); // Check for expired contracts on load useEffect(() => { @@ -340,6 +401,7 @@ export default function BlindboxRoomPage() { rotate: [0, -3, 3, 0], transition: { duration: 0.5 }, }); + fetchSuggestions(); } catch (e) { setError(e instanceof Error ? e.message : "提交失败"); } finally { @@ -710,6 +772,41 @@ export default function BlindboxRoomPage() { + {!input && ( +
+ {suggestionsSource === "ai" ? ( + + ) : ( + + )} + {suggestionsLoading ? ( + <> + {[1, 2, 3].map((i) => ( +
+ ))} + + ) : ( + suggestions.map((s) => ( + + )) + )} + +
+ )} +
{ } } +const SUGGEST_SYSTEM_PROMPT = `你是一个脑洞大开的周末活动灵感助手。根据用户房间里已有的活动想法,推荐 4 个风格相近但不重复的新想法。 + +要求: +- 贴合已有想法的整体调性(如果偏文艺就推文艺的,偏冒险就推冒险的) +- 适当加入一些意想不到的创意,激发灵感 +- 每条想法 5-15 个字,口语化、有画面感 +- 不要与已有想法重复或过于相似 + +只返回 JSON:{ "suggestions": ["想法1", "想法2", "想法3", "想法4"] }`; + +export async function suggestIdeas(existingIdeas: string[]): Promise { + try { + const client = getClient(); + const response = await client.chat.completions.create({ + model: "deepseek-chat", + messages: [ + { role: "system", content: SUGGEST_SYSTEM_PROMPT }, + { role: "user", content: `已有想法:\n${existingIdeas.map((s, i) => `${i + 1}. ${s}`).join("\n")}` }, + ], + response_format: { type: "json_object" }, + max_tokens: 200, + temperature: 0.9, + }); + + const text = response.choices[0]?.message?.content; + if (!text) return []; + + const parsed = JSON.parse(text); + if (!Array.isArray(parsed.suggestions)) return []; + + return parsed.suggestions + .filter((s: unknown) => typeof s === "string" && s.length > 0) + .slice(0, 4); + } catch { + return []; + } +} + export async function generateSchedule( ctx: ScheduleContext, ): Promise<{ items: PlanItem[]; summary: string } | null> {