Files
infiplot-web/lib/engine/context/index.ts
T
Zonghao Yuan 0e4c2ebef4 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>
2026-06-18 18:05:38 +08:00

291 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}