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
+290
View File
@@ -0,0 +1,290 @@
import type { Session, Character } from "@infiplot/types";
import {
renderStoryStateSpine,
renderStoryStateDynamic,
renderHistoryEntry,
} from "../prompts";
// ──────────────────────────────────────────────────────────────────────
// ContextProvider — data-driven segment registry.
//
// Replaces the monolithic `buildWriterContextParts` (prompts.ts:425)
// with a registered list of segments, each rendered independently.
//
// Invariants:
// - **SENTINEL append-only**: character-cards / sceneKeys / archived-
// history use a fixed header + "entries follow" sentinel line. Adding
// a character only APPENDS bytes; earlier bytes never shift. This is
// crucial for prompt prefix caching.
// - **stable / dynamic split**: stable segments form the cached prefix;
// dynamic segments are the suffix that changes every call. Mixing them
// would destroy cache hit rate.
// - **try/catch isolation**: a failing segment is skipped, not fatal.
// ──────────────────────────────────────────────────────────────────────
export type ContextSegment = {
id: string;
zone: "stable" | "dynamic";
order: number;
render: (session: Session) => string[];
};
// ── Stable segments ─────────────────────────────────────────────────
const worldAndStyle: ContextSegment = {
id: "world-style",
zone: "stable",
order: 100,
render: (session) => {
const parts: string[] = [];
parts.push(`世界观:${session.worldSetting}`);
parts.push(`画风:${session.styleGuide}`);
if (session.playerName) {
parts.push(
`玩家名字:${session.playerName}(NPC 对话时用此名字称呼玩家;speaker 字段仍固定为 "你" 不变)`,
);
}
return parts;
},
};
const storySpine: ContextSegment = {
id: "story-spine",
zone: "stable",
order: 200,
render: (session) => [renderStoryStateSpine(session.storyState)],
};
function renderCharacterCard(c: Character): string[] {
const hasPersona =
c.persona || c.speakingStyle || c.sampleDialogue?.length || c.relationshipToPlayer;
if (!hasPersona) return [`- ${c.name}`];
const lines: string[] = [`- ${c.name}`];
if (c.persona) lines.push(` 设定:${c.persona}`);
if (c.personalityTraits?.length)
lines.push(` 性格:${c.personalityTraits.join("、")}`);
if (c.speakingStyle) lines.push(` 说话风格:${c.speakingStyle}`);
if (c.sampleDialogue?.length) {
lines.push(` 对白示例:`);
for (const d of c.sampleDialogue) lines.push(`${d}`);
}
if (c.relationshipToPlayer)
lines.push(` 与玩家关系:${c.relationshipToPlayer}`);
return lines;
}
const characterCards: ContextSegment = {
id: "character-cards",
zone: "stable",
order: 300,
render: (session) => {
// SENTINEL: header + marker are byte-identical even when the list is
// empty. Adding a character only APPENDS bytes — never shifts earlier.
const parts: string[] = [];
parts.push("已登记角色(speaker 必须用这些名字之一,或本场景新引入):");
parts.push("(以下每行一个已登记角色,开场前为空。)");
for (const c of session.characters) {
parts.push(...renderCharacterCard(c));
}
return parts;
},
};
function collectPriorSceneKeys(session: Session): string[] {
const seen = new Set<string>();
for (const entry of session.history) {
const k = entry.scene.sceneKey;
if (k) seen.add(k);
}
return Array.from(seen);
}
const priorSceneKeys: ContextSegment = {
id: "prior-sceneKeys",
zone: "stable",
order: 400,
render: (session) => {
// SENTINEL pattern — same rationale as character-cards.
const parts: string[] = [];
parts.push("已使用的 sceneKey(同一物理空间请沿用,不要新造):");
parts.push("(以下每行一个已用过的 sceneKey,开场前为空。)");
for (const k of collectPriorSceneKeys(session)) parts.push(`- ${k}`);
return parts;
},
};
const archivedHistory: ContextSegment = {
id: "archived-history",
zone: "stable",
order: 500,
render: (session) => {
// Only history[0..N-2] — the last entry is live (visitedBeatIds still
// growing, speculative prefetch sees different snapshots). Putting it
// here would corrupt prefix cache.
const archived = session.history.slice(0, -1);
const parts: string[] = [];
parts.push("场景历史(按时间顺序,已完结):");
parts.push("(以下每段一幕已完结的场景,开场前为空。)");
archived.forEach((entry, idx) => {
parts.push(renderHistoryEntry(entry, idx + 1));
});
return parts;
},
};
const loreConstant: ContextSegment = {
id: "lore-constant",
zone: "stable",
order: 600,
render: (session) => {
if (!session.worldBooks?.length) return [];
const constant = session.worldBooks
.flatMap((book) => book.entries.filter((e) => e.position === "constant"))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
.map((e) => e.content);
if (!constant.length) return [];
return [
"【世界设定 · 恒定知识】",
...constant.map((c) => `- ${c}`),
];
},
};
// ── Dynamic segments ────────────────────────────────────────────────
const storyDynamic: ContextSegment = {
id: "story-dynamic",
zone: "dynamic",
order: 100,
render: (session) => [renderStoryStateDynamic(session.storyState)],
};
const lastBeat: ContextSegment = {
id: "last-beat",
zone: "dynamic",
order: 200,
render: (session) => {
const last = session.history.at(-1);
if (!last) return [];
const lastBeatId = last.visitedBeatIds.at(-1) ?? last.scene.entryBeatId;
const beat = last.scene.beats.find((b) => b.id === lastBeatId);
if (!beat) return [];
const frag: string[] = [];
if (beat.narration) frag.push(`旁白:${beat.narration}`);
if (beat.line) frag.push(`${beat.speaker ?? "?"}${beat.line}`);
if (!frag.length) return [];
return [
`上一刻(玩家停留的最后一个画面,新场景从这里的情绪无缝承接):\n ${frag.join(" / ")}`,
];
},
};
const transitionHint: ContextSegment = {
id: "transition-hint",
zone: "dynamic",
order: 300,
render: (session) => {
if (session.history.length === 0) {
return [
"这是故事的开场。请按【故事档案】里的 nextHook 把第一幕的冷开场设计出来——开场即抓人,别花笔墨铺垫世界观。",
];
}
const last = session.history.at(-1);
const lastExit = last?.exit;
if (lastExit) {
if (lastExit.kind === "choice") {
return [
`承接「玩家在上一场选择了:${lastExit.label}」无缝续写下一个场景(转场命题:${lastExit.nextSceneSeed})。开场要让玩家感到这正是上一步的结果,并延续此刻的情绪。`,
];
}
return [
`承接「玩家自由动作:${lastExit.action}」无缝续写下一个场景,延续此刻的情绪与处境。`,
];
}
return ["无缝续写下一个场景,延续上一刻的情绪。"];
},
};
const loreTriggered: ContextSegment = {
id: "lore-triggered",
zone: "dynamic",
order: 400,
render: (session) => {
if (!session.worldBooks?.length) return [];
const lastBeatText = getLastBeatText(session);
const triggered = session.worldBooks
.flatMap((book) => book.entries.filter((e) => e.position === "triggered"))
.filter((e) => e.keys.some((key) => lastBeatText.includes(key)))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
.map((e) => e.content);
if (!triggered.length) return [];
return [
"【世界设定 · 情境激活】",
...triggered.map((t) => `- ${t}`),
];
},
};
/** Extract text from the last 3 beats for keyword matching (≤5000 chars). */
function getLastBeatText(session: Session): string {
if (!session.history.length) return "";
const lastEntry = session.history[session.history.length - 1];
if (!lastEntry) return "";
const scene = lastEntry.scene;
const beats = scene?.beats || [];
const lastN = beats.slice(-3);
const text = lastN
.map((b) => [b.narration, b.line].filter(Boolean).join(" "))
.join(" ");
return text.slice(0, 5000);
}
// ── Registry ────────────────────────────────────────────────────────
const defaultSegments: ContextSegment[] = [
worldAndStyle,
storySpine,
characterCards,
priorSceneKeys,
archivedHistory,
loreConstant,
storyDynamic,
lastBeat,
transitionHint,
loreTriggered,
];
export function buildWriterContext(
session: Session,
segments: ContextSegment[] = defaultSegments,
): { stableParts: string[]; dynamicParts: string[] } {
const stable = segments
.filter((s) => s.zone === "stable")
.sort((a, b) => a.order - b.order);
const dynamic = segments
.filter((s) => s.zone === "dynamic")
.sort((a, b) => a.order - b.order);
const stableParts: string[] = [];
for (const seg of stable) {
try {
stableParts.push(...seg.render(session));
stableParts.push("");
} catch (err) {
console.warn(`[ContextProvider] segment "${seg.id}" render failed, skipped:`, err);
}
}
const dynamicParts: string[] = [];
for (const seg of dynamic) {
try {
dynamicParts.push(...seg.render(session));
dynamicParts.push("");
} catch (err) {
console.warn(`[ContextProvider] segment "${seg.id}" render failed, skipped:`, err);
}
}
return { stableParts, dynamicParts };
}