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,41 @@
|
||||
import "server-only";
|
||||
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* Get D1 database instance from Cloudflare Workers env binding.
|
||||
*
|
||||
* Usage in API routes:
|
||||
* const db = getDb();
|
||||
* const stories = await db.select().from(schema.stories).where(...);
|
||||
*
|
||||
* @throws Error if called outside Cloudflare Workers runtime (e.g. local dev without wrangler)
|
||||
*/
|
||||
export function getDb() {
|
||||
try {
|
||||
const { env } = getCloudflareContext();
|
||||
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
"D1 binding 'DB' not found. " +
|
||||
"Ensure wrangler.jsonc has d1_databases configured and you're running via wrangler dev/deploy."
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
} catch (error) {
|
||||
// Re-throw with more context for debugging
|
||||
throw new Error(
|
||||
`Failed to get D1 database: ${error instanceof Error ? error.message : String(error)}. ` +
|
||||
"Make sure you're running in Cloudflare Workers context (wrangler dev/deploy)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for the Drizzle D1 database instance.
|
||||
* Useful for dependency injection and testing.
|
||||
*/
|
||||
export type DbInstance = ReturnType<typeof getDb>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import type { DbInstance } from "../client";
|
||||
import { featuredStories } from "../schema";
|
||||
import type { FeaturedStory } from "../schema";
|
||||
|
||||
/**
|
||||
* Featured Story Repository - encapsulates D1 access for homepage featured stories.
|
||||
*
|
||||
* Provides: listByGender (active only, sorted by sortOrder), incrementClick (analytics).
|
||||
*/
|
||||
export class FeaturedRepository {
|
||||
constructor(private db: DbInstance) {}
|
||||
|
||||
/**
|
||||
* List active featured stories for a given gender, ordered by sortOrder.
|
||||
*
|
||||
* @param gender "male" or "female"
|
||||
* @returns Array of FeaturedStory (only isActive=1, sorted by sortOrder ASC)
|
||||
*/
|
||||
async listByGender(gender: "male" | "female"): Promise<FeaturedStory[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(featuredStories)
|
||||
.where(and(eq(featuredStories.gender, gender), eq(featuredStories.isActive, 1)))
|
||||
.orderBy(featuredStories.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment click count for a featured story (analytics).
|
||||
*
|
||||
* @param id Featured story ID (e.g. "m0", "f12")
|
||||
* @returns true if updated, false if not found
|
||||
*/
|
||||
async incrementClick(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.update(featuredStories)
|
||||
.set({ clickCount: sql`${featuredStories.clickCount} + 1` })
|
||||
.where(eq(featuredStories.id, id));
|
||||
|
||||
// Drizzle D1 update returns { success, meta: { changes }, results }
|
||||
return ((result as any).meta?.changes ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ── Stories ──────────────────────────────────────────────────────────────
|
||||
// User story sessions (REQ-4). Each story contains multiple scenes and characters.
|
||||
export const stories = sqliteTable(
|
||||
"stories",
|
||||
{
|
||||
id: text("id").primaryKey(), // s_xxx session ID
|
||||
userId: text("user_id"), // nullable - Phase 1 uses anonymous sessionId
|
||||
worldSetting: text("world_setting").notNull(),
|
||||
styleGuide: text("style_guide").notNull(),
|
||||
styleReferenceImageKey: text("style_reference_image_key"), // R2 key (optional)
|
||||
orientation: text("orientation").notNull().default("landscape"), // "portrait" | "landscape"
|
||||
storyStateJson: text("story_state_json"), // JSON: StoryState
|
||||
status: text("status").notNull().default("active"), // "active" | "archived"
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`)
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("stories_user_id_idx").on(table.userId),
|
||||
createdAtIdx: index("stories_created_at_idx").on(table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Scenes ───────────────────────────────────────────────────────────────
|
||||
// Story scenes (REQ-4). Beats stored as JSON blob (not separate table).
|
||||
export const scenes = sqliteTable(
|
||||
"scenes",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
storyId: text("story_id")
|
||||
.notNull()
|
||||
.references(() => stories.id, { onDelete: "cascade" }),
|
||||
sceneKey: text("scene_key"), // e.g. "classroom-dusk"
|
||||
sceneSummary: text("scene_summary"),
|
||||
sceneImageKey: text("scene_image_key"), // R2 key (optional)
|
||||
sceneImageUrl: text("scene_image_url"), // Runware CDN URL (primary)
|
||||
beatsJson: text("beats_json"), // JSON: Beat[] - whole scene beats graph
|
||||
sortOrder: integer("sort_order").notNull(), // scene sequence in story
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
storyIdIdx: index("scenes_story_id_idx").on(table.storyId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Characters ───────────────────────────────────────────────────────────
|
||||
// Story characters (REQ-4). Each character belongs to a story.
|
||||
export const characters = sqliteTable(
|
||||
"characters",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
storyId: text("story_id")
|
||||
.notNull()
|
||||
.references(() => stories.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
visualDescription: text("visual_description"),
|
||||
voiceDescription: text("voice_description"),
|
||||
basePortraitKey: text("base_portrait_key"), // R2 key (optional)
|
||||
basePortraitUrl: text("base_portrait_url"), // CDN URL (primary)
|
||||
basePortraitUuid: text("base_portrait_uuid"), // image service UUID
|
||||
voiceJson: text("voice_json"), // JSON: CharacterVoice
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
storyNameIdx: uniqueIndex("characters_story_name_idx").on(
|
||||
table.storyId,
|
||||
table.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Featured Stories ─────────────────────────────────────────────────────
|
||||
// Featured story cards displayed on homepage (REQ-5).
|
||||
export const featuredStories = sqliteTable(
|
||||
"featured_stories",
|
||||
{
|
||||
id: text("id").primaryKey(), // e.g. "m0", "f12"
|
||||
gender: text("gender").notNull(), // "male" | "female"
|
||||
title: text("title").notNull(),
|
||||
outline: text("outline").notNull(),
|
||||
style: text("style").notNull(),
|
||||
tags: text("tags").notNull(), // JSON array
|
||||
coverPath: text("cover_path").notNull(), // e.g. "/home/m0.webp"
|
||||
firstactPath: text("firstact_path").notNull(), // e.g. "/home/firstact/m0.json"
|
||||
firstscenePath: text("firstscene_path"), // e.g. "/home/firstscene/m0.webp"
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
isActive: integer("is_active").notNull().default(1), // 1 = active, 0 = inactive
|
||||
clickCount: integer("click_count").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
genderActiveIdx: index("featured_gender_active_idx").on(
|
||||
table.gender,
|
||||
table.isActive,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Type exports ─────────────────────────────────────────────────────────
|
||||
export type Story = typeof stories.$inferSelect;
|
||||
export type NewStory = typeof stories.$inferInsert;
|
||||
|
||||
export type Scene = typeof scenes.$inferSelect;
|
||||
export type NewScene = typeof scenes.$inferInsert;
|
||||
|
||||
export type Character = typeof characters.$inferSelect;
|
||||
export type NewCharacter = typeof characters.$inferInsert;
|
||||
|
||||
export type FeaturedStory = typeof featuredStories.$inferSelect;
|
||||
export type NewFeaturedStory = typeof featuredStories.$inferInsert;
|
||||
Reference in New Issue
Block a user