feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into staging with conflict resolution, feature integration, and bug fixes. Engine: - Paradigm D: single-stream Writer replacing dual-phase Plan/Beats - Delete Architect agent; story bible generated via Writer <plan> tag - Modular prompt architecture (segments/registry/builder) - StreamRouter for tagged stream splitting (<plan>/<story>/<choices>) Infrastructure: - Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter) - D1 database schema + Drizzle ORM (scaffolded, not yet active) - R2 storage helpers (scaffolded, not yet active) - Story persistence API routes + client-side persistence BYOK (Bring Your Own Key): - /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth) - CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to server proxy transparently via OpenAI SDK custom fetch - BYO config support added to classify-freeform and vision routes - SettingsModal CORS privacy notice (keys never logged/stored) SSE streaming: - engineClient.ts: fetchSSE helper for progressive scene events - startSession/requestScene accept optional emit callback - Fix SSE error event field name (error → message) in scene/start routes i18n integration: - Wire buildLanguageDirective into paradigm D's prompt builder - Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text - Preserve Session.language + LanguageSwitcher from i18n commit Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { classifyFreeform } from "@infiplot/engine";
|
||||
import type { FreeformClassifyRequest } from "@infiplot/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
import { loadEngineConfig, buildByoEngineConfig } from "@/lib/config";
|
||||
import { requireUser } from "@/lib/supabase/guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -25,11 +25,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadEngineConfig();
|
||||
const official = loadEngineConfig();
|
||||
const config = body.byo ? buildByoEngineConfig(body.byo, official) : official;
|
||||
const result = await classifyFreeform(config, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
const status = message.includes("Invalid BYO") || message.includes("Missing BYO") ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const base = loadEngineConfig();
|
||||
// See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS.
|
||||
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
|
||||
const result = await requestInsertBeat(config, body);
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { proxyLLM, type ProxyLLMParams } from "@/lib/byoProxy";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/supabase/guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const auth = await requireUser();
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
let parsed: Partial<ProxyLLMParams>;
|
||||
try {
|
||||
parsed = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const { provider, apiKey, baseUrl, body } = parsed;
|
||||
if (!provider || !apiKey || !baseUrl || !body) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: provider, apiKey, baseUrl, body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
if (!["openai", "claude", "gemini"].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to proxy core
|
||||
return proxyLLM({
|
||||
provider: provider as "openai" | "claude" | "gemini",
|
||||
apiKey,
|
||||
baseUrl,
|
||||
body,
|
||||
model: parsed.model,
|
||||
stream: parsed.stream,
|
||||
});
|
||||
}
|
||||
+55
-6
@@ -1,5 +1,5 @@
|
||||
import { requestScene } from "@infiplot/engine";
|
||||
import type { Character, SceneRequest } from "@infiplot/types";
|
||||
import type { Character, SceneRequest, SceneStreamEvent } from "@infiplot/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
import { requireUser } from "@/lib/supabase/guard";
|
||||
@@ -13,6 +13,10 @@ function stripKnownVoices(
|
||||
);
|
||||
}
|
||||
|
||||
function formatSSE(event: SceneStreamEvent | { type: string; [k: string]: unknown }): string {
|
||||
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -30,17 +34,62 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "session is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const acceptsSSE = req.headers.get("accept")?.includes("text/event-stream");
|
||||
|
||||
try {
|
||||
const base = loadEngineConfig();
|
||||
// See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS.
|
||||
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
|
||||
const result = await requestScene(config, body);
|
||||
|
||||
if (!acceptsSSE) {
|
||||
const result = await requestScene(config, body);
|
||||
const knownNames = new Set(
|
||||
(body.session.characters ?? []).map((c) => c.name),
|
||||
);
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
characters: stripKnownVoices(result.characters, knownNames),
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const knownNames = new Set(
|
||||
(body.session.characters ?? []).map((c) => c.name),
|
||||
);
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
characters: stripKnownVoices(result.characters, knownNames),
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const result = await requestScene(config, body, (event) => {
|
||||
controller.enqueue(encoder.encode(formatSSE(event)));
|
||||
});
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
formatSSE({
|
||||
type: "done",
|
||||
response: {
|
||||
...result,
|
||||
characters: stripKnownVoices(result.characters, knownNames),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSSE({ type: "error", message })),
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
+43
-6
@@ -1,9 +1,13 @@
|
||||
import { startSession } from "@infiplot/engine";
|
||||
import type { StartRequest } from "@infiplot/types";
|
||||
import type { SceneStreamEvent, StartRequest } from "@infiplot/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
import { requireUser } from "@/lib/supabase/guard";
|
||||
|
||||
function formatSSE(event: SceneStreamEvent | { type: string; [k: string]: unknown }): string {
|
||||
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Matches /api/vision and /api/parse-style-image — the user's resized 512px
|
||||
@@ -43,14 +47,47 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const acceptsSSE = req.headers.get("accept")?.includes("text/event-stream");
|
||||
|
||||
try {
|
||||
const base = loadEngineConfig();
|
||||
// BYO key: the browser provisions + synths voices directly against Xiaomi
|
||||
// (key never reaches us), so strip server-side TTS so the engine skips all
|
||||
// provisioning + synth. See StartRequest.clientTts.
|
||||
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
|
||||
const result = await startSession(config, body);
|
||||
return NextResponse.json(result);
|
||||
|
||||
if (!acceptsSSE) {
|
||||
const result = await startSession(config, body);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const result = await startSession(config, body, (event) => {
|
||||
controller.enqueue(encoder.encode(formatSSE(event)));
|
||||
});
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
formatSSE({ type: "done", response: result }),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSSE({ type: "error", message })),
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET/DELETE /api/stories/[id] — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence disabled until authentication integration.
|
||||
* Returns 404 so client handles gracefully (localStorage is the source of truth).
|
||||
*
|
||||
* To re-enable: Restore original implementation after auth integration.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
_context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
_context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { FeaturedRepository } from "@/lib/db/repositories/featuredRepo";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/stories/featured?gender=male
|
||||
*
|
||||
* List active featured stories for homepage display.
|
||||
* Fallback: D1 query fails → return empty array (homepage shows no cards, gracefully degrades).
|
||||
*
|
||||
* Query Params:
|
||||
* gender: "male" | "female" (required)
|
||||
*
|
||||
* Response: { stories: FeaturedStory[] }
|
||||
* Errors: 400 (invalid gender), 500 (should not reach user - caught and degraded)
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const genderParam = searchParams.get("gender");
|
||||
|
||||
// Validate gender
|
||||
if (!genderParam || !["male", "female"].includes(genderParam)) {
|
||||
return NextResponse.json(
|
||||
{ error: "gender query parameter must be 'male' or 'female'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const gender = genderParam as "male" | "female";
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const repo = new FeaturedRepository(db);
|
||||
|
||||
const stories = await repo.listByGender(gender);
|
||||
|
||||
return NextResponse.json({ stories });
|
||||
} catch (err) {
|
||||
// D1 unavailable or query failed - degrade to empty array
|
||||
// (homepage will show no cards but remain functional)
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[stories/featured] D1 query failed, returning empty array:", message);
|
||||
|
||||
return NextResponse.json({ stories: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/stories/list — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence disabled until authentication integration.
|
||||
* Returns empty list so client falls back to localStorage-only mode.
|
||||
*
|
||||
* To re-enable: Restore original implementation after auth integration.
|
||||
*/
|
||||
export async function GET(_req: Request) {
|
||||
return NextResponse.json({ stories: [] });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/stories/save — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence is disabled until an authentication system (better-auth) is
|
||||
* integrated. Without auth, anonymous writes to D1 have no rate limiting,
|
||||
* per-user quota, or ownership verification — an abuse/DoS risk on a public,
|
||||
* registration-less site. The client (lib/clientStoryPersistence.ts) now
|
||||
* persists stories to localStorage only; this 503 keeps the contract intact
|
||||
* for any caller that still hits the endpoint.
|
||||
*
|
||||
* The full D1 implementation lives in StoryRepository (lib/db/repositories/
|
||||
* storyRepo.ts), which is untouched. To re-enable after auth integration:
|
||||
* restore the handler to validate input + call `repo.save(...)` (see the
|
||||
* task-10 implementation log) and gate it behind an authenticated session.
|
||||
*
|
||||
* See: ARCHITECTURE_DESIGN.md Phase 2, memory tech_d1_anonymous_write_risk
|
||||
*/
|
||||
export async function POST(_req: Request) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled - using local storage" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { visionDecide } from "@infiplot/engine";
|
||||
import type { VisionRequest } from "@infiplot/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
import { loadEngineConfig, buildByoEngineConfig } from "@/lib/config";
|
||||
import { requireUser } from "@/lib/supabase/guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -45,11 +45,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadEngineConfig();
|
||||
const official = loadEngineConfig();
|
||||
const config = body.byo ? buildByoEngineConfig(body.byo, official) : official;
|
||||
const result = await visionDecide(config, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
const status = message.includes("Invalid BYO") || message.includes("Missing BYO") ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
+95
-16
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
@@ -121,6 +122,31 @@ const OPTS: Opt[] = [
|
||||
|
||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||
|
||||
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级,
|
||||
// 都归一到这个形状后只走一条渲染路径。
|
||||
type FeaturedCard = {
|
||||
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
|
||||
title: string;
|
||||
outline: string;
|
||||
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 按索引一一对应)。
|
||||
@@ -771,6 +797,22 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源,
|
||||
// 同时作为首屏即时渲染的初始值,避免等 fetch 期间卡片区空白)。
|
||||
function buildFallbackCards(g: Gender): FeaturedCard[] {
|
||||
const imgPrefix = g === "女性向" ? "f" : "m";
|
||||
const localStories = STORIES[g];
|
||||
return DISPLAY_ORDER[g].map((origIdx) => {
|
||||
const c = localStories[origIdx]!;
|
||||
return {
|
||||
id: `${imgPrefix}${origIdx}`,
|
||||
title: c.title,
|
||||
outline: c.outline,
|
||||
coverPath: `/home/${imgPrefix}${origIdx}.webp`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- typewriter ---------- */
|
||||
|
||||
// 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句
|
||||
@@ -1462,6 +1504,39 @@ export default function HomePage() {
|
||||
return () => clearTimeout(t);
|
||||
}, [gender, galleryGender]);
|
||||
|
||||
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
|
||||
// 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。
|
||||
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
|
||||
buildFallbackCards(galleryGender),
|
||||
);
|
||||
useEffect(() => {
|
||||
const apiGender = galleryGender === "女性向" ? "female" : "male";
|
||||
fetch(`/api/stories/featured?gender=${apiGender}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { stories: FeaturedStoryRow[] }) => {
|
||||
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
|
||||
// D1 故障时 featured route 返回 { stories: [] }(HTTP 200),
|
||||
// 空数组也必须降级到常量,否则首页白屏。
|
||||
const rows = data.stories ?? [];
|
||||
if (rows.length === 0) {
|
||||
setFeaturedCards(buildFallbackCards(galleryGender));
|
||||
return;
|
||||
}
|
||||
setFeaturedCards(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
outline: s.outline,
|
||||
coverPath: s.coverPath,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络故障 / JSON 解析失败 → 降级到常量
|
||||
setFeaturedCards(buildFallbackCards(galleryGender));
|
||||
});
|
||||
}, [galleryGender]);
|
||||
|
||||
/* close any open dropdown on outside click */
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
@@ -1735,7 +1810,7 @@ export default function HomePage() {
|
||||
// 「语音配音」选项仍然生效:把 audioEnabled 经 sessionStorage 传给 /play。
|
||||
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
|
||||
// 的红果默认基调,对精选卡不再生效。
|
||||
const onCardClick = (idx: number, _card: StoryContent) => {
|
||||
const onCardClick = (cardId: string) => {
|
||||
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
sessionStorage.setItem(
|
||||
@@ -1746,9 +1821,9 @@ export default function HomePage() {
|
||||
source: "curated",
|
||||
gender: galleryGender,
|
||||
tts: audioEnabled,
|
||||
card: `${imgPrefix}${idx}`,
|
||||
card: cardId as `${"m" | "f"}${number}`,
|
||||
});
|
||||
router.push(`/play?card=${imgPrefix}${idx}`);
|
||||
router.push(`/play?card=${cardId}`);
|
||||
};
|
||||
|
||||
// overflow-x-hidden 在 wrapper 层兜底:body 的 overflow-x-hidden 在移动端会因
|
||||
@@ -1762,6 +1837,14 @@ export default function HomePage() {
|
||||
</span>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<LanguageSwitcher variant="compact" />
|
||||
<Link
|
||||
href="/stories"
|
||||
aria-label="我的剧情"
|
||||
title="我的剧情"
|
||||
className="text-base text-clay-500 hover:text-ember-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<i className="fa-solid fa-book-bookmark" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1935,19 +2018,15 @@ export default function HomePage() {
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-5">
|
||||
{DISPLAY_ORDER[galleryGender].map((origIdx) => {
|
||||
const c = stories[origIdx];
|
||||
if (!c) return null;
|
||||
return (
|
||||
<StoryCard
|
||||
key={`${imgPrefix}-${origIdx}`}
|
||||
title={c.title}
|
||||
outline={c.outline}
|
||||
image={`/home/${imgPrefix}${origIdx}.webp`}
|
||||
onClick={() => onCardClick(origIdx, c)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{featuredCards.map((card) => (
|
||||
<StoryCard
|
||||
key={card.id}
|
||||
title={card.title}
|
||||
outline={card.outline}
|
||||
image={card.coverPath}
|
||||
onClick={() => onCardClick(card.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+36
-15
@@ -21,6 +21,7 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { collectBeatAudioForExport } from "@/lib/exportAudio";
|
||||
import { loadFromLocalStorage } from "@/lib/clientStoryPersistence";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import {
|
||||
STORY_SHARE_STORAGE_KEY,
|
||||
@@ -807,10 +808,6 @@ function PlayInner() {
|
||||
const replayActiveRef = useRef(false);
|
||||
const exportingStoryRef = useRef(false);
|
||||
const exportingGalleryRef = useRef(false);
|
||||
// Audio carried in from a `.infiplot` share file, keyed by `${sceneId}:${beatId}`.
|
||||
// Survives scene swaps so a player who re-exports a replayed game keeps the
|
||||
// baked voices that the original creator already paid to synth — they're
|
||||
// free to embed back into the new gallery / share file.
|
||||
const prebakedAudioRef = useRef<Record<string, string>>({});
|
||||
// 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,
|
||||
@@ -1192,8 +1189,6 @@ function PlayInner() {
|
||||
setVisionClickEnabled(settings.visionClickEnabled);
|
||||
const nextPlayerName = settings.playerName || undefined;
|
||||
setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev);
|
||||
// Refresh the BYO TTS config so a key entered mid-session takes effect
|
||||
// immediately — byoTtsRef is otherwise only read once at mount.
|
||||
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
|
||||
byoTtsRef.current = cfg;
|
||||
setByoTtsConfig(cfg);
|
||||
@@ -1587,10 +1582,12 @@ function PlayInner() {
|
||||
// ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg
|
||||
// 后走 /api/start 现场生成
|
||||
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
|
||||
// ?storyId=<uuid> → 加载已保存的剧情(从 localStorage)
|
||||
const cardName = params.get("card");
|
||||
const presetId = params.get("preset");
|
||||
const isCustom = params.get("custom") === "1";
|
||||
const isShare = params.get("share") === "1";
|
||||
const storyId = params.get("storyId");
|
||||
|
||||
if (isShare) {
|
||||
(async () => {
|
||||
@@ -1629,11 +1626,6 @@ function PlayInner() {
|
||||
replayIndexRef.current = 0;
|
||||
replayActiveRef.current = imported.history.length > 1;
|
||||
visitedBeatsRef.current = [first.scene.entryBeatId];
|
||||
// Stash pre-baked audio (from doc.audioByBeatId) so it survives scene
|
||||
// swaps and re-exports. Keyed by `${sceneId}:${beatId}`. Also seed the
|
||||
// current beatAudioMap for the first scene so audio plays right away
|
||||
// — the scene-change effect normally clears the map on transition,
|
||||
// and bare beat ids "b1/b2/..." would otherwise miss prebaked entries.
|
||||
if (doc.audioByBeatId) {
|
||||
prebakedAudioRef.current = { ...doc.audioByBeatId };
|
||||
const seed: Record<string, string> = {};
|
||||
@@ -1710,11 +1702,43 @@ function PlayInner() {
|
||||
// be tagged onto the local Session build for /api/scene calls).
|
||||
const sessionLanguage: string = locale;
|
||||
|
||||
if (!cardName && !livePayload) {
|
||||
if (!cardName && !livePayload && !storyId) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Load saved story path ──
|
||||
if (storyId) {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
|
||||
const loadedSession = loadFromLocalStorage(storyId);
|
||||
if (!loadedSession) {
|
||||
setError("找不到保存的剧情");
|
||||
return;
|
||||
}
|
||||
const firstScene = loadedSession.history[0]?.scene;
|
||||
if (!firstScene) {
|
||||
setError("剧情数据损坏");
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? "");
|
||||
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? "";
|
||||
setSession(loadedSession);
|
||||
setCurrentScene(firstScene);
|
||||
setCurrentBeatId(firstScene.entryBeatId);
|
||||
setImageUrl(blobUrl);
|
||||
visitedBeatsRef.current = [firstScene.entryBeatId];
|
||||
setOrientation(loadedSession.orientation ?? "landscape");
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: loadedSession.history.length });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
type PrebakedFirstAct = StartResponse & {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
@@ -1766,9 +1790,6 @@ function PlayInner() {
|
||||
|
||||
fetchStart
|
||||
.then(async (data) => {
|
||||
// Resolve to a paintable src before committing to state. Proxy path:
|
||||
// a fully-local blob: URL the browser paints atomically (no row-by-row
|
||||
// "层层加载"). Direct path (default): the preloaded original URL.
|
||||
const blobUrl = await getOrCreateBlobUrl(data.imageUrl);
|
||||
lastImageOriginalUrlRef.current = data.imageUrl;
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
|
||||
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
|
||||
|
||||
export default function StoriesPage() {
|
||||
const [stories, setStories] = useState<StoryMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStoryList()
|
||||
.then(setStories)
|
||||
.catch(() => setStories([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (storyId: string) => {
|
||||
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return;
|
||||
|
||||
setDeletingId(storyId);
|
||||
const success = await deleteStory(storyId);
|
||||
|
||||
if (success) {
|
||||
setStories((prev) => prev.filter((s) => s.id !== storyId));
|
||||
} else {
|
||||
alert("删除失败,请稍后重试");
|
||||
}
|
||||
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
// D1 timestamps arrive as ISO strings over the JSON API boundary (the
|
||||
// server-side Date is serialized by NextResponse.json), so coerce before use.
|
||||
const formatDate = (value: Date | string | number) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return "今天";
|
||||
if (days === 1) return "昨天";
|
||||
if (days < 7) return `${days} 天前`;
|
||||
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* ================== HEADER ================== */}
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
InfiPlot
|
||||
</Link>
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
我 · 的 · 剧 · 情
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ================== CONTENT ================== */}
|
||||
<section className="px-6 md:px-16 pt-16 md:pt-24 pb-20 md:pb-24 flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
载 · 入 · 中
|
||||
</p>
|
||||
</div>
|
||||
) : stories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
|
||||
<i className="fa-solid fa-book-open text-4xl text-clay-300 mb-6" />
|
||||
<p className="font-serif italic text-lg text-clay-500 mb-4">
|
||||
还没有保存的剧情
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
|
||||
>
|
||||
回到首页开始新的故事
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{stories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
className="bg-cream-100 border border-clay-900/10 rounded-sm p-6 transition-all duration-200 hover:shadow-md hover:border-clay-900/20 relative group"
|
||||
>
|
||||
<Link
|
||||
href={`/play?storyId=${encodeURIComponent(story.id)}`}
|
||||
className="block cursor-pointer"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="font-serif text-lg text-clay-900 leading-tight mb-2 line-clamp-2">
|
||||
{story.worldSetting.slice(0, 60)}
|
||||
{story.worldSetting.length > 60 ? "..." : ""}
|
||||
</h3>
|
||||
<p className="text-sm text-clay-600 line-clamp-1">
|
||||
{story.styleGuide}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-photo-film text-[9px]" />
|
||||
{story.sceneCount} 幕
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-clock text-[9px]" />
|
||||
{formatDate(story.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete(story.id);
|
||||
}}
|
||||
disabled={deletingId === story.id}
|
||||
aria-label="删除"
|
||||
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-clay-400 hover:text-ember-500 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<i className={deletingId === story.id ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-trash-can"} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ================== FOOTER ================== */}
|
||||
<footer className="px-6 md:px-16 pb-8">
|
||||
<div className="hairline-full w-full mb-4" />
|
||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
||||
<span>MMXXVI</span>
|
||||
<span className="num">{stories.length} 个剧情</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user