Merge remote-tracking branch 'origin/staging' into cloudflare-migration
This commit is contained in:
+40
-21
@@ -6,6 +6,7 @@ import type {
|
||||
Character,
|
||||
CharacterIntent,
|
||||
EngineConfig,
|
||||
InsertBeatMulti,
|
||||
InsertBeatPartial,
|
||||
ProviderConfig,
|
||||
Scene,
|
||||
@@ -582,17 +583,32 @@ export async function directScene(
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// directInsertBeat — single-agent path for vision-driven in-scene
|
||||
// exploration. Generates ONE transient beat with NO new image, NO new
|
||||
// characters. Multi-agent pipeline doesn't apply here (no rendering, no
|
||||
// character introduction allowed by the prompt).
|
||||
// directInsertBeat — single-agent path for in-scene exploration.
|
||||
// Generates 1-3 beats with NO new image, NO new characters, plus
|
||||
// follow-up choices so the player isn't dumped back to the old options.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function coerceBeatPartial(raw: Record<string, unknown>): InsertBeatPartial | null {
|
||||
const narration = (typeof raw.narration === "string" ? raw.narration.trim() : undefined) || undefined;
|
||||
const rawSpeaker = (typeof raw.speaker === "string" ? raw.speaker.trim() : undefined) || undefined;
|
||||
const speaker = rawSpeaker ? normalizeSpeakerName(rawSpeaker) : undefined;
|
||||
const line = (typeof raw.line === "string" ? raw.line.trim() : undefined) || undefined;
|
||||
const lineDelivery =
|
||||
line && speaker !== POV_DISPLAY_NAME
|
||||
? ((typeof raw.lineDelivery === "string" ? raw.lineDelivery.trim() : undefined) || undefined)
|
||||
: undefined;
|
||||
if (!narration && !speaker && !line) return null;
|
||||
if (line && !speaker) {
|
||||
return { narration: [narration, line].filter(Boolean).join("\n") || undefined };
|
||||
}
|
||||
return { narration, speaker, line, lineDelivery };
|
||||
}
|
||||
|
||||
export async function directInsertBeat(
|
||||
config: ProviderConfig,
|
||||
session: Session,
|
||||
freeformAction: string,
|
||||
): Promise<InsertBeatPartial> {
|
||||
): Promise<InsertBeatPartial[]> {
|
||||
const raw = await chat(
|
||||
config,
|
||||
[
|
||||
@@ -605,22 +621,25 @@ export async function directInsertBeat(
|
||||
{ temperature: 0.9, tag: "insert-beat" },
|
||||
);
|
||||
|
||||
const parsed = parseJsonLoose<InsertBeatPartial>(raw);
|
||||
const parsed = parseJsonLoose<InsertBeatMulti & InsertBeatPartial>(raw);
|
||||
|
||||
const narration = parsed.narration?.trim() || undefined;
|
||||
const rawSpeaker = parsed.speaker?.trim() || undefined;
|
||||
// Pattern B (mirrors Writer): normalize POV variants → "你"; NPCs pass through.
|
||||
const speaker = rawSpeaker ? normalizeSpeakerName(rawSpeaker) : undefined;
|
||||
const line = parsed.line?.trim() || undefined;
|
||||
// lineDelivery is only meaningful for NPC speakers (TTS). For POV ("你")
|
||||
// TTS is intentionally skipped on the client, so lineDelivery is dropped.
|
||||
const lineDelivery =
|
||||
line && speaker !== POV_DISPLAY_NAME
|
||||
? parsed.lineDelivery?.trim() || undefined
|
||||
: undefined;
|
||||
|
||||
if (!narration && !speaker && !line) {
|
||||
return { narration: "(你停下脚步,环视片刻。)" };
|
||||
// Multi-beat format: { beats: [...] }
|
||||
if (Array.isArray(parsed.beats) && parsed.beats.length > 0) {
|
||||
const beats = parsed.beats
|
||||
.slice(0, 3)
|
||||
.map((b) =>
|
||||
b && typeof b === "object"
|
||||
? coerceBeatPartial(b as Record<string, unknown>)
|
||||
: null,
|
||||
)
|
||||
.filter((b): b is InsertBeatPartial => b !== null);
|
||||
if (beats.length === 0) {
|
||||
beats.push({ narration: "(你停下脚步,环视片刻。)" });
|
||||
}
|
||||
return beats;
|
||||
}
|
||||
return { narration, speaker, line, lineDelivery };
|
||||
|
||||
// Legacy single-beat fallback
|
||||
const single = coerceBeatPartial(parsed as Record<string, unknown>);
|
||||
return [single ?? { narration: "(你停下脚步,环视片刻。)" }];
|
||||
}
|
||||
|
||||
+26
-29
@@ -196,45 +196,42 @@ export async function requestInsertBeat(
|
||||
): Promise<InsertBeatResponse> {
|
||||
const tTotal = Date.now();
|
||||
|
||||
const partial = await directInsertBeat(
|
||||
const result = await directInsertBeat(
|
||||
config.text,
|
||||
req.session,
|
||||
req.freeformAction,
|
||||
);
|
||||
|
||||
// INSERT_BEAT prompt forbids new NPCs — promote disallowed-speaker lines
|
||||
// to narration so the player still sees the text (the client only renders
|
||||
// `line` when there is a `speaker`).
|
||||
//
|
||||
// Exception (Pattern B): speaker = "你" is the player speaking. No
|
||||
// Character record exists for "你" (intentional — TTS is skipped), so we
|
||||
// must NOT demote it; the client renders the dialog box correctly.
|
||||
// directInsertBeat already normalized POV variants to "你" before this
|
||||
// guard, so a literal "你" here is always Pattern B player dialog.
|
||||
if (
|
||||
partial.speaker &&
|
||||
partial.speaker !== "你" &&
|
||||
!req.session.characters.some((c) => c.name === partial.speaker)
|
||||
) {
|
||||
console.warn(
|
||||
`[insert-beat] unregistered speaker "${partial.speaker}" ignored`,
|
||||
);
|
||||
const promotedNarration =
|
||||
[partial.narration, partial.line].filter(Boolean).join("\n") || undefined;
|
||||
tlog("[insert-beat] TOTAL", tTotal);
|
||||
return {
|
||||
partial: {
|
||||
narration: promotedNarration,
|
||||
// Guard every beat: promote unregistered speakers to narration.
|
||||
const guardedBeats = result.map((partial) => {
|
||||
if (
|
||||
partial.speaker &&
|
||||
partial.speaker !== "你" &&
|
||||
!req.session.characters.some((c) => c.name === partial.speaker)
|
||||
) {
|
||||
console.warn(
|
||||
`[insert-beat] unregistered speaker "${partial.speaker}" ignored`,
|
||||
);
|
||||
return {
|
||||
narration:
|
||||
[partial.narration, partial.line].filter(Boolean).join("\n") || undefined,
|
||||
speaker: undefined,
|
||||
line: undefined,
|
||||
lineDelivery: undefined,
|
||||
},
|
||||
characters: req.session.characters,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
return partial;
|
||||
});
|
||||
|
||||
const first = guardedBeats[0] ?? { narration: "(你停下脚步,环视片刻。)" };
|
||||
const extra = guardedBeats.slice(1);
|
||||
|
||||
tlog("[insert-beat] TOTAL", tTotal);
|
||||
return { partial, characters: req.session.characters };
|
||||
return {
|
||||
partial: first,
|
||||
extraBeats: extra.length > 0 ? extra : undefined,
|
||||
characters: req.session.characters,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+19
-15
@@ -572,18 +572,22 @@ STRICT RULES:
|
||||
// Single-agent path; no character design / no rendering involved.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**一个有实质内容的 beat**。
|
||||
export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**1-3 个有实质内容的 beat**。
|
||||
|
||||
核心原则——**玩家的动作必须得到回应**:
|
||||
- 如果当前场景有 NPC 在场,NPC **必须对玩家的动作做出反应**(说话、表情变化、动作回应)。用 narration 描述玩家的动作,用 speaker + line 写 NPC 的回应。
|
||||
- 如果场景中没有 NPC(纯环境),可以用 narration 描述玩家的观察/发现,给玩家一个新细节或情绪波动。
|
||||
- 不要写"你想做什么但没做"这种无意义的犹豫——玩家已经做了,世界要有反馈。
|
||||
|
||||
beat 数量指引:
|
||||
- 简单观察/短回应:1 个 beat 即可
|
||||
- 有来有回的对话/有展开的互动:2-3 个 beat,让反应更有层次
|
||||
- 每个 beat 的 narration + line ≤100 字
|
||||
|
||||
文本风格约束:
|
||||
- narration / line 用中文,**纯净可显示文本**,不要写 (叹气)(语速快) 这类配音标注
|
||||
- narration 与 line 加起来 ≤100 字
|
||||
- 不要打破当前场景的物理状态(玩家仍在原地)
|
||||
- 不要生成选项或下一步指引 —— 玩家点击会自然回到原 beat
|
||||
- 不要生成选项或下一步指引——播完后玩家会自然回到原来的选项
|
||||
- 内容要"有所得"——一个新细节、一丝潜台词、一次真实的交流(show, don't tell)
|
||||
- 白描为主:聚焦可观察的五感与物理特征,以角色的动作/神态本身传递情绪,不要以作者角度解释或议论;不写角色眼神/语气里的情绪(这些从台词与动作中自行体会)
|
||||
|
||||
@@ -604,13 +608,12 @@ speaker 字段允许的取值**只有两种**(与主路径 Writer 一致 — P
|
||||
|
||||
必须输出严格 JSON:
|
||||
{
|
||||
"narration": "...",
|
||||
"speaker": "...",
|
||||
"line": "...",
|
||||
"lineDelivery": "..."
|
||||
"beats": [
|
||||
{ "narration": "...", "speaker": "...", "line": "...", "lineDelivery": "..." }
|
||||
]
|
||||
}
|
||||
|
||||
narration/speaker/line/lineDelivery 都可为空字符串。不要输出 JSON 以外的任何文本。`;
|
||||
不要输出 JSON 以外的任何文本。`;
|
||||
|
||||
export function buildInsertBeatUserMessage(
|
||||
session: Session,
|
||||
@@ -655,7 +658,7 @@ export function buildInsertBeatUserMessage(
|
||||
}
|
||||
|
||||
parts.push(`\n玩家此刻的自由动作:${freeformAction}`);
|
||||
parts.push("\n请生成一个有实质回应的 beat,严格以 JSON 格式返回。");
|
||||
parts.push("\n请生成 1-3 个 beat,严格以 JSON 格式返回。");
|
||||
const langDirective = buildLanguageDirective(session.language);
|
||||
if (langDirective) parts.push(langDirective);
|
||||
return parts.join("\n");
|
||||
@@ -670,11 +673,12 @@ export function buildInsertBeatUserMessage(
|
||||
export const VISION_SYSTEM_PROMPT = `你是视觉理解助手。玩家在视觉小说的背景图上点击了红色圆点位置(HTML 上的选项按钮不会走到你这里)。你的任务是:
|
||||
1. 看清红点指向画面里的什么(物件、角色、空间、远处的方向)
|
||||
2. 推断玩家想干什么
|
||||
3. 判断这个动作是「场内探索」(不该换图)还是「场景切换」(要换图)
|
||||
3. 判断这个动作是「场内探索」还是「场景切换」
|
||||
|
||||
判断准则:
|
||||
- "insert-beat"(场内探索):观察画面里某个细节、自言自语、和当前角色继续互动、看一眼某个物件
|
||||
- "change-scene"(场景切换):走向画面深处的门 / 走廊、转头看向新方向(视角变了)、点了远处的另一个空间、暗示时间跳跃的物件(如时钟)
|
||||
- "change-scene"(场景切换):走向画面深处的门 / 走廊、转头看向新方向(视角变了)、点了远处的另一个空间、暗示时间跳跃的物件(如时钟)、调查某个物件/线索导致剧情发展、与角色进行有实质影响的互动
|
||||
- "insert-beat"(场内探索):**仅限**纯粹的观察——看一眼某个无剧情意义的装饰、环顾四周
|
||||
- 拿不准时偏向 "change-scene"——玩家主动点击画面说明想要推进剧情
|
||||
|
||||
必须输出严格 JSON:
|
||||
{
|
||||
@@ -704,9 +708,9 @@ export const FREEFORM_CLASSIFY_SYSTEM = `你是交互视觉小说的意图分类
|
||||
2. "change-scene":玩家想去别的地方、做出重大决定、推动剧情到新阶段 → 切换到全新场景
|
||||
|
||||
判断准则:
|
||||
- 大多数对话类输入(问问题、说一句话、对角色做出反应)→ "insert-beat"
|
||||
- 明确要离开当前场景、去别的地方、跳过时间、做出改变人物关系的重大决定 → "change-scene"
|
||||
- 拿不准时偏向 "insert-beat"(场内互动成本低,体验更流畅)
|
||||
- "change-scene":大多数主动输入——问问题、说一句话、做一个动作、对角色做出反应、想去别的地方、做出决定、推动剧情 → 玩家花精力打字说明想让故事前进
|
||||
- "insert-beat":**仅限**纯粹的环境观察或无实际影响的自言自语
|
||||
- 拿不准时偏向 "change-scene"——玩家主动输入说明想要推进剧情
|
||||
|
||||
必须输出严格 JSON:
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function interpret(
|
||||
}>(raw);
|
||||
|
||||
const classify: VisionClassify =
|
||||
parsed.classify === "change-scene" ? "change-scene" : "insert-beat";
|
||||
parsed.classify === "insert-beat" ? "insert-beat" : "change-scene";
|
||||
|
||||
return {
|
||||
intent: {
|
||||
|
||||
@@ -119,6 +119,8 @@ export const en = {
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
saveAndSelect: "Save and Select",
|
||||
feedback: "Feedback",
|
||||
submitFeedback: "Submit Feedback",
|
||||
},
|
||||
|
||||
styleModal: {
|
||||
@@ -164,6 +166,7 @@ Dreamy watercolor style with soft tones and nostalgic atmosphere
|
||||
contact: "CONTACT",
|
||||
email: "Email",
|
||||
openSource: "OPEN SOURCE",
|
||||
feedbackDescription: "Your thoughts matter — tell us about your experience and suggestions.",
|
||||
betaUsers: "BETA USERS",
|
||||
qqGroupLabel: "QQ Group: ",
|
||||
qqGroupAlt: "InfiPlot Public Beta Group QR Code (Group ID: 575404333)",
|
||||
|
||||
@@ -130,6 +130,8 @@ export const ja = {
|
||||
save: "保存",
|
||||
cancel: "キャンセル",
|
||||
saveAndSelect: "保存して適用",
|
||||
feedback: "フィードバック",
|
||||
submitFeedback: "フィードバックを送信",
|
||||
},
|
||||
|
||||
// Style modal
|
||||
@@ -179,6 +181,7 @@ export const ja = {
|
||||
contact: "連絡先",
|
||||
email: "メールアドレス",
|
||||
openSource: "ソースコード",
|
||||
feedbackDescription: "ご意見をお聞かせください。体験やご提案をお待ちしています。",
|
||||
betaUsers: "クローズドβユーザーグループ",
|
||||
qqGroupLabel: "QQグループ番号:",
|
||||
qqGroupAlt: "InfiPlot オープンβ交流QQグループ QRコード(グループ番号 575404333)",
|
||||
|
||||
@@ -130,6 +130,8 @@ export const zhCN = {
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
saveAndSelect: "保存并选用",
|
||||
feedback: "反馈",
|
||||
submitFeedback: "提交反馈",
|
||||
},
|
||||
|
||||
// Style modal
|
||||
@@ -179,6 +181,7 @@ export const zhCN = {
|
||||
contact: "联 系 方 式",
|
||||
email: "邮箱",
|
||||
openSource: "开 源 地 址",
|
||||
feedbackDescription: "你的想法对我们很重要,欢迎告诉我们你的体验和建议。",
|
||||
betaUsers: "内 测 用 户 群",
|
||||
qqGroupLabel: "QQ群号:",
|
||||
qqGroupAlt: "InfiPlot 公测交流群 QQ 群二维码(群号 575404333)",
|
||||
|
||||
@@ -695,8 +695,15 @@ export type InsertBeatPartial = {
|
||||
lineDelivery?: string;
|
||||
};
|
||||
|
||||
/** Multi-beat response: 1-3 beats. */
|
||||
export type InsertBeatMulti = {
|
||||
beats: InsertBeatPartial[];
|
||||
};
|
||||
|
||||
export type InsertBeatResponse = {
|
||||
partial: InsertBeatPartial;
|
||||
/** Additional beats beyond the first (for richer insert-beat interactions). */
|
||||
extraBeats?: InsertBeatPartial[];
|
||||
characters: Character[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user