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
+161 -113
View File
@@ -1,22 +1,19 @@
import { chat } from "@infiplot/ai-client";
import { chatStream } from "@infiplot/ai-client";
import type {
Beat,
BeatActiveCharacter,
BeatChoice,
BeatChoiceEffect,
BeatNext,
ChatStreamResult,
ProviderConfig,
Session,
StoryStatePatch,
WriterPlan,
WriterScenePlan,
} from "@infiplot/types";
import { parseJsonLoose } from "../jsonParser";
import {
WRITER_BEATS_SYSTEM,
WRITER_PLAN_SYSTEM,
buildWriterBeatsUserMessage,
buildWriterPlanUserMessage,
} from "../prompts";
import { buildWriterStreamMessages } from "../prompts";
// ──────────────────────────────────────────────────────────────────────
// Writer agent — owns the narrative half of scene generation, in TWO phases.
@@ -353,8 +350,9 @@ function coerceStringArray(raw: unknown): string[] | undefined {
// Pull the volatile story-memory rewrite out of the Writer's JSON. Only
// non-empty fields are kept; an all-empty/absent patch returns undefined so
// the director leaves the carried StoryState untouched.
function coerceStoryStatePatch(
// the director leaves the carried StoryState untouched. Exported so the
// prose splitter can reuse it to parse the <story> segment's <memory> block.
export function coerceStoryStatePatch(
raw: RawStoryStatePatch | undefined,
): StoryStatePatch | undefined {
if (!raw || typeof raw !== "object") return undefined;
@@ -409,110 +407,7 @@ function renameBeatId(beats: Beat[], from: string, to: string): Beat[] {
});
}
// ── Phase A — plan the scene skeleton. Fast (small output): just enough for
// the Cinematographer + character design + Painter to start before the
// dialogue exists. The cast is unioned with the entry roster/speaker so a
// character named in the entry but omitted from `cast` still gets designed.
export async function runWriterPlan(
config: ProviderConfig,
session: Session,
): Promise<WriterPlan> {
const raw = await chat(
config,
[
{ role: "system", content: WRITER_PLAN_SYSTEM },
{ role: "user", content: buildWriterPlanUserMessage(session) },
],
{ temperature: 0.9, tag: "writer-plan" },
);
const parsed = parseJsonLoose<RawPlan>(raw);
const entryActiveCharacters =
coerceActiveCharacters(parsed.entryActiveCharacters) ?? [];
// Normalize POV variants → "你"; NPC names pass through. "你" is a valid entry
// speaker (Pattern B — player talking), but is never a designed cast member.
const rawEntrySpeaker = parsed.entrySpeaker?.trim() || undefined;
const entrySpeaker = rawEntrySpeaker
? normalizeSpeakerName(rawEntrySpeaker)
: undefined;
const cast = coerceCast(parsed.cast);
const castSet = new Set(cast);
const addToCast = (name: string): void => {
if (!isPovName(name) && !castSet.has(name)) {
castSet.add(name);
cast.push(name);
}
};
for (const c of entryActiveCharacters) addToCast(c.name);
if (entrySpeaker) addToCast(entrySpeaker);
return {
sceneSummary: parsed.sceneSummary?.trim() || "未指定场景概要",
sceneKey: normalizeSceneKey(parsed.sceneKey),
entryBeatId: parsed.entryBeatId?.trim() || "b1",
cast,
entryActiveCharacters,
entrySpeaker,
};
}
// ── Phase B — expand the plan into the full beats[] graph + storyStatePatch.
// Overlapped with the image pipeline by the director. The plan's entry id is
// pinned onto a real beat so the already-painted entry frame resolves.
export async function runWriterBeats(
config: ProviderConfig,
session: Session,
plan: WriterPlan,
): Promise<WriterBeatsOutput> {
const raw = await chat(
config,
[
{ role: "system", content: WRITER_BEATS_SYSTEM },
{ role: "user", content: buildWriterBeatsUserMessage(session, plan) },
],
{ temperature: 0.9, tag: "writer-beats" },
);
const parsed = parseJsonLoose<RawBeats>(raw);
const rawBeats = Array.isArray(parsed.beats) ? parsed.beats : [];
if (rawBeats.length === 0) {
throw new Error("Writer (beats) returned no beats");
}
let beats = ensureUniqueChoiceIds(
repairBeats(
ensureUniqueBeatIds(
rawBeats.map((b, i) => coerceBeat(b, i, rawBeats.length)),
),
),
);
// The Painter already composed the entry frame from plan.entryBeatId + its
// roster, so the scene's entry MUST resolve to that id. If Phase B ignored
// it, rename the first beat to it (no collision — id is absent by the guard).
if (!beats.some((b) => b.id === plan.entryBeatId)) {
beats = renameBeatId(beats, beats[0]!.id, plan.entryBeatId);
}
// 把入场 beat 的 roster 钉成 plan 的:画师合成进帧的正是
// plan.entryActiveCharacters,运行时入场 beat 必须显示同一批人(与上面钉
// id 同理)。speaker 故意不钉——它和 line/TTS 耦合,强行覆盖会错配台词。
const entryRoster =
plan.entryActiveCharacters.length > 0 ? plan.entryActiveCharacters : undefined;
beats = beats.map((b) =>
b.id === plan.entryBeatId ? { ...b, activeCharacters: entryRoster } : b,
);
return {
beats,
storyStatePatch: coerceStoryStatePatch(parsed.storyStatePatch),
};
}
// Phase B fallback — when runWriterBeats fails entirely, keep the scene
// Fallback — when the Writer stream fails to yield usable beats, keep the scene
// playable with a single entry beat synthesized from the plan: narrate the
// planned summary and offer one change-scene exit so the player can advance.
export function synthesizeFallbackBeats(plan: WriterPlan): Beat[] {
@@ -532,3 +427,156 @@ export function synthesizeFallbackBeats(plan: WriterPlan): Beat[] {
// Re-export POV constants for downstream filters (director's orphan voices).
export { POV_DISPLAY_NAME, POV_VARIANTS, isPovName, normalizeSpeakerName };
// ──────────────────────────────────────────────────────────────────────
// Paradigm D — single-pass streaming Writer
// ──────────────────────────────────────────────────────────────────────
/**
* Streaming Writer: single LLM call producing `<plan>/<story>/<choices>`
* tagged output. The caller (director) feeds the textStream to StreamRouter
* which dispatches downstream agents as tags close.
*
* Uses `chatStream` (Task 2) + `buildWriterStreamUserMessage` (ContextProvider).
* Temperature and tag mirror the existing chat() calls.
*/
export function runWriterStream(
config: ProviderConfig,
session: Session,
): ChatStreamResult {
return chatStream(
config,
buildWriterStreamMessages(session),
{ temperature: 0.9, tag: "writer-stream" },
);
}
/**
* Coerce a raw parsed plan (from StreamRouter's `<plan>` segment) into a
* clean WriterScenePlan. Reuses the existing Phase A coercion pipeline.
*/
export function coercePlanFromRaw(raw: Record<string, unknown>): WriterScenePlan {
const entryActiveCharacters =
coerceActiveCharacters(raw.entryActiveCharacters as RawActiveCharacter[]) ?? [];
const rawEntrySpeaker =
typeof raw.entrySpeaker === "string" ? raw.entrySpeaker.trim() : undefined;
const entrySpeaker = rawEntrySpeaker
? normalizeSpeakerName(rawEntrySpeaker)
: undefined;
const cast = coerceCast(raw.cast);
const castSet = new Set(cast);
const addToCast = (name: string): void => {
if (!isPovName(name) && !castSet.has(name)) {
castSet.add(name);
cast.push(name);
}
};
for (const c of entryActiveCharacters) addToCast(c.name);
if (entrySpeaker) addToCast(entrySpeaker);
const characterIntents = Array.isArray(raw.characterIntents)
? (raw.characterIntents as Array<Record<string, unknown>>)
.filter((ci) => typeof ci.name === "string" && (ci.name as string).trim())
.map((ci) => ({
name: (ci.name as string).trim(),
mood: typeof ci.mood === "string" ? ci.mood.trim() || undefined : undefined,
motivation:
typeof ci.motivation === "string"
? ci.motivation.trim() || undefined
: undefined,
speakingTone:
typeof ci.speakingTone === "string"
? ci.speakingTone.trim() || undefined
: undefined,
}))
: undefined;
// Story bible — first scene only. The Writer's <plan> includes a storyBible
// sub-object on the opening scene (replacing the old Architect call). Absent
// on subsequent scenes (the carried StoryState stays authoritative).
const rawBible = raw.storyBible as Record<string, unknown> | undefined;
let storyBible: WriterScenePlan["storyBible"];
if (rawBible && typeof rawBible === "object") {
const logline = typeof rawBible.logline === "string" ? rawBible.logline.trim() : "";
const genreTags = typeof rawBible.genreTags === "string" ? rawBible.genreTags.trim() : "";
const protagonist =
typeof rawBible.protagonist === "string" ? rawBible.protagonist.trim() : "";
const castNotes =
typeof rawBible.castNotes === "string" ? rawBible.castNotes.trim() || undefined : undefined;
// Only treat it as a real bible if at least one core field is present.
if (logline || genreTags || protagonist) {
storyBible = { logline, genreTags, protagonist, castNotes };
}
}
return {
sceneSummary:
typeof raw.sceneSummary === "string"
? raw.sceneSummary.trim() || "未指定场景概要"
: "未指定场景概要",
sceneKey: normalizeSceneKey(
typeof raw.sceneKey === "string" ? raw.sceneKey : undefined,
),
entryBeatId:
typeof raw.entryBeatId === "string"
? raw.entryBeatId.trim() || "b1"
: "b1",
cast,
entryActiveCharacters,
entrySpeaker,
characterIntents,
storyBible,
};
}
/**
* Coerce raw beats into clean Beat[] + optional StoryStatePatch. Called by
* proseSplitter (散文→RawBeat[]) and as fallback for degraded streams.
* Reuses the full pipeline: coerceBeat → ensureUniqueBeatIds → repairBeats →
* ensureUniqueChoiceIds → entry-id pinning.
*/
export function coerceBeatsFromRaw(
raw: unknown,
plan: WriterScenePlan,
): WriterBeatsOutput {
// Input can be a bare RawBeat[] or { beats, storyStatePatch } wrapper.
let rawBeats: RawBeat[] = [];
let rawPatch: RawStoryStatePatch | undefined;
if (Array.isArray(raw)) {
rawBeats = raw;
} else if (raw && typeof raw === "object") {
const obj = raw as Record<string, unknown>;
rawBeats = Array.isArray(obj.beats) ? (obj.beats as RawBeat[]) : [];
rawPatch = obj.storyStatePatch as RawStoryStatePatch | undefined;
}
if (rawBeats.length === 0) {
return { beats: synthesizeFallbackBeats(plan), storyStatePatch: undefined };
}
let beats = ensureUniqueChoiceIds(
repairBeats(
ensureUniqueBeatIds(
rawBeats.map((b, i) => coerceBeat(b, i, rawBeats.length)),
),
),
);
if (!beats.some((b) => b.id === plan.entryBeatId)) {
beats = renameBeatId(beats, beats[0]!.id, plan.entryBeatId);
}
const entryRoster =
plan.entryActiveCharacters.length > 0 ? plan.entryActiveCharacters : undefined;
beats = beats.map((b) =>
b.id === plan.entryBeatId ? { ...b, activeCharacters: entryRoster } : b,
);
return {
beats,
storyStatePatch: coerceStoryStatePatch(rawPatch),
};
}