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:
Zonghao Yuan
2026-06-18 18:05:38 +08:00
committed by GitHub
parent 05bd7e229c
commit 0e4c2ebef4
78 changed files with 7396 additions and 919 deletions
+299
View File
@@ -0,0 +1,299 @@
// Client-side story persistence helpers.
//
// Provides: anonymous user ID management, save/load functions that call
// /api/stories/* and fallback to localStorage when D1 is unavailable.
import type { Session, Scene, Character, StoryState } from "@infiplot/types";
import type { StorySaveInput, SceneSaveInput, CharacterSaveInput, StoryMeta, StoryLoadResult } from "@/lib/db/repositories/storyRepo";
const USER_ID_KEY = "infiplot:userId";
const SAVE_FALLBACK_KEY = "infiplot:savedStories";
// ── Anonymous User ID ────────────────────────────────────────────────────
export function getOrCreateUserId(): string {
if (typeof window === "undefined") return "";
try {
let id = localStorage.getItem(USER_ID_KEY);
if (!id) {
id = `anon_${crypto.randomUUID()}`;
localStorage.setItem(USER_ID_KEY, id);
}
return id;
} catch {
return `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
}
// ── Session → Save Input Conversion ─────────────────────────────────────
export function sessionToSaveInput(session: Session): {
story: StorySaveInput;
scenes: SceneSaveInput[];
characters: CharacterSaveInput[];
} {
const story: StorySaveInput = {
id: session.id,
userId: getOrCreateUserId(),
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
styleReferenceImage: session.styleReferenceImage,
orientation: (session.orientation as "portrait" | "landscape") ?? "landscape",
storyState: session.storyState,
status: "active",
};
const scenes: SceneSaveInput[] = (session.history ?? []).map(
(entry, idx) => ({
id: entry.scene.id,
sceneKey: entry.scene.sceneKey,
sceneSummary: entry.scene.scenePrompt,
imageUrl: entry.scene.imageUrl ?? "",
beats: entry.scene.beats,
sortOrder: idx,
}),
);
const characters: CharacterSaveInput[] = (session.characters ?? []).map(
(c) => ({
name: c.name,
visualDescription: c.visualDescription,
voiceDescription: c.voiceDescription,
portrait:
c.basePortraitUrl || c.basePortraitUuid
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid }
: undefined,
voice: c.voice,
}),
);
return { story, scenes, characters };
}
// ── Save ─────────────────────────────────────────────────────────────────
export type SaveResult =
| { ok: true; storyId: string; source: "server" }
| { ok: true; storyId: string; source: "localStorage" }
| { ok: false; error: string };
export async function saveStory(session: Session): Promise<SaveResult> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration).
// Anonymous D1 writes lack rate limiting / quota / ownership checks — an
// abuse risk on a public registration-less site. Persist locally instead.
return saveToLocalStorage(session);
/* DISABLED: D1 server path (will re-enable after auth integration)
const { story, scenes, characters } = sessionToSaveInput(session);
try {
const res = await fetch("/api/stories/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ story, scenes, characters }),
});
if (res.ok) {
const data = (await res.json()) as { storyId: string };
return { ok: true, storyId: data.storyId, source: "server" };
}
// Server failed - fallback to localStorage
throw new Error(`Server returned ${res.status}`);
} catch {
// D1 unavailable or network error - fallback to localStorage
return saveToLocalStorage(session);
}
*/
}
function saveToLocalStorage(session: Session): SaveResult {
try {
const existing = loadFromLocalStorageAll();
// Strip bulky fields before persistence to stay within localStorage quota
// (~5-10MB across ALL keys). Without this, a multi-scene session with
// several voiced characters serializes to 1-2MB+ (voice.referenceAudioBase64
// is ~160KB each, styleReferenceImage 30-80KB), which can exceed quota and
// — worse — block the main thread on the synchronous localStorage write,
// freezing the subsequent navigation back to the home page. Both fields are
// reconstructible: voices re-provision on the next /api/scene call, and
// styleReferenceImage is cosmetic (engine regenerates gracefully without it).
const slimSession: Session = {
...session,
styleReferenceImage: undefined,
characters: session.characters.map((c) => ({ ...c, voice: undefined })),
};
const entry = {
id: session.id,
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
sceneCount: session.history?.length ?? 0,
savedAt: Date.now(),
sessionJson: JSON.stringify(slimSession),
};
const updated = [entry, ...existing.filter((e) => e.id !== session.id)].slice(0, 20);
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return { ok: true, storyId: session.id, source: "localStorage" };
} catch {
return { ok: false, error: "无法保存到本地存储" };
}
}
// ── Load ─────────────────────────────────────────────────────────────────
export async function loadStoryList(): Promise<StoryMeta[]> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
const entries = loadFromLocalStorageAll();
return entries.map((e) => ({
id: e.id,
userId: null, // anonymous
worldSetting: e.worldSetting,
styleGuide: e.styleGuide,
orientation: "landscape", // localStorage doesn't store this, default
status: "active",
sceneCount: e.sceneCount,
createdAt: new Date(e.savedAt),
updatedAt: new Date(e.savedAt),
}));
/* DISABLED: D1 server path (will re-enable after auth integration)
const userId = getOrCreateUserId();
try {
const res = await fetch(`/api/stories/list?userId=${encodeURIComponent(userId)}`);
if (res.ok) {
const data = (await res.json()) as { stories: StoryMeta[] };
return data.stories;
}
return [];
} catch {
return [];
}
*/
}
export async function loadStory(storyId: string): Promise<StoryLoadResult | null> {
// TEMPORARY: localStorage-only mode — unused in current code (play page uses
// loadFromLocalStorage directly). Returns null to maintain type compatibility.
// Will be re-enabled when D1 is restored after auth integration.
return null;
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`);
if (res.ok) {
return (await res.json()) as StoryLoadResult;
}
return null;
} catch {
return null;
}
*/
}
export async function deleteStory(storyId: string): Promise<boolean> {
// TEMPORARY: localStorage-only mode
try {
const existing = loadFromLocalStorageAll();
const updated = existing.filter((e) => e.id !== storyId);
if (updated.length === existing.length) return false; // not found
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return true;
} catch {
return false;
}
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
method: "DELETE",
});
return res.ok;
} catch {
return false;
}
*/
}
// ── localStorage fallback helpers ────────────────────────────────────────
type LocalStorageEntry = {
id: string;
worldSetting: string;
styleGuide: string;
sceneCount: number;
savedAt: number;
sessionJson: string;
};
function loadFromLocalStorageAll(): LocalStorageEntry[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(SAVE_FALLBACK_KEY);
if (!raw) return [];
return JSON.parse(raw) as LocalStorageEntry[];
} catch {
return [];
}
}
export function loadFromLocalStorage(storyId: string): Session | null {
const entries = loadFromLocalStorageAll();
const entry = entries.find((e) => e.id === storyId);
if (!entry) return null;
try {
return JSON.parse(entry.sessionJson) as Session;
} catch {
return null;
}
}
// ── StoryLoadResult → Session Conversion ─────────────────────────────────
/**
* Convert StoryLoadResult (API response from /api/stories/[id]) back to Session
* shape consumed by app/play/page.tsx.
*/
export function storyLoadResultToSession(result: StoryLoadResult): Session {
const { story, scenes, characters } = result;
// Map scenes back to SceneHistoryEntry structure
const history = scenes.map((s) => {
const beats = s.beats ?? [];
// entryBeatId is not persisted in D1 — recover it from the first beat.
const entryBeatId = beats[0]?.id ?? "";
return {
scene: {
id: s.id,
sceneKey: s.sceneKey,
scenePrompt: s.sceneSummary ?? "",
imageUrl: s.imageUrl,
beats,
entryBeatId,
orientation: s.orientation,
},
visitedBeatIds: entryBeatId ? [entryBeatId] : [], // rebuilt as user navigates
exit: undefined, // Not persisted in D1
};
});
return {
id: story.id,
// createdAt crosses the JSON API boundary as an ISO string, so coerce it
// back to an epoch the Session shape expects (number).
createdAt: new Date(story.createdAt).getTime(),
worldSetting: story.worldSetting,
styleGuide: story.styleGuide,
styleReferenceImage: story.styleReferenceImage,
orientation: story.orientation,
storyState: story.storyState,
history,
characters: characters.map((c) => ({
name: c.name,
voiceDescription: c.voiceDescription ?? "",
visualDescription: c.visualDescription,
basePortraitUuid: c.portrait?.uuid,
basePortraitUrl: c.portrait?.url,
voice: c.voice,
})),
};
}