"use client"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { PlayCanvas, type Phase } from "@/components/PlayCanvas"; import { PRESETS } from "@/lib/presets"; import type { ClickIntent, InteractResponse, Session, StartResponse, StoryFrame, VisionResponse, } from "@yume/types"; function PlayInner() { const router = useRouter(); const params = useSearchParams(); const [phase, setPhase] = useState("loading-first"); const [session, setSession] = useState(null); const [imageBase64, setImageBase64] = useState(null); const [frame, setFrame] = useState(null); const [intent, setIntent] = useState(null); const [pendingClick, setPendingClick] = useState<{ x: number; y: number; } | null>(null); const [turnNum, setTurnNum] = useState(0); const [error, setError] = useState(null); const [presentation, setPresentation] = useState(false); const startedRef = useRef(false); const prefetchAbortRef = useRef(null); const prefetchRef = useRef>>({}); const togglePresentation = useCallback(async () => { const entering = !presentation; if (entering) { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } } catch { // Browser may refuse fullscreen — still enter chrome-less mode } setPresentation(true); } else { try { if (document.fullscreenElement) { await document.exitFullscreen(); } } catch { // ignore } setPresentation(false); } }, [presentation]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "f" || e.key === "F") { if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); void togglePresentation(); } else if (e.key === "Escape" && presentation) { setPresentation(false); } } function onFullscreenChange() { // Sync if user exited browser fullscreen via Esc / system gesture if (!document.fullscreenElement && presentation) { setPresentation(false); } } window.addEventListener("keydown", onKey); document.addEventListener("fullscreenchange", onFullscreenChange); return () => { window.removeEventListener("keydown", onKey); document.removeEventListener("fullscreenchange", onFullscreenChange); }; }, [togglePresentation, presentation]); useEffect(() => { if (startedRef.current) return; startedRef.current = true; let payload: { worldSetting: string; styleGuide: string } | null = null; const presetId = params.get("preset"); if (presetId) { const p = PRESETS.find((x) => x.id === presetId); if (p) { payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide }; } } else if (params.get("custom") === "1") { const stored = sessionStorage.getItem("yume:custom"); if (stored) { try { payload = JSON.parse(stored); } catch { payload = null; } } } if (!payload) { router.replace("/"); return; } const finalPayload = payload; fetch("/api/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(finalPayload), }) .then(async (r) => { if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; throw new Error(j.error ?? r.statusText); } return r.json() as Promise; }) .then((data) => { setSession({ id: data.sessionId, createdAt: Date.now(), worldSetting: finalPayload.worldSetting, styleGuide: finalPayload.styleGuide, history: [{ frame: data.frame }], }); setFrame(data.frame); setImageBase64(data.imageBase64); setPhase("ready"); setTurnNum(1); }) .catch((e) => setError(String(e))); }, [params, router]); // Prefetch next-frame candidates whenever current frame becomes ready. // All three fire in parallel for fastest cache fill. NOT depending on // `phase` — we don't want to abort in-flight prefetches just because // the user clicked. They should continue so handleClick can await them. useEffect(() => { if (!session || !frame) return; prefetchAbortRef.current?.abort(); const ctrl = new AbortController(); prefetchAbortRef.current = ctrl; const choices = frame.uiElements.filter((e) => e.kind === "choice"); const promises: Record> = {}; for (const choice of choices) { const syntheticIntent: ClickIntent = { targetId: choice.id, targetLabel: choice.label, reasoning: "prefetch", }; const p = fetch("/api/interact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, intent: syntheticIntent }), signal: ctrl.signal, }).then(async (r) => { if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; throw new Error(j.error ?? r.statusText); } return r.json() as Promise; }); p.catch(() => {}); promises[choice.id] = p; } prefetchRef.current = promises; return () => { ctrl.abort(); }; }, [frame?.id, session?.id]); async function handleClick(click: { x: number; y: number }) { if (phase !== "ready" || !session || !imageBase64) return; setPhase("interacting"); setPendingClick(click); setIntent(null); const cacheSnapshot = prefetchRef.current; try { // Step 1: Vision (~4s) — figure out what the user actually clicked const visionRes = await fetch("/api/vision", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, prevImageBase64: imageBase64, click, }), }); if (!visionRes.ok) { const j = (await visionRes.json().catch(() => ({}))) as { error?: string; }; throw new Error(j.error ?? visionRes.statusText); } const { intent: clickIntent } = (await visionRes.json()) as VisionResponse; // Step 2: Cache lookup const cached = clickIntent.targetId ? cacheSnapshot[clickIntent.targetId] : undefined; let result: InteractResponse; if (cached) { // Cache hit — await the prefetched promise (mostly already resolved) result = await cached; // Overwrite the synthetic prefetch intent on history with the real one const lastIdx = result.session.history.length - 1; result = { ...result, intent: clickIntent, session: { ...result.session, history: result.session.history.map((entry, idx) => idx === lastIdx ? { ...entry, click, intent: clickIntent } : entry, ), }, }; } else { // Cache miss (free-form click) — abort wasted prefetches, run live prefetchAbortRef.current?.abort(); const liveRes = await fetch("/api/interact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, intent: clickIntent, click }), }); if (!liveRes.ok) { const j = (await liveRes.json().catch(() => ({}))) as { error?: string; }; throw new Error(j.error ?? liveRes.statusText); } result = (await liveRes.json()) as InteractResponse; } // Apply the result: append new frame to history const updatedHistory = [...result.session.history, { frame: result.frame }]; setSession({ ...result.session, history: updatedHistory }); setFrame(result.frame); setImageBase64(result.imageBase64); setIntent(clickIntent); setPendingClick(null); setTurnNum((t) => t + 1); setPhase("ready"); } catch (e) { setError(String(e)); setPendingClick(null); setPhase("ready"); } } if (error) { return (

出 · 了 · 点 · 状 · 况

{error}

返 回
); } if (presentation) { return (
); } return (
云梦
第 · {String(turnNum).padStart(3, "0")} · 帧 · {session?.id.slice(2, 14) ?? "—"}
{phase === "loading-first" && (

正 · 在 · 唤 · 起 · 第 · 一 · 帧

)} {phase === "interacting" && (

AI · 正 · 在 · 描 · 画 · 下 · 一 · 刻

预取选项秒级响应 · 自由点击稍候

)} {phase === "ready" && intent?.targetLabel && (

上 · 一 · 步 · {intent.targetLabel}

)} {phase === "ready" && !intent && turnNum > 0 && (

点 · 击 · 任 · 意 · 处 · 回 · 应

)}
Ⅰ · Ⅰ
); } export default function PlayPage() { return ( 载入中 } > ); }