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
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* 交互剧情演练 — 模拟真实玩家游玩,记录长文本剧情到 Markdown。
*
* 流程:start → 沿 beat 图推进 → 遇 choice 选分支 → 中途 insert-beat 自由交互
* → change-scene 换场 → 循环。完整记录旁白/内心独白/对白 + 分支 + 自由交互。
*
* 用法:node scripts/playthrough-demo.mjs
*/
import { writeFile } from "node:fs/promises";
const BASE = "https://infiplot.y-9e6.workers.dev";
const OUT = "G:\\infiplot\\.spec-workflow\\specs\\narrative-depth-redesign\\playthrough-demos-v2.md";
// 三个不同题材的开局 + 每局的「自由交互动作」脚本(模拟玩家点击/输入)
const PLAYTHROUGHS = [
{
id: "A",
title: "校园暗恋·雨天的天台",
worldSetting:
"现代日本高中。梅雨季的午后,你(第二人称男生)暗恋着同班的吉他社少女,今天偶然发现她独自在天台避雨弹唱。围绕青涩暗恋与少女不为人知的心事展开。",
styleGuide: "anime illustration, soft rainy atmosphere, warm muted tones",
// 模拟玩家在场景内的自由交互(insert-beat
freeformActions: [
"悄悄走近,假装只是来收衣服,偷看她的侧脸",
"鼓起勇气问她:这首歌是写给谁的?",
],
},
{
id: "B",
title: "悬疑·深夜便利店",
worldSetting:
"现代都市。凌晨三点,你(第二人称)是值夜班的便利店店员。一个浑身湿透、神色慌张的女人冲进店里,反锁了门,说有人在追她。窗外的雨夜里似乎真有黑影徘徊。",
styleGuide: "noir, neon-lit convenience store at night, rain on windows",
freeformActions: [
"不动声色地按下柜台下的报警按钮,同时观察她的反应",
"递给她一杯热咖啡,低声问:到底发生了什么?",
],
},
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 把一个 beat 渲染成 Markdown 片段
function renderBeat(beat, playerName) {
const lines = [];
// narration 先行
if (beat.narration) lines.push(`*${beat.narration}*`);
// speaker + line
if (beat.speaker && beat.line) {
const who = beat.speaker === "你" ? (playerName || "你") : beat.speaker;
const delivery = beat.lineDelivery ? ` _(${beat.lineDelivery})_` : "";
if (beat.speaker === "你") {
lines.push(`**${who}(心声)**${beat.line}`);
} else {
lines.push(`**${who}**:「${beat.line}${delivery}`);
}
} else if (beat.line) {
lines.push(beat.line);
}
return lines.join("\n\n");
}
// 沿 beat 图走一条线性路径,遇到第一个 choice 就返回(带可选项)
// 返回 { rendered: string[], exitChoice, beats }
function walkScene(scene, playerName) {
const byId = new Map(scene.beats.map((b) => [b.id, b]));
const rendered = [];
const visited = new Set();
let cur = byId.get(scene.entryBeatId) ?? scene.beats[0];
let exitChoice = null;
let chosenLabel = null;
while (cur && !visited.has(cur.id)) {
visited.add(cur.id);
const frag = renderBeat(cur, playerName);
if (frag) rendered.push(frag);
if (cur.next.type === "continue") {
cur = byId.get(cur.next.nextBeatId);
continue;
}
// choice 节点:列出所有选项,选一个
const choices = cur.next.choices;
const choiceLines = choices.map(
(c, i) =>
` ${i === 0 ? "👉" : " "} [${c.effect.kind === "change-scene" ? "换场" : "场内"}] ${c.label}`,
);
rendered.push(`\n**【可选分支】**\n${choiceLines.join("\n")}`);
// 策略:优先选第一个 change-scene 推进剧情;没有则选第一个 advance-beat
const sceneChange = choices.find((c) => c.effect.kind === "change-scene");
const picked = sceneChange ?? choices[0];
chosenLabel = picked.label;
rendered.push(`\n> 🎮 玩家选择:**${picked.label}**`);
if (picked.effect.kind === "change-scene") {
exitChoice = picked;
break;
} else {
// advance-beat:跳到目标 beat 继续走
cur = byId.get(picked.effect.targetBeatId);
}
}
return { rendered, exitChoice, chosenLabel };
}
async function postJSON(path, body) {
const r = await fetch(BASE + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
const t = await r.text().catch(() => "");
throw new Error(`${path} ${r.status}: ${t.slice(0, 200)}`);
}
return r.json();
}
async function runPlaythrough(pt) {
console.log(`\n${"═".repeat(56)}\n🎬 ${pt.id}: ${pt.title}\n${"═".repeat(56)}`);
const md = [`## 剧本 ${pt.id}${pt.title}\n`, `> 设定:${pt.worldSetting}\n`];
// ── 开局 ──
console.log(" [start] 开局...");
const startData = await postJSON("/api/start", {
worldSetting: pt.worldSetting,
styleGuide: pt.styleGuide,
orientation: "landscape",
});
let session = {
id: startData.sessionId,
createdAt: Date.now(),
worldSetting: pt.worldSetting,
styleGuide: pt.styleGuide,
orientation: "landscape",
storyState: startData.storyState,
characters: startData.characters,
history: [],
};
// bible 摘要
const sb = startData.storyState;
if (sb) {
md.push(`### 故事档案(Architect\n`);
md.push(`- **logline**${sb.logline ?? ""}`);
md.push(`- **题材**${sb.genreTags ?? ""}`);
md.push(`- **主角**${sb.protagonist ?? ""}`);
if (sb.castNotes) md.push(`- **配角**\n ${String(sb.castNotes).replace(/\n/g, "\n ")}`);
md.push("");
}
let scene = startData.scene;
const MAX_SCENES = 3;
for (let s = 0; s < MAX_SCENES; s++) {
console.log(` [场景${s + 1}] ${scene.beats.length} beats, key=${scene.sceneKey}`);
md.push(`### 第 ${s + 1}${scene.sceneKey ? `${scene.sceneKey}` : ""}\n`);
const { rendered, exitChoice } = walkScene(scene, undefined);
md.push(rendered.join("\n\n"));
// 记录本幕入 history(供后续 scene/insert-beat 携带)
session.history.push({
scene,
visitedBeatIds: scene.beats.map((b) => b.id),
exit: exitChoice
? { kind: "choice", choiceId: exitChoice.id, label: exitChoice.label, nextSceneSeed: exitChoice.effect.nextSceneSeed }
: { kind: "choice", choiceId: "auto", label: "继续", nextSceneSeed: "故事继续推进" },
});
session.storyState = startData.storyState; // 会被 scene 响应更新
// ── 自由交互(insert-beat):每幕插一次,模拟玩家点击/输入 ──
const action = pt.freeformActions[s];
if (action) {
console.log(` [insert-beat] "${action.slice(0, 20)}..."`);
md.push(`\n> 🖱️ 玩家自由行动:**${action}**\n`);
try {
await sleep(1500);
const ib = await postJSON("/api/insert-beat", { session, freeformAction: action });
const p = ib.partial;
const frag = renderBeat(
{ narration: p.narration, speaker: p.speaker, line: p.line, lineDelivery: p.lineDelivery },
undefined,
);
md.push(frag || "*(无回应)*");
if (ib.characters) session.characters = ib.characters;
} catch (e) {
md.push(`*insert-beat 失败:${e.message}*`);
}
}
md.push("");
// ── 换场到下一幕 ──
if (s < MAX_SCENES - 1) {
console.log(" [scene] 换场生成下一幕...");
await sleep(2000);
try {
const sceneData = await postJSON("/api/scene", { session });
scene = sceneData.scene;
session.storyState = sceneData.storyState;
session.characters = sceneData.characters;
} catch (e) {
md.push(`*(换场失败:${e.message}*\n`);
break;
}
}
}
md.push(`\n---\n`);
return md.join("\n");
}
async function main() {
console.log("🎮 交互剧情演练");
console.log(`📍 ${BASE}\n`);
const doc = [
`# 交互剧情演练样本\n`,
`> 生成时间:${new Date().toISOString()}`,
`> 环境:${BASE}`,
`> 模型:gemini-3.1-flash-lite-preview`,
`>`,
`> 说明:模拟真实玩家游玩——开局 → 沿剧情推进 → 遇分支选择 → 中途自由交互(insert-beat)→ 换场。`,
`> *斜体*=旁白/环境描写,**角色(心声)**=玩家内心独白,**角色**「」=NPC对白,👉=玩家所选分支,🖱️=玩家自由行动。\n`,
`---\n`,
];
for (const pt of PLAYTHROUGHS) {
try {
doc.push(await runPlaythrough(pt));
} catch (e) {
console.error(`${pt.id} 失败: ${e.message}`);
doc.push(`## 剧本 ${pt.id}${pt.title}\n\n*(生成失败:${e.message}*\n\n---\n`);
}
await sleep(2000);
}
await writeFile(OUT, doc.join("\n"), "utf-8");
console.log(`\n✅ 剧情已记录:${OUT}`);
}
main().catch((e) => {
console.error("💥", e);
process.exit(1);
});