From aef4771d2e1c3cfc5895851cd1dd989427967cf1 Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Fri, 5 Jun 2026 12:36:41 +0800 Subject: [PATCH 1/3] feat(dx): add agents.md Signed-off-by: baizhi958216 <1475289190@qq.com> --- AGENTS.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 162 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aa4f26b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# Repository Guidelines + +This is the primary working guide for AI coding agents and contributors. It summarizes the repo-specific rules and adds contributor workflow guidance. Prefer it over generic Next.js assumptions. + +## Project Structure & First Reads + +InfiPlot is a Next.js 16 / React 19 / TypeScript app for AI-driven interactive visual novels (galgame). The server is intentionally stateless: the client carries the full `Session` and sends it to API routes whenever new generation is needed. + +- `app/`: App Router pages and API routes. Start here for request/response behavior. +- `app/page.tsx`: Home/custom-start flow, preset cards, style-image upload/parsing, and analytics. +- `app/play/page.tsx`: Client session runtime, speculative scene prefetch, voice retention/stripping, image preload/proxying, orientation locking, and API callers. +- `components/`: Client UI, especially `PlayCanvas.tsx`, `CustomForm.tsx`, `PresetCard.tsx`, `TtsKeyModal.tsx`, and `Analytics.tsx`. +- `lib/types/index.ts`: Shared domain contracts. Read this before changing payload shapes. +- `lib/engine/`: Core story engine. `director.ts` orchestrates scene generation. +- `lib/engine/agents/`: Architect, Writer, CharacterDesigner, Cinematographer, Painter. +- `lib/engine/prompts.ts`: Agent prompts and prompt-cache-sensitive message builders. +- `lib/ai-client/`: Text, image, vision, and retry wrappers. +- `lib/tts-client/`: TTS integration. +- `lib/config.ts`: Server-side provider/environment loading. +- `lib/presets.ts`, `lib/ttsPresets.ts`, `lib/options.ts`: Home-page presets and selectable options. +- `scripts/`: Asset and preset generation helpers. +- `public/`, `docs/`: Static assets and documentation imagery. + +For engine work, read `lib/types/index.ts`, the target agent/orchestrator file, and the API route exposing the behavior. For UI work, inspect the component and the owning page. + +## Core Architecture + +The engine behaves like `Session + EngineConfig -> SceneResult`. The client appends returned scenes to `session.history`, replaces `session.characters` and `session.storyState`, and sends the updated `Session` back later. Do not introduce server-side session storage, hidden global game state, or persistence unless explicitly requested. + +The core pipeline is `directScene()` in `lib/engine/director.ts`. Writer is intentionally split into two phases so image generation can begin before full dialogue is ready: + +1. Writer Phase A runs serially and produces `WriterPlan`: `sceneSummary`, `sceneKey`, `entryBeatId`, `cast`, `entryActiveCharacters`, and `entrySpeaker`. +2. Writer Phase B starts immediately and overlaps the image pipeline. It produces `beats[]` and `storyStatePatch`, constrained to honor the plan. +3. CharacterDesigner card LLMs and Cinematographer run in parallel from the plan. +4. Entry-beat portraits may block Painter because they become references. +5. Painter generates the scene background from Cinematographer `integratedPrompt` plus `referenceImages`. +6. Non-entry portraits and all voice provisioning should overlap with painting, then Phase B is awaited before scene assembly. + +Do not add blocking calls between Writer Phase A completion and Painter start. Anything that can overlap with Phase B or painting should. + +At session start, `startSession()` runs Architect first to create `storyState`; subsequent scene requests must rely on the client-carried `Session`, not server memory. + +## Domain Model Invariants + +`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. + +`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. + +The player POV is hardcoded as second-person Chinese `"你"`. The player should not appear in `activeCharacters`, images, portraits, or TTS. Preserve normalization in Writer and InsertBeat flows. + +`orientation` is session-wide and locked at start (`"portrait"` for upright touch devices, otherwise `"landscape"`). It controls prompt framing, generated dimensions, mock images, and `PlayCanvas` layout; preserve back-compat by coercing missing/invalid values to `"landscape"`. + +`styleReferenceImage` is an optional client-resized `data:image/...` reference stored in the carried `Session`. It can make request bodies large, so keep validation limits and client resizing intact. + +## Agent Output & Error Handling + +Agent outputs should follow the existing pattern: + +1. Raw LLM type accepts optional and variant fields. +2. Coercion normalizes names, defaults, and malformed values. +3. Repair fixes structural issues. +4. Fallback returns a safe value instead of throwing at the agent boundary. + +Never use direct `JSON.parse()` on core agent LLM output. Use `parseJsonLoose()` from `lib/engine/jsonParser.ts`, which attempts direct parse, fenced JSON extraction, object slicing, and `jsonrepair`. Narrow utility routes may parse first only when they also have a safe fallback, as `/api/parse-style-image` does. + +Maintain graceful degradation. Existing flows tolerate malformed AI JSON, failed character cards, failed portraits, failed TTS, failed image references, optional analytics, and provider timeouts. Do not convert optional provider failures into hard crashes. + +## Visual Continuity & Prompt Caching + +`sceneKey` identifies a physical space such as `"classroom-dusk"`. If a new scene shares a key with prior history, the prior scene image should be reused as a reference. Character portraits are also references. + +Runware allows at most 4 references. Preserve the priority: style reference image, prior scene, speaker portrait, then other NPCs. Prefer image URLs for `referenceImages` when needed because Runware can fail to recognize UUIDs. The OpenAI/Gemini image paths can also accept references through the AI SDK, but they return data URIs and synthetic UUIDs, so repeated session transport is heavier than Runware's URL/UUID loop. + +Writer prompt caching depends on `buildWriterPlanUserMessage()` and `buildWriterBeatsUserMessage()` keeping their stable prefixes intact: world, style, story spine, archived history, known scene keys, and character list. The dynamic suffix contains current state, last beat, exit hint, and the current plan. Do not reorder or reformat stable prefix sections casually; it can destroy cache hit rates. + +## API Flow + +Common routes live under `app/api/`: + +- `POST /api/start`: starts a session via Architect then `directScene()`. +- `POST /api/scene`: generates the next scene from an existing session. +- `POST /api/vision`: interprets scene-image clicks. +- `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. + +When changing public types or route payloads, update all route callers and client consumers in the same change. + +All API routes currently run on `runtime = "nodejs"`. Keep Cloudflare implications in mind before adding Node-only dependencies to code that should also work in browser/client or OpenNext builds. + +The client deliberately strips `voice.referenceAudioBase64` from `Session` before `/api/scene`, `/api/vision`, and `/api/insert-beat` transport, then merges voices back locally. Server responses strip already-known voices to reduce payload size. Preserve this first-load/request-size behavior when changing character or TTS flow. + +`clientTts: true` means the browser owns Xiaomi TTS keys and provisions/synthesizes voices locally; routes must drop `config.tts` so server-side TTS is skipped and user keys never touch the server. + +`app/play/page.tsx` speculatively prefetches future `/api/scene` responses up to `PREFETCH_MAX_DEPTH`. If scene/session shape changes, update speculative session construction, cache re-rooting, abort logic, and voice/image preload handling together. + +## Build, Test, and Development Commands + +Use pnpm with Node >=22. `pnpm-lock.yaml` is the source of truth; `package-lock.json` is legacy and should not be updated unless requested. + +- `pnpm dev`: local Next.js dev server. +- `pnpm build`: production build for Vercel/default target. +- `pnpm start`: run production server after building. +- `pnpm lint`: Next.js built-in lint. +- `pnpm typecheck`: `tsc --noEmit`. +- `pnpm build:cf`: Cloudflare Workers build through OpenNext. +- `pnpm preview:cf`: local Cloudflare preview. +- `pnpm deploy:cf`: Cloudflare deploy. + +There is no dedicated test framework, no Prettier config, and no standalone ESLint config. Before handing off code changes, run `pnpm typecheck` and `pnpm lint`; run `pnpm build` for routing, deployment, or provider initialization changes. + +## Coding Style & Imports + +Write TypeScript with 2-space indentation, double quotes, semicolons, and ESM imports. Prefer named exports for shared helpers and components when practical. + +Use aliases from `tsconfig.json`: `@/*`, `@infiplot/engine`, `@infiplot/ai-client`, `@infiplot/tts-client`, and `@infiplot/types`. Avoid deep relative import chains when an alias exists. + +React components use PascalCase. Hooks, helpers, variables, and functions use camelCase. Types and interfaces use PascalCase. Route folders follow Next.js App Router conventions. UI work should follow the existing Tailwind-heavy visual language. + +Comment only non-obvious sequencing, provider quirks, fallback behavior, or architectural invariants. + +## Configuration & Providers + +Use `.env.example` as the source of truth. Never commit `.env.local`, API keys, uploaded user content, or generated secrets. + +- Text and Vision use `TEXT_*` and `VISION_*`; default protocol is `openai_compatible`, with native `anthropic` and `google` available via `TEXT_PROVIDER` / `VISION_PROVIDER`. +- Image uses `IMAGE_*`; supported protocols are `runware`, `openai_compatible`, native `openai`, and native `google`. When `IMAGE_PROVIDER` is unset, Runware is inferred from `*.runware.ai` URLs and otherwise falls back to OpenAI-compatible image generations. +- TTS uses Xiaomi MiMo protocol and is optional: blank config means silent mode. +- `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. +- `NEXT_PUBLIC_*` values are inlined at build time. + +## File Dependency Map + +If modifying Writer, also check `director.ts`, `prompts.ts`, WriterPlan/StoryState types, and Cinematographer/Painter consumers. If modifying CharacterDesigner, check Director scheduling/merge logic, portrait prompts, voice provisioning, and Painter reference collection. If modifying Cinematographer or Painter, check Director, prompt builders, provider image options, orientation handling, and reference priority. If modifying Architect, check `orchestrator.ts`, `prompts.ts`, and StoryState patch rules. If modifying `lib/types/index.ts`, check all agents, Director, Orchestrator, API routes, and client consumers in `app/page.tsx`, `app/play/page.tsx`, and `components/PlayCanvas.tsx`. If modifying TTS, check server `beat-audio`, BYO client TTS, voice stripping/merging, and payload privacy. If modifying image delivery, check Painter, `lib/ai-client/image.ts`, mock images, orientation dimensions, preload/proxy logic, and style-reference validation. + +## Guide Maintenance + +After any refactor, architecture change, provider-client rewrite, public type change, new route, payload-shape change, or major UI flow change, reread the affected files and compare them against this `AGENTS.md`. Update `AGENTS.md` in the same change if the architecture, commands, invariants, dependency map, environment variables, or "What Not To Do" list drifted. The canonical filename is `AGENTS.md`; treat mentions like `AGETNS.md` as typos and repair the real file. + +## Commit & Pull Request Guidelines + +Follow observed Conventional Commit style: `feat(web): ...`, `fix(play): ...`, `perf(engine): ...`, `chore(engine): ...`. + +PRs should include a short behavior summary, validation commands run, linked issues when relevant, screenshots or recordings for UI changes, and notes for environment, provider, deployment, or payload-shape changes. + +## What Not To Do + +- Do not make the server stateful. +- Do not generate images, portraits, or TTS for `"你"`. +- Do not let Writer patch stable `StoryState` fields. +- Do not reorder the Writer stable prompt prefix without a clear cache-aware reason. +- Do not assume Runware UUID references always work. +- Do not remove fallbacks, timeout handling, analytics privacy constraints, or reference priority rules. +- Do not leak browser-provided TTS keys to the server or send retained voice audio through scene/vision/insert-beat session payloads. +- Do not break session-locked orientation or style-reference propagation when changing start/play flows. +- Do not regenerate large assets in `public/` unless the user requested asset work. +- Do not mix prompt refactors, provider-client rewrites, UI restyling, and deployment changes in one narrow task. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a645528 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGETNTS.md \ No newline at end of file From 5a7daa84525c7df261329fbd9dc717cab859502a Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Sat, 6 Jun 2026 20:52:10 +0800 Subject: [PATCH 2/3] feat(play): add history dialog Signed-off-by: baizhi958216 <1475289190@qq.com> --- AGENTS.md | 2 + app/globals.css | 36 +++++++ app/play/page.tsx | 75 +++++++++++++- components/DialogueHistoryModal.tsx | 148 ++++++++++++++++++++++++++++ components/PlayCanvas.tsx | 32 +++++- 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 components/DialogueHistoryModal.tsx diff --git a/AGENTS.md b/AGENTS.md index aa4f26b..397c77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,8 @@ Use aliases from `tsconfig.json`: `@/*`, `@infiplot/engine`, `@infiplot/ai-clien React components use PascalCase. Hooks, helpers, variables, and functions use camelCase. Types and interfaces use PascalCase. Route folders follow Next.js App Router conventions. UI work should follow the existing Tailwind-heavy visual language. +Modal/dialog UI should be extracted into dedicated components instead of being inlined inside large page or canvas components. Keep the host responsible for open/close state and domain data, and keep the modal component responsible for dialog layout, overlay behavior, keyboard close handling, scroll containers, and modal-specific styling. + Comment only non-obvious sequencing, provider quirks, fallback behavior, or architectural invariants. ## Configuration & Providers diff --git a/app/globals.css b/app/globals.css index 5051ba2..9cb7d82 100644 --- a/app/globals.css +++ b/app/globals.css @@ -52,6 +52,42 @@ text-transform: uppercase; letter-spacing: 0.32em; } + + .vn-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(195, 155, 75, 0.58) rgba(20, 14, 8, 0.32); + } + + .vn-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .vn-scrollbar::-webkit-scrollbar-track { + background: + linear-gradient( + to right, + transparent 0, + transparent 3px, + rgba(20, 14, 8, 0.46) 3px, + rgba(20, 14, 8, 0.46) 5px, + transparent 5px + ); + } + + .vn-scrollbar::-webkit-scrollbar-thumb { + background: rgba(195, 155, 75, 0.52); + border: 2px solid rgba(14, 10, 6, 0.88); + border-radius: 999px; + } + + .vn-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(220, 180, 95, 0.76); + } + + .vn-scrollbar::-webkit-scrollbar-corner { + background: transparent; + } } @keyframes infiplot-ripple { diff --git a/app/play/page.tsx b/app/play/page.tsx index 528da77..225ffc7 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -11,7 +11,11 @@ import { useRef, useState, } from "react"; -import { PlayCanvas, type Phase } from "@/components/PlayCanvas"; +import { + PlayCanvas, + type Phase, +} from "@/components/PlayCanvas"; +import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal"; import { TtsKeyModal } from "@/components/TtsKeyModal"; import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; @@ -262,6 +266,63 @@ type ScenePathStep = { exit: { choiceId: string; label: string; nextSceneSeed: string }; }; +function buildDialogueHistory( + session: Session | null, + currentSceneId: string | undefined, + currentVisitedBeatIds: string[], +): DialogueHistoryItem[] { + if (!session) return []; + + return session.history.flatMap((entry, sceneIndex) => { + const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b])); + const visitedBeatIds = + entry.scene.id === currentSceneId + ? currentVisitedBeatIds + : entry.visitedBeatIds; + + return visitedBeatIds.flatMap((beatId, beatIndex) => { + const beat = beatsById.get(beatId); + if (!beat) return []; + + const nextVisitedBeatId = visitedBeatIds[beatIndex + 1]; + const choice = + beat.next.type === "choice" + ? beat.next.choices.find((c) => { + if (c.effect.kind === "advance-beat") { + return c.effect.targetBeatId === nextVisitedBeatId; + } + return ( + beatIndex === visitedBeatIds.length - 1 && + entry.exit?.kind === "choice" && + c.id === entry.exit.choiceId + ); + }) + : undefined; + const freeformAction = + beatIndex === visitedBeatIds.length - 1 && + entry.exit?.kind === "freeform" + ? entry.exit.action + : undefined; + + const body = beat.speaker ? beat.line : beat.narration; + const narration = beat.speaker ? beat.narration : undefined; + if (!body && !narration && !choice && !freeformAction) return []; + + return [ + { + id: `${sceneIndex}:${beatId}:${beatIndex}`, + sceneIndex: sceneIndex + 1, + speaker: beat.speaker, + body, + narration, + selectedChoice: choice?.label, + freeformAction, + }, + ]; + }); + }); +} + function pathKey(steps: ScenePathStep[]): string { return steps.map((s) => s.exit.choiceId).join("/"); } @@ -549,6 +610,16 @@ function PlayInner() { return currentScene.beats.find((b) => b.id === currentBeatId) ?? null; }, [currentScene, currentBeatId]); + const dialogueHistory = useMemo( + () => + buildDialogueHistory( + session, + currentScene?.id, + visitedBeatsRef.current, + ), + [session, currentScene?.id, currentBeatId], + ); + const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null; useEffect(() => { @@ -1369,6 +1440,7 @@ function PlayInner() { onSelectChoice={onSelectChoice} orientation={orientation} fullViewport + dialogueHistory={dialogueHistory} /> {orientation === "portrait" && (
void; +}) { + const listRef = useRef(null); + + useEffect(() => { + const el = listRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [items.length]); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + style={{ + background: "rgba(14, 10, 6, 0.88)", + border: "1.5px solid rgba(175, 138, 72, 0.72)", + borderRadius: "6px", + backdropFilter: "blur(14px)", + WebkitBackdropFilter: "blur(14px)", + boxShadow: + "0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)", + }} + role="dialog" + aria-modal="true" + aria-label="剧情回溯" + > +
+
+ + 剧 · 情 · 回 · 溯 +
+ +
+ +
+ {items.length === 0 ? ( +

+ 暂无历史。 +

+ ) : ( +
+ {items.map((item) => ( +
+
+ + 第 {String(item.sceneIndex).padStart(3, "0")} 幕 + + {item.speaker && ( + + {item.speaker} + + )} +
+ {item.body && ( +

+ {item.body} +

+ )} + {item.narration && ( +

+ {item.narration} +

+ )} + {item.selectedChoice && ( +

+ + 选择 + + {item.selectedChoice} +

+ )} + {item.freeformAction && ( +

+ + 行动 + + {item.freeformAction} +

+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 5ee2ced..db23245 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -1,6 +1,10 @@ "use client"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { + DialogueHistoryModal, + type DialogueHistoryItem, +} from "@/components/DialogueHistoryModal"; import type { Beat, BeatChoice, Orientation } from "@infiplot/types"; export type Phase = @@ -174,6 +178,7 @@ export function PlayCanvas({ orientation = "landscape", aboveCanvas, aboveCanvasLeft, + dialogueHistory = [], }: { imageUrl: string | null; audioSrc: string | null; @@ -191,9 +196,11 @@ export function PlayCanvas({ aboveCanvas?: ReactNode; // 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。 aboveCanvasLeft?: ReactNode; + dialogueHistory?: DialogueHistoryItem[]; }) { const imgRef = useRef(null); const audioRef = useRef(null); + const [historyOpen, setHistoryOpen] = useState(false); const [audioDurationMs, setAudioDurationMs] = useState( undefined, ); @@ -404,11 +411,19 @@ export function PlayCanvas({ : undefined } > + {historyOpen && ( + setHistoryOpen(false)} + /> + )} + {choices.length > 0 && (
@@ -487,13 +502,26 @@ export function PlayCanvas({ {typingDone && beat.next.type === "continue" && ( )} + +
)}
From 9794a5a3290a9f7846aae164845ccc60c3a9f302 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 6 Jun 2026 21:39:24 +0800 Subject: [PATCH 3/3] fix(play): fix CLAUDE.md typo and dialogue history memo anti-pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix @AGETNTS.md → @AGENTS.md typo in CLAUDE.md - Remove ref read inside useMemo (React anti-pattern causing one-frame stale data) - Simplify buildDialogueHistory to read visitedBeatIds directly from session.history, which also fixes incorrect scene-ID matching when the same ID appears multiple times Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- app/play/page.tsx | 16 +++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a645528..eef4bd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGETNTS.md \ No newline at end of file +@AGENTS.md \ No newline at end of file diff --git a/app/play/page.tsx b/app/play/page.tsx index 225ffc7..e7d2fa4 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -268,17 +268,12 @@ type ScenePathStep = { function buildDialogueHistory( session: Session | null, - currentSceneId: string | undefined, - currentVisitedBeatIds: string[], ): DialogueHistoryItem[] { if (!session) return []; return session.history.flatMap((entry, sceneIndex) => { const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b])); - const visitedBeatIds = - entry.scene.id === currentSceneId - ? currentVisitedBeatIds - : entry.visitedBeatIds; + const visitedBeatIds = entry.visitedBeatIds; return visitedBeatIds.flatMap((beatId, beatIndex) => { const beat = beatsById.get(beatId); @@ -611,13 +606,8 @@ function PlayInner() { }, [currentScene, currentBeatId]); const dialogueHistory = useMemo( - () => - buildDialogueHistory( - session, - currentScene?.id, - visitedBeatsRef.current, - ), - [session, currentScene?.id, currentBeatId], + () => buildDialogueHistory(session), + [session], ); const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;