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