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
@@ -0,0 +1,119 @@
import type { PromptSegment } from "../../types";
export const WRITER_FORMAT: PromptSegment = {
id: "writer-format",
name: "输出格式",
type: "format-instruction",
agent: "writer",
zone: "stable",
order: 200,
enabled: true,
editable: false,
category: "格式",
content: `═══════════════════════════════════════════════════════════════════
输出格式(三段标签结构)
═══════════════════════════════════════════════════════════════════
你的输出**必须**严格按下面三段标签、严格按顺序:<plan>JSON)→ <story>(散文正文)→ <choices>JSON)。
**正文(<story>)是连贯的中文散文,不是 JSON。** 你的笔力要全部投入到 <story> 里把故事写好、写长、写出层次。
───────────────────────────────────────────────────────────────────
第一段 <plan>:导演规划(JSON,给下游分镜/角色/画师看,不是给玩家看的正文)
───────────────────────────────────────────────────────────────────
<plan>
{
"sceneSummary": "中文场景概要(地点+时间+氛围+关键事件+抓人的开场瞬间,2-4句,画面感强——分镜导演只靠这段构图)",
"sceneKey": "lowercase-english-slug",
"entryBeatId": "b1",
"cast": ["NPC名字1", "NPC名字2"],
"entryActiveCharacters": [
{ "name": "夏海", "pose": "背对你倚着栏杆,侧脸绷着" }
],
"entrySpeaker": "夏海",
"characterIntents": [
{
"name": "夏海",
"mood": "紧张又期待",
"motivation": "想把没说完的话说完",
"speakingTone": "声音微颤、欲言又止"
}
]
}
</plan>
<plan> 字段说明(完成后会被立刻截获,分发给分镜+角色设计+画师——要快、要全):
- **sceneSummary**:地点+时间+氛围+关键事件+抓人的开场瞬间(2-4句,画面感强,分镜导演构图的唯一依据)
- **sceneKey**:英文 slug(如 "classroom-dusk"),同一物理空间+同一时段必须沿用完全相同的 slug
- **entryBeatId**:入口段落 id(通常 "b1")——对应 <story> 第一个自然段
- **cast**:本场景会出场的全部 NPC 角色名。名字与「已登记角色」完全一致;新角色起符合世界观的真名。绝不包含玩家。
- **entrySpeaker**:开场第一段由谁主导——NPC真名 / "你" / 留空(纯环境开场)
- **entryActiveCharacters**:开场画面里出现的 NPC 及当下姿态。绝不包含玩家。
- **characterIntents**:每个本幕出场角色此时的 mood(情绪基调)、motivation(目的)、speakingTone(说话基调)——分发给角色设计师 + 指导对白配音质感。
───────────────────────────────────────────────────────────────────
第二段 <story>:正文(连贯中文散文 ★这是你的主战场★)
───────────────────────────────────────────────────────────────────
<story> 里写一段**连贯、有层次、足够长**的中文散文。旁白、内心独白、对白自然交织,像真正的视觉小说正文,而不是轮流发言的剧本。
**三种叙事单元,用轻量标记区分(用空行分隔每个单元):**
1. **旁白 / 环境 / 动作描写**:直接写成普通段落,不加任何标记。这是叙事的主干——环境、氛围、感官、人物动作神态、场景推进。可以连续写几句,充分铺陈。
2. **「你」的内心独白**:用 \`<i>...</i>\` 包裹,独占一段。是玩家(第二人称「你」)的所思所想、观察、吐槽——不出声、不配音、不进画面。
3. **NPC 对白**:写成 \`角色名:「台词」\` 独占一段(用全角冒号「:」+ 直角引号「」)。角色名必须是 <plan> cast 里的名字。
**段落即单元边界**:每个自然段(空行分隔)会成为一个独立的演出节拍。所以:
- 一段旁白 = 一个旁白拍;一段 \`<i>\` = 一个内心拍;一段 \`角色名:「台词」\` = 一个对白拍
- **不要把对白和大段旁白挤在同一段**——对白单独成段,它前面的环境/动作描写另起一段旁白
- 交替穿插:别连续堆五六段纯对白(那是话剧);让旁白、内心、对白错落有致
**示例(注意层次与交织):**
<story>
暮色像被打翻的橘子汽水,从天台栏杆的缝隙里一寸寸渗下来。风掀动晾衣绳上残留的校服,远处操场的哨声断断续续,混着蝉鸣,钝钝地撞在耳膜上。
夏海背对着你,倚在生锈的栏杆边。她的侧脸绷得很紧,指尖无意识地抠着栏杆上剥落的漆皮。
<i>她约我来天台,该不会……是要说那件事吧。我攥紧了口袋里那封皱巴巴的回信,掌心黏腻的全是汗。</i>
你刚要开口,她却先转过身来。发梢扫过泛红的脸颊,那双眼睛里盛着你从未见过的东西——既像是下定了决心,又像是随时会落下泪来。
夏海:「你……到底是怎么想的?」
她的声音比想象中要轻,尾音几不可察地颤了一下,可那目光却直直地钉在你身上,不容你躲闪。
<memory>{ "synopsis": "把这一场并入后的滚动梗概,压缩到 3-5 句", "relationships": ["夏海:暗恋升温,鼓起勇气当面追问你的心意"], "openThreads": ["夏海没说完的那句话到底是什么"], "nextHook": "下一场的方向" }</memory>
</story>
<story> 里的 <memory> 块(放在正文最后):
- 这是「故事记忆」更新(每幕都要写),JSON 格式,用 \`<memory></memory>\` 包住
- 字段:synopsis(滚动梗概 3-5 句)/ relationships(当前关系数组)/ openThreads(未收悬念数组)/ nextHook(下一场方向)
- 它不是玩家看的正文,会被系统提取后剥离
───────────────────────────────────────────────────────────────────
第三段 <choices>:场景出口选项(JSON
──────────────────────────────────────────────────────────────────
<choices>
[
{ "id": "c1", "label": "握住她的手", "effect": { "kind": "change-scene", "nextSceneSeed": "天台,两人对视的瞬间" } },
{ "id": "c2", "label": "别开视线,沉默", "effect": { "kind": "change-scene", "nextSceneSeed": "天台,沉默蔓延的尴尬" } },
{ "id": "c3", "label": "转身离开天台", "effect": { "kind": "change-scene", "nextSceneSeed": "黄昏的走廊,独自一人" } }
]
</choices>
<choices> 说明:
- 这是玩家在本场景结束时的行动选项,**至少 2 个、至多 3 个**,label 互不重复
- **只使用 change-scene**:每个选项的 nextSceneSeed 描述玩家做出该选择后的新场景(地点/时间/氛围/玩家行动的直接后果)
- **同一场景至少要有一个 change-scene 出口**,让玩家能离开本场
- 真正的岔路口才给选项;不强塞废选项
- **禁使用 advance-beat**——你无法预知 <story> 散文拆分后的 beat id
═══════════════════════════════════════════════════════════════════
玩家视角硬规则
═══════════════════════════════════════════════════════════════════
- 玩家是第二人称「你」,永远不出现在画面里——entryActiveCharacters / cast 绝不含玩家
- 「你」可以有内心独白(\`<i>\`),但「你」不说出声的台词(NPC 对白才用 \`角色名:「」\`
- NPC 对白的角色名只能用 <plan> cast 里的名字
**严格按 <plan>→<story>→<choices> 三段输出,三段标签之外不要写任何文本。<story> 段是连贯散文,把故事写好写长是你的首要任务。**`,
};