Files
infiplot-web/lib/db/repositories/storyRepo.ts
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

309 lines
9.9 KiB
TypeScript

import "server-only";
import { eq, desc, sql, inArray } from "drizzle-orm";
import type { DbInstance } from "../client";
import { stories, scenes, characters } from "../schema";
import type { Session, Scene as EngineScene, Character as EngineCharacter, StoryState } from "@infiplot/types";
// ── Type Adapters ────────────────────────────────────────────────────────
/**
* Input shape for saving a story session.
* Mirrors Session but with explicit story-level fields.
*/
export type StorySaveInput = {
id: string; // Session ID
userId?: string; // nullable - Phase 1 uses anonymous sessionId
worldSetting: string;
styleGuide: string;
styleReferenceImage?: string; // data URI or R2 key (TBD in save logic)
orientation: "portrait" | "landscape";
storyState?: StoryState;
status?: "active" | "archived";
};
export type SceneSaveInput = {
id: string;
sceneKey?: string;
sceneSummary?: string;
imageUrl: string; // Runware CDN URL (primary)
beats: EngineScene["beats"]; // Beat graph - will be serialized to beatsJson
orientation?: "portrait" | "landscape";
sortOrder: number; // scene sequence in story
};
export type CharacterSaveInput = {
name: string;
visualDescription?: string;
voiceDescription?: string;
portrait?: {
url?: string;
uuid?: string;
};
voice?: EngineCharacter["voice"];
};
/**
* Story metadata for list views.
*/
export type StoryMeta = {
id: string;
userId: string | null;
worldSetting: string;
styleGuide: string;
orientation: string;
status: string;
sceneCount: number;
createdAt: Date;
updatedAt: Date;
};
/**
* Full story load result (maps back to Session structure).
*/
export type StoryLoadResult = {
story: {
id: string;
userId: string | null;
worldSetting: string;
styleGuide: string;
styleReferenceImage?: string;
orientation: "portrait" | "landscape";
storyState?: StoryState;
status: string;
createdAt: Date;
updatedAt: Date;
};
scenes: Array<{
id: string;
sceneKey?: string;
sceneSummary?: string;
imageUrl: string;
beats: EngineScene["beats"];
orientation?: "portrait" | "landscape";
sortOrder: number;
createdAt: Date;
}>;
characters: Array<{
name: string;
visualDescription?: string;
voiceDescription?: string;
portrait?: {
url?: string;
uuid?: string;
};
voice?: EngineCharacter["voice"];
}>;
};
// ── Repository ───────────────────────────────────────────────────────────
/**
* Story Repository - encapsulates D1 access for story persistence.
*
* **Atomic save**: uses D1 batch transaction to ensure all-or-nothing writes.
* **Cascade delete**: relies on schema FK ON DELETE CASCADE.
* **Serialization**: beats and storyState are JSON-serialized to TEXT columns.
*/
export class StoryRepository {
constructor(private db: DbInstance) {}
/**
* Save a complete story session (story + scenes + characters) atomically.
* Uses D1 batch transaction - all writes succeed or all fail.
*
* @param input Story metadata
* @param sceneInputs Scene list (beats will be serialized)
* @param characterInputs Character list (voice will be serialized)
* @returns storyId on success
* @throws Error if D1 transaction fails
*/
async save(
input: StorySaveInput,
sceneInputs: SceneSaveInput[],
characterInputs: CharacterSaveInput[],
): Promise<{ storyId: string }> {
const now = new Date();
// Build story record
const storyRecord = {
id: input.id,
userId: input.userId ?? null,
worldSetting: input.worldSetting,
styleGuide: input.styleGuide,
styleReferenceImageKey: input.styleReferenceImage ?? null, // Phase 1: store data URI as-is; R2 upload TBD
orientation: input.orientation,
storyStateJson: input.storyState ? JSON.stringify(input.storyState) : null,
status: input.status ?? "active",
createdAt: now,
updatedAt: now,
};
// Build scene records (serialize beats to JSON)
const sceneRecords = sceneInputs.map((s, idx) => ({
id: s.id,
storyId: input.id,
sceneKey: s.sceneKey ?? null,
sceneSummary: s.sceneSummary ?? null,
sceneImageKey: null, // Phase 1: R2 upload TBD
sceneImageUrl: s.imageUrl,
beatsJson: JSON.stringify(s.beats),
sortOrder: s.sortOrder ?? idx,
createdAt: now,
}));
// Build character records (serialize voice to JSON, ensure uniqueness per story+name)
const characterRecords = characterInputs.map((c, idx) => ({
id: `${input.id}_char_${idx}`, // synthetic ID
storyId: input.id,
name: c.name,
visualDescription: c.visualDescription ?? null,
voiceDescription: c.voiceDescription ?? null,
basePortraitKey: null, // Phase 1: R2 upload TBD
basePortraitUrl: c.portrait?.url ?? null,
basePortraitUuid: c.portrait?.uuid ?? null,
voiceJson: c.voice ? JSON.stringify(c.voice) : null,
createdAt: now,
}));
// Execute atomic batch transaction
await this.db.batch([
this.db.insert(stories).values(storyRecord).onConflictDoUpdate({
target: stories.id,
set: {
worldSetting: storyRecord.worldSetting,
styleGuide: storyRecord.styleGuide,
styleReferenceImageKey: storyRecord.styleReferenceImageKey,
orientation: storyRecord.orientation,
storyStateJson: storyRecord.storyStateJson,
status: storyRecord.status,
updatedAt: now,
},
}),
// Clear old scenes/characters (will cascade delete via FK)
this.db.delete(scenes).where(eq(scenes.storyId, input.id)),
this.db.delete(characters).where(eq(characters.storyId, input.id)),
// Insert new scenes/characters
...sceneRecords.map((r) => this.db.insert(scenes).values(r)),
...characterRecords.map((r) => this.db.insert(characters).values(r)),
]);
return { storyId: input.id };
}
/**
* Load a complete story by ID, reconstructing Session shape.
*
* @param storyId Story primary key
* @returns StoryLoadResult with deserialized beats/storyState, or null if not found
*/
async findById(storyId: string): Promise<StoryLoadResult | null> {
const [storyRow] = await this.db
.select()
.from(stories)
.where(eq(stories.id, storyId))
.limit(1);
if (!storyRow) return null;
const sceneRows = await this.db
.select()
.from(scenes)
.where(eq(scenes.storyId, storyId))
.orderBy(scenes.sortOrder);
const characterRows = await this.db
.select()
.from(characters)
.where(eq(characters.storyId, storyId));
return {
story: {
id: storyRow.id,
userId: storyRow.userId,
worldSetting: storyRow.worldSetting,
styleGuide: storyRow.styleGuide,
styleReferenceImage: storyRow.styleReferenceImageKey ?? undefined,
orientation: storyRow.orientation as "portrait" | "landscape",
storyState: storyRow.storyStateJson
? (JSON.parse(storyRow.storyStateJson) as StoryState)
: undefined,
status: storyRow.status,
createdAt: storyRow.createdAt,
updatedAt: storyRow.updatedAt,
},
scenes: sceneRows.map((s) => ({
id: s.id,
sceneKey: s.sceneKey ?? undefined,
sceneSummary: s.sceneSummary ?? undefined,
imageUrl: s.sceneImageUrl ?? "", // CR-5: nullable column, fallback to empty string
beats: s.beatsJson ? JSON.parse(s.beatsJson) : [],
orientation: s.sceneImageUrl ? undefined : undefined, // Phase 1: no per-scene orientation in schema
sortOrder: s.sortOrder,
createdAt: s.createdAt,
})),
characters: characterRows.map((c) => ({
name: c.name,
visualDescription: c.visualDescription ?? undefined,
voiceDescription: c.voiceDescription ?? undefined,
portrait: c.basePortraitUrl
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid ?? undefined }
: undefined,
voice: c.voiceJson ? JSON.parse(c.voiceJson) : undefined,
})),
};
}
/**
* List story metadata for a given user, ordered by most recent first.
*
* @param userId User ID (or anonymous sessionId in Phase 1)
* @param limit Max stories to return (default 50)
* @returns Array of StoryMeta
*/
async listByUser(userId: string, limit = 50): Promise<StoryMeta[]> {
const storyRows = await this.db
.select()
.from(stories)
.where(eq(stories.userId, userId))
.orderBy(desc(stories.updatedAt))
.limit(limit);
if (storyRows.length === 0) return [];
// CR-10: batch scene count in 2 queries total (not N+1)
const storyIds = storyRows.map((r) => r.id);
const countRows = await this.db
.select({ storyId: scenes.storyId, count: sql<number>`count(*)` })
.from(scenes)
.where(inArray(scenes.storyId, storyIds))
.groupBy(scenes.storyId);
const countMap = new Map(countRows.map((r) => [r.storyId, r.count]));
return storyRows.map((row) => ({
id: row.id,
userId: row.userId,
worldSetting: row.worldSetting,
styleGuide: row.styleGuide,
orientation: row.orientation,
status: row.status,
sceneCount: countMap.get(row.id) ?? 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
/**
* Delete a story and all associated scenes/characters (cascade via FK).
*
* @param storyId Story primary key
* @returns true if deleted, false if not found
*/
async delete(storyId: string): Promise<boolean> {
const result = await this.db.delete(stories).where(eq(stories.id, storyId));
// Drizzle D1 delete returns { success, meta: { changes }, results }
return ((result as any).meta?.changes ?? 0) > 0;
}
}