feat: 盲盒想法输入增加 AI 灵感推荐
输入框下方展示灵感建议标签,点击即填入,降低创作门槛: - 房间有 ≥2 条想法时调用 DeepSeek 生成贴合调性的推荐 - 想法不足或 AI 失败时 fallback 到 18 条静态灵感随机选 4 条 - 提交新想法后自动刷新推荐,支持手动换一批
This commit is contained in:
@@ -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" });
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Lightbulb,
|
||||||
|
Shuffle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
@@ -49,6 +51,32 @@ interface RoomInfo {
|
|||||||
|
|
||||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||||
|
|
||||||
|
const IDEA_INSPIRATIONS = [
|
||||||
|
"去城市最高楼看日落",
|
||||||
|
"挑战一人做一道菜",
|
||||||
|
"找一家从没去过的店",
|
||||||
|
"逛一个从没去过的街区",
|
||||||
|
"去公园野餐",
|
||||||
|
"看一场午夜电影",
|
||||||
|
"在家做一顿异国料理",
|
||||||
|
"骑车去郊外探险",
|
||||||
|
"去二手市场淘宝",
|
||||||
|
"去博物馆逛半天",
|
||||||
|
"试一项没玩过的运动",
|
||||||
|
"随机坐公交到终点站",
|
||||||
|
"一起画画或做手工",
|
||||||
|
"找个天台看星星",
|
||||||
|
"去一家评分最低的餐厅",
|
||||||
|
"穿最好看的衣服去拍照",
|
||||||
|
"交换手机玩一个小时",
|
||||||
|
"一起去做志愿者",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRandom<T>(arr: T[], n: number): T[] {
|
||||||
|
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BlindboxRoomPage() {
|
export default function BlindboxRoomPage() {
|
||||||
const { code } = useParams<{ code: string }>();
|
const { code } = useParams<{ code: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -61,6 +89,9 @@ export default function BlindboxRoomPage() {
|
|||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 [poolCount, setPoolCount] = useState(0);
|
||||||
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
|
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
|
||||||
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
||||||
@@ -146,6 +177,35 @@ export default function BlindboxRoomPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [room]);
|
}, [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 fetchAcceptedPlan = useCallback(async () => {
|
||||||
const p = getCachedProfile();
|
const p = getCachedProfile();
|
||||||
if (!room || !p) return;
|
if (!room || !p) return;
|
||||||
@@ -167,8 +227,9 @@ export default function BlindboxRoomPage() {
|
|||||||
if (isMember && room) {
|
if (isMember && room) {
|
||||||
fetchIdeas();
|
fetchIdeas();
|
||||||
fetchAcceptedPlan();
|
fetchAcceptedPlan();
|
||||||
|
fetchSuggestions();
|
||||||
}
|
}
|
||||||
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
|
}, [isMember, room, fetchIdeas, fetchAcceptedPlan, fetchSuggestions]);
|
||||||
|
|
||||||
// Check for expired contracts on load
|
// Check for expired contracts on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,6 +401,7 @@ export default function BlindboxRoomPage() {
|
|||||||
rotate: [0, -3, 3, 0],
|
rotate: [0, -3, 3, 0],
|
||||||
transition: { duration: 0.5 },
|
transition: { duration: 0.5 },
|
||||||
});
|
});
|
||||||
|
fetchSuggestions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "提交失败");
|
setError(e instanceof Error ? e.message : "提交失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -710,6 +772,41 @@ export default function BlindboxRoomPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!input && (
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-1.5">
|
||||||
|
{suggestionsSource === "ai" ? (
|
||||||
|
<Sparkles size={13} className="mr-0.5 shrink-0 text-purple-400/80" />
|
||||||
|
) : (
|
||||||
|
<Lightbulb size={13} className="mr-0.5 shrink-0 text-amber-500/80" />
|
||||||
|
)}
|
||||||
|
{suggestionsLoading ? (
|
||||||
|
<>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-6 w-20 animate-pulse rounded-full bg-surface/60 ring-1 ring-border/40" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setInput(s); inputRef.current?.focus(); }}
|
||||||
|
className="rounded-full bg-surface/80 px-2.5 py-1 text-xs text-secondary ring-1 ring-border/60 transition-all hover:bg-purple-600/10 hover:text-purple-400 hover:ring-purple-600/30 active:scale-95"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={refreshSuggestions}
|
||||||
|
disabled={suggestionsLoading}
|
||||||
|
aria-label="换一批灵感"
|
||||||
|
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-secondary disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{suggestionsLoading ? <Loader2 size={12} className="animate-spin" /> : <Shuffle size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex w-full gap-2">
|
<div className="flex w-full gap-2">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleDraw}
|
onClick={handleDraw}
|
||||||
|
|||||||
@@ -113,6 +113,44 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUGGEST_SYSTEM_PROMPT = `你是一个脑洞大开的周末活动灵感助手。根据用户房间里已有的活动想法,推荐 4 个风格相近但不重复的新想法。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 贴合已有想法的整体调性(如果偏文艺就推文艺的,偏冒险就推冒险的)
|
||||||
|
- 适当加入一些意想不到的创意,激发灵感
|
||||||
|
- 每条想法 5-15 个字,口语化、有画面感
|
||||||
|
- 不要与已有想法重复或过于相似
|
||||||
|
|
||||||
|
只返回 JSON:{ "suggestions": ["想法1", "想法2", "想法3", "想法4"] }`;
|
||||||
|
|
||||||
|
export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
|
||||||
|
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(
|
export async function generateSchedule(
|
||||||
ctx: ScheduleContext,
|
ctx: ScheduleContext,
|
||||||
): Promise<{ items: PlanItem[]; summary: string } | null> {
|
): Promise<{ items: PlanItem[]; summary: string } | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user