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:
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user