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:
@@ -1,90 +0,0 @@
|
||||
import { chat } from "@infiplot/ai-client";
|
||||
import type { ProviderConfig, Session, StoryState } from "@infiplot/types";
|
||||
import { parseJsonLoose } from "../jsonParser";
|
||||
import { ARCHITECT_SYSTEM, buildArchitectUserMessage } from "../prompts";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Architect agent — ONE LLM call at session start.
|
||||
//
|
||||
// Expands the user's (often terse) world + style prompt into a real story
|
||||
// bible: a second-person protagonist with a want and a flaw, a single
|
||||
// central dramatic question (logline), a genre frame that anchors the
|
||||
// 爽点 rhythm, an engineered cold-open for scene 1 (nextHook), and a small
|
||||
// intentional cast. Seeds the StoryState that the Writer reads and updates
|
||||
// every scene — so the story has a spine from beat one instead of being
|
||||
// improvised cold.
|
||||
//
|
||||
// Everything is best-effort coerced with fallbacks: a malformed LLM
|
||||
// response can never abort session start — worst case the Writer just gets
|
||||
// a thinner bible and improvises more.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RawStoryState = {
|
||||
logline?: unknown;
|
||||
genreTags?: unknown;
|
||||
protagonist?: unknown;
|
||||
castNotes?: unknown;
|
||||
synopsis?: unknown;
|
||||
openThreads?: unknown;
|
||||
relationships?: unknown;
|
||||
nextHook?: unknown;
|
||||
};
|
||||
|
||||
function str(raw: unknown): string {
|
||||
return typeof raw === "string" ? raw.trim() : "";
|
||||
}
|
||||
|
||||
function strArray(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out = raw
|
||||
.map((x) => (typeof x === "string" ? x.trim() : ""))
|
||||
.filter((x) => x.length > 0);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function runArchitect(
|
||||
config: ProviderConfig,
|
||||
session: Session,
|
||||
): Promise<StoryState> {
|
||||
try {
|
||||
const raw = await chat(
|
||||
config,
|
||||
[
|
||||
{ role: "system", content: ARCHITECT_SYSTEM },
|
||||
{ role: "user", content: buildArchitectUserMessage(session) },
|
||||
],
|
||||
{ temperature: 0.85, tag: "architect" },
|
||||
);
|
||||
|
||||
const parsed = parseJsonLoose<RawStoryState>(raw);
|
||||
|
||||
return {
|
||||
// Stable spine — fall back to the raw world/style prompt so the bible is
|
||||
// never wholly empty even if the model returns garbage.
|
||||
logline: str(parsed.logline) || session.worldSetting,
|
||||
genreTags: str(parsed.genreTags),
|
||||
protagonist:
|
||||
str(parsed.protagonist) ||
|
||||
"你是这个故事的主角(第二人称视角,永不出现在画面里)。",
|
||||
castNotes: str(parsed.castNotes) || undefined,
|
||||
// Volatile seeds — the opening Writer will rewrite these via its patch.
|
||||
synopsis: str(parsed.synopsis) || "故事即将开始。",
|
||||
openThreads: strArray(parsed.openThreads),
|
||||
relationships: strArray(parsed.relationships),
|
||||
nextHook: str(parsed.nextHook) || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
// chat() or parseJsonLoose() can throw (network / unrepairable JSON).
|
||||
// The Architect is best-effort: never let it abort session start — return
|
||||
// a minimal bible seeded from the raw prompt and let the Writer improvise.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[architect] failed, using minimal bible: ${msg}`);
|
||||
return {
|
||||
logline: session.worldSetting,
|
||||
genreTags: "",
|
||||
protagonist:
|
||||
"你是这个故事的主角(第二人称视角,永不出现在画面里)。",
|
||||
synopsis: "故事即将开始。",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@infiplot/tts-client";
|
||||
import type {
|
||||
Character,
|
||||
CharacterIntent,
|
||||
CharacterVoice,
|
||||
EngineConfig,
|
||||
Session,
|
||||
@@ -55,6 +56,7 @@ async function runDesignLLM(
|
||||
config: EngineConfig,
|
||||
session: Session,
|
||||
charName: string,
|
||||
intent?: CharacterIntent,
|
||||
): Promise<CharacterDesignOutput> {
|
||||
const raw = await chat(
|
||||
config.text,
|
||||
@@ -62,12 +64,20 @@ async function runDesignLLM(
|
||||
{ role: "system", content: buildCharacterDesignerSystem({ stepfun: stepfunEnabled(config) }) },
|
||||
{
|
||||
role: "user",
|
||||
content: buildCharacterDesignerUserMessage(charName, session),
|
||||
content: buildCharacterDesignerUserMessage(charName, session, intent),
|
||||
},
|
||||
],
|
||||
{ temperature: 0.7, tag: "character-designer" },
|
||||
);
|
||||
return parseJsonLoose<CharacterDesignOutput>(raw);
|
||||
// parseJsonLoose can throw on irreparable JSON; degrade to an empty card so
|
||||
// designCharacterCard's fallbacks (name-inference voice, no portrait) kick in.
|
||||
try {
|
||||
return parseJsonLoose<CharacterDesignOutput>(raw);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[characterDesigner] design JSON parse failed for ${charName}: ${msg}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the server's TTS config points at StepFun (so the CharacterDesigner
|
||||
@@ -155,9 +165,10 @@ export async function designCharacterCard(
|
||||
config: EngineConfig,
|
||||
session: Session,
|
||||
charName: string,
|
||||
intent?: CharacterIntent,
|
||||
): Promise<CharacterCard> {
|
||||
const tDesign = Date.now();
|
||||
const design = await runDesignLLM(config, session, charName);
|
||||
const design = await runDesignLLM(config, session, charName, intent);
|
||||
tlog(`[charDesigner ${charName}] design LLM`, tDesign);
|
||||
|
||||
// Drop invalid catalog picks before they reach provision/synth. A hallucinated
|
||||
|
||||
+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