Files
infiplot-web/app/stories/page.tsx
T
Zonghao Yuan 0e4c2ebef4 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>
2026-06-18 18:05:38 +08:00

158 lines
6.0 KiB
TypeScript

"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>
);
}