feat(play): always generate new scene for freeform text input + enhance insert-beat

User feedback: custom interactions rarely produce new story content because
the classifier heavily biased toward insert-beat (single reaction, no scene
change). Three changes to fix this:

1. Freeform text input now always triggers a full scene generation (skips
   the classify step entirely) — users who type expect the story to advance.

2. Vision (background click) classifier de-biased: prompt now favors
   change-scene when uncertain, and the code fallback flipped from
   insert-beat to change-scene. insert-beat narrowed to pure observation.

3. Insert-beat enhanced: generates 1-3 beats (was 1) with follow-up
   choices (was: loop back to original beat). Even when vision classifies
   as insert-beat, the player gets richer content and new options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-24 18:36:35 +08:00
parent ae4d9f8873
commit 6f8125570a
6 changed files with 160 additions and 150 deletions
+27 -29
View File
@@ -196,45 +196,43 @@ 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.beats.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,
followUpChoices: result.choices,
characters: req.session.characters,
};
}
// ──────────────────────────────────────────────────────────────────────