From 0abd5f15254eafa4b926869be77d8ca89d2b71c1 Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Sun, 7 Jun 2026 17:13:27 +0800 Subject: [PATCH] feat(play): add encrypted story sharing --- AGENTS.md | 4 + app/api/story-pack/route.ts | 44 +++++ app/api/story-unpack/route.ts | 38 +++++ app/page.tsx | 63 +++++++ app/play/page.tsx | 304 ++++++++++++++++++++++++++++++++-- components/PlayCanvas.tsx | 21 ++- lib/storyShare.ts | 215 ++++++++++++++++++++++++ lib/types/index.ts | 4 + 8 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 app/api/story-pack/route.ts create mode 100644 app/api/story-unpack/route.ts create mode 100644 lib/storyShare.ts diff --git a/AGENTS.md b/AGENTS.md index 3d75bb1..0eb8262 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ At session start, `startSession()` runs Architect first to create `storyState`; `Scene` is an image plus a graph of `Beat` nodes. `Beat.next` is either `continue` or `choice`. A scene should have at least one meaningful `change-scene` exit toward a new scene. Beat ids are graph keys; keep them unique and repair references when coercing LLM output. +`SceneHistoryEntry.storyStateAfter` snapshots the story memory after each scene is generated. Keep it when exporting/importing playable story JSON or replaying shared sessions so continuing from a replayed prefix uses the right narrative context. + `StoryState` has stable and volatile zones. Stable fields are set by Architect and must not be patched by Writer: `logline`, `genreTags`, `protagonist`, `castNotes`. Volatile fields may be rewritten every scene: `synopsis`, `openThreads`, `relationships`, `nextHook`. If adding a field, classify it and update `applyStoryStatePatch()` plus Writer coercion. Characters are identified by `name`. `mergeCharacters()` preserves existing portrait and voice fields when a later design omits them. Do not casually change character matching without checking Writer, Director, and Painter reference handling. @@ -91,6 +93,7 @@ Common routes live under `app/api/`: - `POST /api/insert-beat`: creates a transient beat without image generation. - `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent. - `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art. +- `POST /api/story-pack` / `POST /api/story-unpack`: stateless AES-GCM packing/unpacking for playable story share `.infiplot` files; uses `GALLERY_SECRET`. When changing public types or route payloads, update all route callers and client consumers in the same change. @@ -139,6 +142,7 @@ Use `.env.example` as the source of truth. Never commit `.env.local`, API keys, - `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration. - `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts. - Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving. +- `GALLERY_SECRET` enables encrypted `.infiplot` share files for gallery and playable story export/import. - `NEXT_PUBLIC_*` values are inlined at build time. ## File Dependency Map diff --git a/app/api/story-pack/route.ts b/app/api/story-pack/route.ts new file mode 100644 index 0000000..b73f7ce --- /dev/null +++ b/app/api/story-pack/route.ts @@ -0,0 +1,44 @@ +import { packDoc } from "@/lib/galleryCrypto"; + +export const runtime = "nodejs"; + +const MAX_DOC_BYTES = 12_000_000; + +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + let docStr: string; + try { + const body = (await req.json()) as { docStr?: unknown }; + if (typeof body.docStr !== "string") { + return Response.json({ error: "Missing docStr" }, { status: 400 }); + } + docStr = body.docStr; + } catch { + return Response.json({ error: "Bad JSON" }, { status: 400 }); + } + + if (new TextEncoder().encode(docStr).byteLength > MAX_DOC_BYTES) { + return Response.json( + { error: "剧情数据太大,无法打包分享" }, + { status: 413 }, + ); + } + + const bytes = await packDoc(docStr, secret); + const ab = new ArrayBuffer(bytes.byteLength); + new Uint8Array(ab).set(bytes); + return new Response(ab, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/story-unpack/route.ts b/app/api/story-unpack/route.ts new file mode 100644 index 0000000..5f4a3ba --- /dev/null +++ b/app/api/story-unpack/route.ts @@ -0,0 +1,38 @@ +import { unpackDoc } from "@/lib/galleryCrypto"; + +export const runtime = "nodejs"; + +const MAX_FILE_BYTES = 13_000_000; + +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + let ab: ArrayBuffer; + try { + ab = await req.arrayBuffer(); + } catch { + return Response.json({ error: "Bad request body" }, { status: 400 }); + } + if (ab.byteLength > MAX_FILE_BYTES) { + return Response.json({ error: "文件太大" }, { status: 413 }); + } + if (ab.byteLength === 0) { + return Response.json({ error: "文件为空" }, { status: 400 }); + } + + try { + const docStr = await unpackDoc(new Uint8Array(ab), secret); + return Response.json({ docStr }); + } catch (e) { + return Response.json( + { error: e instanceof Error ? e.message : "解包失败" }, + { status: 400 }, + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 729fbdc..73a2c50 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; +import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -1249,6 +1250,8 @@ export default function HomePage() { const [customStyleGuide, setCustomStyleGuide] = useState(""); const [customStyleRefImage, setCustomStyleRefImage] = useState(""); const inputRef = useRef(null); + const storyImportRef = useRef(null); + const [storyImportError, setStoryImportError] = useState(null); // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 const [hintClosed, setHintClosed] = useState(false); @@ -1396,6 +1399,44 @@ export default function HomePage() { router.push("/play?custom=1"); }; + const handleStoryImport = async (file: File | undefined) => { + setStoryImportError(null); + if (!file) return; + if (file.size <= 0) { + setStoryImportError("这个剧情文件是空的。"); + return; + } + if (file.size > 12_000_000) { + setStoryImportError("剧情文件太大,无法载入。"); + return; + } + try { + let text: string; + if (file.name.toLowerCase().endsWith(".json") || file.type === "application/json") { + text = await file.text(); + } else { + const r = await fetch("/api/story-unpack", { + method: "POST", + body: await file.arrayBuffer(), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + throw new Error(j.error ?? "剧情文件解包失败。"); + } + const j = (await r.json()) as { docStr?: unknown }; + if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。"); + text = j.docStr; + } + const doc = parseStoryShareDoc(JSON.parse(text)); + window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc)); + router.push("/play?share=1"); + } catch (e) { + setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。"); + } finally { + if (storyImportRef.current) storyImportRef.current.value = ""; + } + }; + const stories = STORIES[galleryGender]; const imgPrefix = galleryGender === "女性向" ? "f" : "m"; const analyticsOn = Boolean( @@ -1511,6 +1552,28 @@ export default function HomePage() { +
+ void handleStoryImport(e.target.files?.[0])} + /> + + {storyImportError && ( +

+ {storyImportError} +

+ )} +
{prompt && (

Enter 发送 · Shift+Enter 换行 diff --git a/app/play/page.tsx b/app/play/page.tsx index c6bf723..2d52965 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -21,6 +21,12 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { PRESETS } from "@/lib/presets"; +import { + STORY_SHARE_STORAGE_KEY, + createStoryShareDoc, + parseStoryShareDoc, + storyShareFilename, +} from "@/lib/storyShare"; import { provisionVoice, synthesize } from "@infiplot/tts-client"; import type { Beat, @@ -621,6 +627,9 @@ function PlayInner() { const currentSceneRef = useRef(null); const currentBeatRef = useRef(null); const visitedBeatsRef = useRef([]); + const replaySourceRef = useRef(null); + const replayIndexRef = useRef(-1); + const replayActiveRef = useRef(false); // Original (CDN) URL of the currently-rendered scene image. Used as the key // to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL, // not the blob URL, because blobUrlCache is keyed by original URL. @@ -876,6 +885,13 @@ function PlayInner() { [prefetchSceneAudio], ); + function detachRecordedReplay(): void { + replayActiveRef.current = false; + replaySourceRef.current = null; + replayIndexRef.current = -1; + clearPool(poolRef.current); + } + // ── Export to interactive gallery (PPT-style replay) ───────────────── // Drop all but the (keepCount) most-recent gallery exports from localStorage, // ordered by their stored createdAt. Called right before writing a new @@ -1034,6 +1050,42 @@ function PlayInner() { })(); }, [trimGalleryExports]); + const handleExportStory = useCallback(() => { + const s = sessionRef.current; + if (!s || s.history.length === 0) return; + const sceneIndex = Math.max(0, s.history.length - 1); + const doc = createStoryShareDoc(s, { + sceneIndex, + beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId, + }); + void (async () => { + try { + const r = await fetch("/api/story-pack", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ docStr: JSON.stringify(doc) }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + window.alert(j.error ?? "剧情分享打包失败"); + return; + } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = storyShareFilename(doc); + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + } catch { + window.alert("剧情分享打包失败"); + } + })(); + }, []); + // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; @@ -1098,9 +1150,57 @@ function PlayInner() { // ?preset= → 内置 PRESETS(仍走 /api/start 现场生成) // ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg // 后走 /api/start 现场生成 + // ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放 const cardName = params.get("card"); const presetId = params.get("preset"); const isCustom = params.get("custom") === "1"; + const isShare = params.get("share") === "1"; + + if (isShare) { + (async () => { + try { + const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY); + if (!raw) throw new Error("没有找到要载入的剧情文件。"); + const doc = parseStoryShareDoc(JSON.parse(raw)); + const imported = doc.session; + const first = imported.history[0]; + if (!first) throw new Error("剧情分享文件没有可载入的剧情。"); + if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。"); + + const sessionOrientation = + first.scene.orientation ?? imported.orientation ?? detectOrientation(); + setOrientation(sessionOrientation); + const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl); + lastImageOriginalUrlRef.current = first.scene.imageUrl; + + const initial: Session = { + ...imported, + history: [ + { + ...first, + visitedBeatIds: [first.scene.entryBeatId], + exit: undefined, + }, + ], + storyState: first.storyStateAfter ?? imported.storyState, + orientation: sessionOrientation, + }; + replaySourceRef.current = imported; + replayIndexRef.current = 0; + replayActiveRef.current = imported.history.length > 1; + visitedBeatsRef.current = [first.scene.entryBeatId]; + setSession(initial); + setCurrentScene(first.scene); + setCurrentBeatId(first.scene.entryBeatId); + setImageUrl(blobUrl); + setPhase("ready"); + track("scene_reached", { scene_index: 1 }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + })(); + return; + } let livePayload: { worldSetting: string; @@ -1224,6 +1324,7 @@ function PlayInner() { { scene: data.scene, visitedBeatIds: [data.scene.entryBeatId], + storyStateAfter: data.storyState, }, ], characters: data.characters, @@ -1250,6 +1351,7 @@ function PlayInner() { const s = session; const scene = currentScene; if (!s || !scene) return; + if (isRecordedReplayLockedAt(currentBeat)) return; const exits = findAllChangeSceneChoices(scene); for (const choice of exits) { @@ -1273,7 +1375,7 @@ function PlayInner() { !!byoTtsRef.current, ); } - }, [currentScene?.id, session?.id]); + }, [currentScene?.id, currentBeat?.id, session?.id]); // Abort all in-flight speculative prefetches when the page unmounts, so we // stop paying for background scene/image generation. Empty deps → fires only @@ -1346,6 +1448,7 @@ function PlayInner() { { scene: result.scene, visitedBeatIds: [result.scene.entryBeatId], + storyStateAfter: result.storyState, }, ], characters: mergeCharactersPreserveVoice( @@ -1373,8 +1476,140 @@ function PlayInner() { } } + function tryRecordedSceneTransition( + choice: BeatChoice, + exit: SceneExit, + visitedForCurrent: string[], + ): boolean { + const source = replaySourceRef.current; + const idx = replayIndexRef.current; + if (!source || idx < 0 || !isRecordedReplayLockedAt(currentBeatRef.current)) { + return false; + } + + const recorded = source.history[idx]; + const next = source.history[idx + 1]; + if ( + !recorded || + !next || + recorded.exit?.kind !== "choice" || + recorded.exit.choiceId !== choice.id + ) { + detachRecordedReplay(); + return false; + } + + void (async () => { + setPhase("transitioning"); + setPendingClick(null); + try { + if (!next.scene.imageUrl) throw new Error("剧情分享文件缺少下一幕图片。"); + const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl); + const priorOriginal = lastImageOriginalUrlRef.current; + if (priorOriginal && priorOriginal !== next.scene.imageUrl) { + revokeBlobUrlFor(priorOriginal); + } + lastImageOriginalUrlRef.current = next.scene.imageUrl; + + const base = sessionRef.current; + if (!base) throw new Error("Session lost mid-replay"); + const closedHistory = base.history.map((h, i, arr) => + i === arr.length - 1 + ? { ...h, visitedBeatIds: visitedForCurrent, exit } + : h, + ); + const nextIndex = idx + 1; + const nextSession: Session = { + ...base, + history: [ + ...closedHistory, + { + ...next, + visitedBeatIds: [next.scene.entryBeatId], + exit: undefined, + }, + ], + characters: source.characters, + storyState: next.storyStateAfter ?? base.storyState, + orientation: next.scene.orientation ?? base.orientation, + }; + replayIndexRef.current = nextIndex; + replayActiveRef.current = true; + visitedBeatsRef.current = [next.scene.entryBeatId]; + setSession(nextSession); + setCurrentScene(next.scene); + setCurrentBeatId(next.scene.entryBeatId); + setImageUrl(blobUrl); + setLastExitLabel(choice.label); + setPhase("ready"); + track("scene_reached", { scene_index: nextSession.history.length }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("ready"); + } + })(); + + return true; + } + + function recordedAllowedChoiceIds(beat: Beat | null): Set | null { + if (!replaySourceRef.current || !beat || beat.next.type !== "choice") return null; + const source = replaySourceRef.current; + const recorded = source?.history[replayIndexRef.current]; + if (!recorded) return new Set(); + + const visited = recorded.visitedBeatIds; + const beatIdx = visited.indexOf(beat.id); + if (beatIdx < 0) return null; + const nextVisited = beatIdx >= 0 ? visited[beatIdx + 1] : undefined; + const allowed = new Set(); + if (nextVisited) { + for (const choice of beat.next.choices) { + if ( + choice.effect.kind === "advance-beat" && + choice.effect.targetBeatId === nextVisited + ) { + allowed.add(choice.id); + } + } + return allowed; + } + + if ( + beatIdx === visited.length - 1 && + recorded.exit?.kind === "choice" && + source.history[replayIndexRef.current + 1] + ) { + allowed.add(recorded.exit.choiceId); + return allowed; + } + return null; + } + + function isRecordedReplayLockedAt(beat: Beat | null): boolean { + if (!replaySourceRef.current || !beat) return false; + const recorded = replaySourceRef.current.history[replayIndexRef.current]; + if (!recorded) return false; + const beatIdx = recorded.visitedBeatIds.indexOf(beat.id); + if (beatIdx < 0) return false; + return Boolean( + recorded.visitedBeatIds[beatIdx + 1] || + ( + beatIdx === recorded.visitedBeatIds.length - 1 && + recorded.exit?.kind === "choice" && + replaySourceRef.current.history[replayIndexRef.current + 1] + ), + ); + } + + function isDisabledByRecordedReplay(choice: BeatChoice): boolean { + const allowed = recordedAllowedChoiceIds(currentBeatRef.current); + return allowed !== null && !allowed.has(choice.id); + } + function onSelectChoice(choice: BeatChoice) { if (phase !== "ready" || !session || !currentScene) return; + if (isDisabledByRecordedReplay(choice)) return; const beatNext = currentBeatRef.current?.next; const choiceIndex = @@ -1390,6 +1625,23 @@ function PlayInner() { } if (choice.effect.kind === "advance-beat") { + if (replayActiveRef.current && currentBeatRef.current) { + const source = replaySourceRef.current; + const idx = replayIndexRef.current; + const recorded = source?.history[idx]; + const recordedVisited = recorded?.visitedBeatIds ?? []; + const beatIdx = recordedVisited.indexOf(currentBeatRef.current.id); + const recordedNext = beatIdx >= 0 ? recordedVisited[beatIdx + 1] : undefined; + if (recordedNext && recordedNext !== choice.effect.targetBeatId) { + detachRecordedReplay(); + } + } + if ( + replaySourceRef.current && + !isRecordedReplayLockedAt(currentBeatRef.current) + ) { + detachRecordedReplay(); + } // Pure local jump. No network. No pool changes. setCurrentBeatId(choice.effect.targetBeatId); return; @@ -1403,6 +1655,9 @@ function PlayInner() { nextSceneSeed: choice.effect.nextSceneSeed, }; + if (tryRecordedSceneTransition(choice, exit, visited)) return; + if (replaySourceRef.current) detachRecordedReplay(); + const cached = consumeChoice(poolRef.current, choice.id); if (cached) { void performSceneTransition(cached, exit, visited, choice.label); @@ -1445,6 +1700,7 @@ function PlayInner() { async function onFreeformInput(text: string) { if (phase !== "ready" || !session || !currentScene) return; + if (replayActiveRef.current) detachRecordedReplay(); track("freeform_input", { scene_index: session.history.length, @@ -1576,6 +1832,7 @@ function PlayInner() { async function onBackgroundClick(click: { x: number; y: number }) { if (phase !== "ready" || !session || !currentScene || !imageUrl) return; + if (replayActiveRef.current) detachRecordedReplay(); setPhase("vision-thinking"); setPendingClick(click); @@ -1720,6 +1977,15 @@ function PlayInner() { // ── Render ──────────────────────────────────────────────────────────── + const replayAllowedChoiceIds = recordedAllowedChoiceIds(currentBeat); + const disabledReplayChoiceIds = + replayAllowedChoiceIds && currentBeat?.next.type === "choice" + ? currentBeat.next.choices + .filter((choice) => !replayAllowedChoiceIds.has(choice.id)) + .map((choice) => choice.id) + : []; + const replayLocked = isRecordedReplayLockedAt(currentBeat); + if (error) { return (

@@ -1768,6 +2034,8 @@ function PlayInner() { onOpenSettings={() => setSettingsOpen(true)} fullViewport dialogueHistory={dialogueHistory} + disabledChoiceIds={disabledReplayChoiceIds} + freeformDisabled={replayLocked} /> {orientation === "portrait" && (
setSettingsOpen(true)} dialogueHistory={dialogueHistory} + disabledChoiceIds={disabledReplayChoiceIds} + freeformDisabled={replayLocked} aboveCanvas={ + <> + + + ) : null } aboveCanvasLeft={ diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 807f4b1..9a03e9f 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -113,12 +113,14 @@ function ChoiceButton({ index, label, disabled, + disabledTitle, vertical, onClick, }: { index: number; label: string; disabled: boolean; + disabledTitle?: string; vertical: boolean; onClick: () => void; }) { @@ -126,9 +128,10 @@ function ChoiceButton({