From e31bd16b15bf90bf591f6c442b0dca8100508d29 Mon Sep 17 00:00:00 2001 From: Kai ki <155355644+zbf1009@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:06:19 +0800 Subject: [PATCH 1/4] fix(engine): prevent directScene hang + enforce segment ID uniqueness in prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defensive fixes surfaced by the PR #95 review (PR-Agent), applied on top of the staging sync: 1. directScene: routeTaggedStream rejecting BEFORE onPlan fires would leave planPromise unsettled, hanging `await planPromise` — and thus the whole /api/start and /api/scene request — forever. Add a .catch that settles the plan with a minimal fallback and resolves routing to a degraded result, so the pipeline produces a playable fallback scene (graceful degradation) instead of hanging. 2. prompts/registry: the duplicate-segment-ID guard only ran under NODE_ENV=development, so a bad merge introducing a duplicate ID would silently shadow a segment in production. Run the check in all environments (once at module load; negligible cost). --- lib/engine/director.ts | 20 ++++++++++++++++++++ lib/engine/prompts/registry.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/engine/director.ts b/lib/engine/director.ts index 8a2b029..aeb5e6c 100644 --- a/lib/engine/director.ts +++ b/lib/engine/director.ts @@ -13,6 +13,7 @@ import type { Session, StoryState, StoryStatePatch, + StreamRouterResult, WriterScenePlan, } from "@infiplot/types"; import type { CharacterCard } from "./agents/characterDesigner"; @@ -259,6 +260,25 @@ export async function directScene( resolvePlan(extracted); } return result; + }).catch((err): StreamRouterResult => { + // routeTaggedStream rejected (stream read / network failure) BEFORE onPlan + // fired. Without this, planPromise would never settle and `await + // planPromise` below would hang the whole request FOREVER. Settle the plan + // with a minimal fallback and resolve routing to a degraded result so the + // pipeline produces a playable fallback scene (graceful degradation) rather + // than hanging or hard-crashing. + console.warn("[directScene] routeTaggedStream rejected, degrading:", err); + if (!planSettled) { + planSettled = true; + resolvePlan(minimalFallbackPlan()); + } + return { + plan: undefined, + beats: [], + choices: undefined, + rawStorySegment: undefined, + degraded: true, + }; }); // ── Step 2 — await plan (settles at close — EARLY) ──────── diff --git a/lib/engine/prompts/registry.ts b/lib/engine/prompts/registry.ts index 2063270..e62983e 100644 --- a/lib/engine/prompts/registry.ts +++ b/lib/engine/prompts/registry.ts @@ -27,7 +27,11 @@ export const WRITER_SEGMENTS: PromptSegment[] = [ WRITER_FORMAT, ]; -if (process.env.NODE_ENV === "development") { +// Validate unique segment IDs in ALL environments (not just development). +// A duplicate ID — e.g. introduced by a bad merge — would otherwise silently +// shadow a segment in production. This runs once at module load; the cost is +// negligible. Throwing fast surfaces the misconfiguration at startup. +{ const ids = WRITER_SEGMENTS.map((s) => s.id); const seen = new Set(); for (const id of ids) { From 610dba78b7bfd178917d45deb5e3ba0e7cf665cb Mon Sep 17 00:00:00 2001 From: Kai ki <155355644+zbf1009@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:19:08 +0800 Subject: [PATCH 2/4] feat(persistence): local-first story persistence (IndexedDB + Supabase skeleton) Remove Cloudflare D1 entirely (4 API routes, lib/db/, Drizzle config/migrations, drizzle-orm/drizzle-kit deps, wrangler D1/R2/KV bindings) and replace with browser-local-first architecture: Open-source build (IndexedDB, no auth): - lib/persistence/ 5-file module: types, idb adapter (zero-dep, fault-tolerant, post-open invalidation retry), localStore (CRUD + sync-reserved metadata + slim/rebuild + retention-cap eviction with tombstone reap + sync-state protection + last-resort bounded fallback), sessionSlim (voice strip + styleRef absent-delete), cloudStore (Supabase skeleton, server-only) - Autosave: persistence fingerprint (history.length:lastBeatCount:playerName), serial saveChain, failure rollback retry, replaySourceRef guard (prevents replayed shared stories from clobbering user saves) - clientStoryPersistence.ts: thin facade (SaveResult discriminated union) - Stories page: /[locale]/stories with 3-language i18n (zh-CN/en/ja) - Homepage: book icon entry point in header Commercial build (Supabase, skeleton only): - Single table public.stories (JSONB + RLS 4 policies on auth.uid()=user_id) - supabase/migrations/ DDL (idempotent) - cloudStore.ts server-only repository, AUTH_ENABLED short-circuit - Not wired to client this phase Featured stories: pure fallback (buildFallbackCards + localizeCards), no D1 Includes fixes from 3 rounds of subagent code-review (tasks 16-30): - CR1: autosave restructure, coerceOrientation, D1 comment cleanup - CR2: fingerprint+serial+rollback+replay guard, idb post-open retry, enforceRetentionCap latent defense, sessionSlim absent invariant - CR3: single-scene share guard (replaySourceRef), insert-beat fingerprint (beats.length), pass3 overflow double-count fix, detach gate unification --- app/[locale]/page.tsx | 74 +-- app/[locale]/play/page.tsx | 130 +++++- app/[locale]/stories/page.tsx | 42 +- app/api/stories/[id]/route.ts | 31 -- app/api/stories/featured/route.ts | 48 -- app/api/stories/list/route.ts | 15 - app/api/stories/save/route.ts | 27 -- drizzle.config.ts | 15 - drizzle/0000_early_paladin.sql | 61 --- drizzle/meta/0000_snapshot.json | 431 ------------------ drizzle/meta/_journal.json | 13 - drizzle/seed-featured.sql | 66 --- lib/clientStoryPersistence.ts | 311 ++----------- lib/db/client.ts | 41 -- lib/db/repositories/featuredRepo.ts | 45 -- lib/db/repositories/storyRepo.ts | 308 ------------- lib/db/schema.ts | 123 ----- lib/engineClient.ts | 14 +- lib/i18n/locales/en.ts | 17 + lib/i18n/locales/ja.ts | 17 + lib/i18n/locales/zh-CN.ts | 17 + lib/persistence/cloudStore.ts | 200 ++++++++ lib/persistence/idb.ts | 190 ++++++++ lib/persistence/localStore.ts | 188 ++++++++ lib/persistence/sessionSlim.ts | 37 ++ lib/persistence/types.ts | 98 ++++ package.json | 2 - pnpm-lock.yaml | 413 ++--------------- .../migrations/20260624135618_stories.sql | 54 +++ wrangler.jsonc | 34 +- 30 files changed, 1043 insertions(+), 2019 deletions(-) delete mode 100644 app/api/stories/[id]/route.ts delete mode 100644 app/api/stories/featured/route.ts delete mode 100644 app/api/stories/list/route.ts delete mode 100644 app/api/stories/save/route.ts delete mode 100644 drizzle.config.ts delete mode 100644 drizzle/0000_early_paladin.sql delete mode 100644 drizzle/meta/0000_snapshot.json delete mode 100644 drizzle/meta/_journal.json delete mode 100644 drizzle/seed-featured.sql delete mode 100644 lib/db/client.ts delete mode 100644 lib/db/repositories/featuredRepo.ts delete mode 100644 lib/db/repositories/storyRepo.ts delete mode 100644 lib/db/schema.ts create mode 100644 lib/persistence/cloudStore.ts create mode 100644 lib/persistence/idb.ts create mode 100644 lib/persistence/localStore.ts create mode 100644 lib/persistence/sessionSlim.ts create mode 100644 lib/persistence/types.ts create mode 100644 supabase/migrations/20260624135618_stories.sql diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f42e87f..b193728 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -123,8 +123,8 @@ const OPTS: Opt[] = [ type StoryContent = { title: string; outline: string; style: string; tags: string[] }; -// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级, -// 都归一到这个形状后只走一条渲染路径。 +// 首页卡片的统一渲染形态。卡片来自硬编码 STORIES(buildFallbackCards), +// 按当前 locale 本地化后渲染。 type FeaturedCard = { id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接 title: string; @@ -132,22 +132,6 @@ type FeaturedCard = { coverPath: string; // e.g. "/home/m0.webp" }; -// D1 featured API 的响应行(与 lib/db/schema.ts FeaturedStory 对应的线上子集)。 -type FeaturedStoryRow = { - id: string; - gender: string; - title: string; - outline: string; - style: string; - tags: string; // JSON 字符串 - coverPath: string; - firstactPath: string; - firstscenePath?: string | null; - sortOrder: number; - isActive: number; - clickCount: number; -}; - import { STYLE_MAP } from "@/lib/options"; /* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。 @@ -798,8 +782,8 @@ const DISPLAY_ORDER: Record = { ], }; -// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源, -// 同时作为首屏即时渲染的初始值,避免等 fetch 期间卡片区空白)。 +// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(卡片的唯一数据源,经 +// localizeCards 三语本地化;惰性初始化为首屏即时渲染初始值,无需任何 fetch)。 function buildFallbackCards(g: Gender): FeaturedCard[] { const imgPrefix = g === "女性向" ? "f" : "m"; const localStories = STORIES[g]; @@ -1532,8 +1516,8 @@ export default function HomePage() { return () => clearTimeout(t); }, [gender, galleryGender]); - // Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。 - // 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。 + // Featured 卡片来自硬编码 STORIES 的本地化结果。惰性初始化确保首屏即有内容 + //(SSR + hydration 一致),locale/gender 变化时重算本地化。 const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null }); const [featuredCards, setFeaturedCards] = useState(() => buildFallbackCards(galleryGender), @@ -1546,34 +1530,11 @@ export default function HomePage() { } const i18n = storiesI18nRef.current.data; if (cancelled) return; - - const apiGender = galleryGender === "女性向" ? "female" : "male"; - try { - const r = await fetch(`/api/stories/featured?gender=${apiGender}`); - const data: { stories: FeaturedStoryRow[] } = await r.json(); - // API 已按 sortOrder 排序且仅返回 isActive=1 的记录。 - // D1 故障时 featured route 返回 { stories: [] }(HTTP 200), - // 空数组也必须降级到常量,否则首页白屏。 - const rows = data.stories ?? []; - if (cancelled) return; - if (rows.length === 0) { - setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n)); - return; - } - setFeaturedCards( - localizeCards( - rows.map((s) => ({ - id: s.id, - title: s.title, - outline: s.outline, - coverPath: s.coverPath, - })), - i18n, - ), - ); - } catch { - if (!cancelled) setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n)); - } + // Featured cards come from the hardcoded fallback set (buildFallbackCards), + // localized for the active locale. The D1 /api/stories/featured route was + // removed — it had no real data and always degraded to this fallback, so + // this is behavior-identical with one fewer network round-trip. + setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n)); })(); return () => { cancelled = true; }; }, [galleryGender, locale]); @@ -1878,8 +1839,17 @@ export default function HomePage() {
- {/* Story persistence UI hidden until auth integration is ready. - Code in app/stories/, app/api/stories/, lib/db/ is retained. */} + {/* "我的剧情" — entry to the browser-local story list (IndexedDB). + Needs no auth; the /[locale]/stories page reads the local store. */} +