From ae3dd17e6b4b5b7c0bc2769620d75ca04339c78d Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sun, 7 Jun 2026 12:03:13 +0800 Subject: [PATCH] feat(web): add player name, freeform input, and unified settings modal - Player name: stored in localStorage, injected into Architect/Writer/InsertBeat prompts so NPCs address the player by name, displayed in dialogue UI - Freeform input: compact button at choice nodes expands to text input, LLM classifier routes to insert-beat (interactive NPC response) or change-scene - SettingsModal: unified panel merging player name, voice toggle (with collapsible TTS key section), replacing the old TtsKeyModal - Insert-beat upgrade: prompt now requires NPC reaction when characters are present, shared by both freeform and Vision paths - IME guard: isComposing check on freeform input to prevent CJK mid-composition submission Co-Authored-By: Claude Opus 4.6 --- app/api/classify-freeform/route.ts | 31 +++ app/page.tsx | 97 +++---- app/play/page.tsx | 141 +++++++++- components/DialogueHistoryModal.tsx | 6 +- components/PlayCanvas.tsx | 143 +++++++++- components/SettingsModal.tsx | 395 ++++++++++++++++++++++++++++ lib/analytics.ts | 1 + lib/engine/index.ts | 1 + lib/engine/orchestrator.ts | 45 ++++ lib/engine/prompts.ts | 91 ++++++- lib/types/index.ts | 23 ++ 11 files changed, 897 insertions(+), 77 deletions(-) create mode 100644 app/api/classify-freeform/route.ts create mode 100644 components/SettingsModal.tsx diff --git a/app/api/classify-freeform/route.ts b/app/api/classify-freeform/route.ts new file mode 100644 index 0000000..d2c10a2 --- /dev/null +++ b/app/api/classify-freeform/route.ts @@ -0,0 +1,31 @@ +import { classifyFreeform } from "@infiplot/engine"; +import type { FreeformClassifyRequest } from "@infiplot/types"; +import { NextResponse } from "next/server"; +import { loadEngineConfig } from "@/lib/config"; + +export const runtime = "nodejs"; + +export async function POST(req: Request) { + let body: FreeformClassifyRequest; + try { + body = (await req.json()) as FreeformClassifyRequest; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.session || !body.freeformText?.trim()) { + return NextResponse.json( + { error: "session and freeformText are required" }, + { status: 400 }, + ); + } + + try { + const config = loadEngineConfig(); + const result = await classifyFreeform(config, body); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/page.tsx b/app/page.tsx index 34029e6..70609f1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,7 +11,7 @@ import { type Gender, } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; -import { TtsKeyModal } from "@/components/TtsKeyModal"; +import { SettingsModal, readStoredPlayerName } from "@/components/SettingsModal"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -47,7 +47,6 @@ const OPTS: Opt[] = [ { label: "性向", items: [...GENDERS] }, { label: "绘画风格", modal: true, items: [...ART_STYLES] }, { label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 }, - { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 }, { label: "内容节奏", items: [...PACINGS], defaultIndex: 1 }, ]; @@ -1239,12 +1238,13 @@ export default function HomePage() { // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 const [hintClosed, setHintClosed] = useState(false); - // 自带 TTS Key 弹窗:可选增强,Key 只存浏览器、绝不经过服务器。 - const [ttsOpen, setTtsOpen] = useState(false); + // 统一设置弹窗(名字 + 配音 + TTS Key):可选增强,数据只存浏览器。 + const [settingsOpen, setSettingsOpen] = useState(false); const [ttsConfigured, setTtsConfigured] = useState(false); + const [playerName, setPlayerName] = useState(""); + const [audioEnabled, setAudioEnabled] = useState(true); const styleRow = OPTS.findIndex((o) => o.modal); - const voiceRow = OPTS.findIndex((o) => o.label === "语音配音"); const genderIndex = sel[0] ?? 0; const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向"; const phrases = EXAMPLE_PHRASES[gender]; @@ -1286,9 +1286,14 @@ export default function HomePage() { } }, []); - // 启动时回填「已启用」徽标——读 localStorage 判断用户是否已存过 Key。 + // 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字 / 配音偏好。 useEffect(() => { setTtsConfigured(readStoredTtsConfig() != null); + setPlayerName(readStoredPlayerName()); + try { + const stored = localStorage.getItem("infiplot:muted"); + if (stored === "1") setAudioEnabled(false); + } catch { /* ignore */ } }, []); // 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。 @@ -1315,8 +1320,7 @@ export default function HomePage() { prompt.trim() || (phrases[phraseIdx] ?? "").trim(); const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动"; const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折"; - const voice = OPTS[3]!.items[sel[3] ?? 1]!; - const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快"; + const pace = PACINGS[sel[3] ?? 1] ?? "紧凑爽快"; // worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、 // 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出 @@ -1352,8 +1356,6 @@ export default function HomePage() { artStyle === "自定义风格" ? DEFAULT_STYLE : artStyle; styleGuide = STYLE_MAP[effectiveStyle] ?? STYLE_MAP[DEFAULT_STYLE]!; } - const audioEnabled = voice === "开启"; - // 只有「自定义」风格选中、且确实上传了参考图时才透传——其他预设没必要 // 占用 reference slot(也避免 styleGuide 已经是文本预设、画师收到不相关 // 参考图反而产生干扰)。 @@ -1373,7 +1375,7 @@ export default function HomePage() { sessionStorage.setItem( "infiplot:custom", - JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }), + JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage, playerName: playerName || undefined }), ); router.push("/play?custom=1"); }; @@ -1391,11 +1393,9 @@ export default function HomePage() { // 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」 // 的红果默认基调,对精选卡不再生效。 const onCardClick = (idx: number, _card: StoryContent) => { - const voice = OPTS[3]!.items[sel[3] ?? 1]!; - const audioEnabled = voice === "开启"; sessionStorage.setItem( "infiplot:custom", - JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }), + JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled, playerName }), ); track("game_start", { source: "curated", @@ -1456,11 +1456,7 @@ export default function HomePage() { value={prompt} onChange={(e) => setPrompt(e.target.value)} onKeyDown={(e) => { - if ( - e.key === "Enter" && - !e.shiftKey && - !e.nativeEvent.isComposing - ) { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); start(); } @@ -1518,30 +1514,23 @@ export default function HomePage() { /> ))} - - - {/* 自带 TTS Key 入口:公共语音模型有 RPM/TPM 限额,高并发易静音; - 填自己的小米 MiMo Key(免费)→ 稳定配音、延迟更低,且 Key 只存本地。 */} -
- + {/* 设置入口:与 CategorySelect 视觉一致,点击打开 modal */} +
+ +
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */} @@ -1550,6 +1539,8 @@ export default function HomePage() {

输入你的想象、配置风格,点击「开始」即可游玩;也可以从下方的精选故事集,挑一篇快速体验{" "} InfiPlot。 + 点击「设置」可以配置你的名字和配音 + API Key,让角色以你的名字称呼你,配音体验也更稳定。

+ + + ) : ( + /* ── Collapsed: normal choices + small freeform trigger ── */ + <> + {choices.map((choice, i) => ( + onSelectChoice(choice)} + /> + ))} + {onFreeformInput && ( + + )} + + )} )} @@ -484,7 +605,7 @@ export function PlayCanvas({ }`} style={{ color: "rgba(205,165,90,0.92)" }} > - {beat.speaker} + {displaySpeaker(beat.speaker)}

)} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx new file mode 100644 index 0000000..3c4e994 --- /dev/null +++ b/components/SettingsModal.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { type ReactNode, useEffect, useState } from "react"; +import { + clearStoredTtsConfig, + readStoredTtsConfig, + writeStoredTtsConfig, +} from "@/lib/clientTtsConfig"; +import { + findTtsPreset, + PAYG_PRESET_ID, + TTS_KEY_DOC_URL, + TTS_REGION_PRESETS, +} from "@/lib/ttsPresets"; + +const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName"; + +export function readStoredPlayerName(): string { + try { + return localStorage.getItem(PLAYER_NAME_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +export function writeStoredPlayerName(name: string): void { + try { + if (name) { + localStorage.setItem(PLAYER_NAME_STORAGE_KEY, name); + } else { + localStorage.removeItem(PLAYER_NAME_STORAGE_KEY); + } + } catch { + /* ignore */ + } +} + +export function SettingsModal({ + initialAudioEnabled = true, + onClose, + onSaved, + footerNote, +}: { + initialAudioEnabled?: boolean; + onClose: () => void; + onSaved: (settings: { ttsConfigured: boolean; playerName: string; audioEnabled: boolean }) => void; + footerNote?: ReactNode; +}) { + const [initialTts] = useState(() => readStoredTtsConfig()); + const initialKind = findTtsPreset(initialTts?.presetId)?.kind ?? "payg"; + const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind); + const [regionId, setRegionId] = useState( + initialKind === "token-plan" + ? (initialTts?.presetId ?? TTS_REGION_PRESETS[0]!.id) + : TTS_REGION_PRESETS[0]!.id, + ); + const [apiKey, setApiKey] = useState(initialTts?.apiKey ?? ""); + const [showKey, setShowKey] = useState(false); + const ttsAlreadyConfigured = initialTts != null; + + const [playerName, setPlayerName] = useState(() => readStoredPlayerName()); + const [voiceOn, setVoiceOn] = useState(initialAudioEnabled); + + const [shown, setShown] = useState(false); + + const expectedPrefix = keyType === "payg" ? "sk-" : "tp-"; + const prefixMismatch = + apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix); + + useEffect(() => { + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + }, []); + + const close = () => { + setShown(false); + setTimeout(onClose, 280); + }; + + const save = () => { + const name = playerName.trim(); + writeStoredPlayerName(name); + + try { + localStorage.setItem("infiplot:muted", voiceOn ? "0" : "1"); + } catch { /* ignore */ } + + const key = apiKey.trim(); + let ttsConfigured = false; + if (key) { + const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId; + writeStoredTtsConfig({ presetId, apiKey: key }); + ttsConfigured = true; + } else if (!ttsAlreadyConfigured) { + ttsConfigured = false; + } else { + ttsConfigured = true; + } + + if (ttsConfigured && !voiceOn) setVoiceOn(true); + const finalVoiceOn = ttsConfigured ? true : voiceOn; + + onSaved({ ttsConfigured, playerName: name, audioEnabled: finalVoiceOn }); + close(); + }; + + const clearAll = () => { + clearStoredTtsConfig(); + writeStoredPlayerName(""); + try { localStorage.removeItem("infiplot:muted"); } catch { /* ignore */ } + onSaved({ ttsConfigured: false, playerName: "", audioEnabled: true }); + close(); + }; + + const hasAnySetting = ttsAlreadyConfigured || readStoredPlayerName().length > 0; + + return ( +
+
e.stopPropagation()} + className={ + "flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " + + (shown ? "opacity-100 scale-100" : "opacity-0 scale-95") + } + > + {/* Header */} +
+
+ + 设置 + + + 可选 · 这些设置仅保存在本地浏览器 + +
+ +
+ +
+ {/* ── Player Name Section ── */} +
+
+ + + + + 玩家名字 + +
+ setPlayerName(e.target.value)} + type="text" + maxLength={20} + autoComplete="off" + spellCheck={false} + placeholder="不填则使用「你」" + className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" + /> + + NPC 会在对话中用这个名字称呼你。不填则默认以「你」称呼。 + +
+ +
+ + {/* ── Voice Section (toggle + key as child) ── */} +
+
+ + + + + 语音配音 + +
+
+ {( + [ + { on: true, label: "开启", icon: "fa-solid fa-volume-high" }, + { on: false, label: "关闭", icon: "fa-solid fa-volume-xmark" }, + ] as const + ).map((t) => { + const active = voiceOn === t.on; + return ( + + ); + })} +
+ + {/* ── TTS Key (sub-section, only when voice is on) ── */} + {voiceOn && ( +
+
+ + + 自带配音 Key + + 可选 +
+

+ 填入你自己的 + 小米 MiMo API Key + ,配音将在浏览器本地合成,Key 只保存在本地、绝不经过服务器。MiMo + TTS 目前 + 限时免费 + ,申请即可使用。 +

+ +
+ + K e y · 类 型 + +
+ {( + [ + { + kind: "payg", + label: "按量付费 Pay-as-you-go", + sub: "sk- 开头", + }, + { + kind: "token-plan", + label: "套餐 Token Plan", + sub: "tp- 开头", + }, + ] as const + ).map((t) => { + const active = keyType === t.kind; + return ( + + ); + })} +
+
+ + {keyType === "token-plan" && ( +
+ + 区 域 节 点 + +
+ {TTS_REGION_PRESETS.map((p) => { + const active = p.id === regionId; + return ( + + ); + })} +
+ + 选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。 + +
+ )} + +
+ + A P I · K e y + +
+ setApiKey(e.target.value)} + type={showKey ? "text" : "password"} + autoComplete="off" + spellCheck={false} + placeholder={ + keyType === "payg" + ? "粘贴 sk- 开头的按量 Key" + : "粘贴 tp- 开头的套餐 Key" + } + className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-50 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" + /> + +
+ {prefixMismatch && ( + + + 此 Key 不是 {expectedPrefix} 开头,可能与所选「 + {keyType === "payg" + ? "按量付费 Pay-as-you-go" + : "套餐 Token Plan"} + 」类型不符,请确认是否填错。 + + )} + + + 如何免费申请 Key?查看图文教程 + +
+ + {footerNote && ( +

+ {footerNote} +

+ )} +
+ )} +
+
+ + {/* Footer */} +
+ {hasAnySetting && ( + + )} + +
+
+
+ ); +} diff --git a/lib/analytics.ts b/lib/analytics.ts index b4f7d5c..e9c6b37 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -49,6 +49,7 @@ type AnalyticsEventData = { kind: "advance-beat" | "change-scene"; }; vision_click: { result: "insert-beat" | "change-scene" }; + freeform_input: { scene_index: number; text_length: number }; tts_toggle: { muted: boolean }; fullscreen_toggle: { on: boolean }; play_heartbeat: never; diff --git a/lib/engine/index.ts b/lib/engine/index.ts index 3a94bc1..c4b868a 100644 --- a/lib/engine/index.ts +++ b/lib/engine/index.ts @@ -2,6 +2,7 @@ export { startSession, requestScene, visionDecide, + classifyFreeform, requestInsertBeat, requestBeatAudio, } from "./orchestrator"; diff --git a/lib/engine/orchestrator.ts b/lib/engine/orchestrator.ts index 30f2726..136ce8c 100644 --- a/lib/engine/orchestrator.ts +++ b/lib/engine/orchestrator.ts @@ -2,6 +2,9 @@ import type { BeatAudioRequest, BeatAudioResponse, EngineConfig, + FreeformClassify, + FreeformClassifyRequest, + FreeformClassifyResponse, InsertBeatRequest, InsertBeatResponse, Session, @@ -13,10 +16,16 @@ import type { VisionResponse, } from "@infiplot/types"; import { coerceOrientation } from "@infiplot/types"; +import { chat } from "@infiplot/ai-client"; import { runArchitect } from "./agents/architect"; import { selectStyle } from "./agents/styleSelector"; import { directInsertBeat, directScene } from "./director"; import { STYLE_MAP } from "@/lib/options"; +import { parseJsonLoose } from "./jsonParser"; +import { + FREEFORM_CLASSIFY_SYSTEM, + buildFreeformClassifyUserMessage, +} from "./prompts"; import { synthesizeBeat } from "./voice"; import { interpret } from "./vision"; @@ -52,6 +61,7 @@ export async function startSession( characters: [], styleReferenceImage: req.styleReferenceImage?.trim() || undefined, orientation: coerceOrientation(req.orientation), + playerName: req.playerName?.trim() || undefined, }; // Stage 0 — Architect (+ optional auto style selection, in parallel). @@ -138,6 +148,41 @@ export async function visionDecide( return interpret(config.vision, req.annotatedImageBase64, current); } +// ────────────────────────────────────────────────────────────────────── +// classifyFreeform — classifies a freeform text input at a choice node +// into match-choice / insert-beat / change-scene. Single lightweight +// LLM call; no image, no scene generation. +// ────────────────────────────────────────────────────────────────────── + +export async function classifyFreeform( + config: EngineConfig, + req: FreeformClassifyRequest, +): Promise { + const current = req.session.history.at(-1)?.scene ?? null; + const userMsg = buildFreeformClassifyUserMessage( + req.freeformText, + current?.scenePrompt, + ); + + const raw = await chat(config.text, [ + { role: "system", content: FREEFORM_CLASSIFY_SYSTEM }, + { role: "user", content: userMsg }, + ], { temperature: 0, tag: "freeform-classify" }); + + const parsed = parseJsonLoose<{ + classify?: string; + freeformAction?: string; + }>(raw); + + const classify: FreeformClassify = + parsed.classify === "change-scene" ? "change-scene" : "insert-beat"; + + return { + classify, + freeformAction: parsed.freeformAction?.trim() || req.freeformText, + }; +} + // ────────────────────────────────────────────────────────────────────── // requestInsertBeat — single-agent transient beat (no image, no new // characters). Stays single-LLM by design — the INSERT_BEAT prompt diff --git a/lib/engine/prompts.ts b/lib/engine/prompts.ts index 0f4e9db..b3e7dd9 100644 --- a/lib/engine/prompts.ts +++ b/lib/engine/prompts.ts @@ -132,6 +132,11 @@ export function buildArchitectUserMessage(session: Session): string { const parts: string[] = []; parts.push(`世界观:${session.worldSetting}`); parts.push(`画风:${session.styleGuide}`); + if (session.playerName) { + parts.push( + `\n玩家名字:${session.playerName}\n(NPC 在对话中应自然地称呼玩家为「${session.playerName}」。「你」仍指代玩家视角,但 NPC 的台词里请使用这个名字而非泛称。不要为玩家设计立绘或音色——玩家是 POV 视角,永不出现在画面中。)`, + ); + } parts.push( "\n请据此产出这部交互剧的故事档案(story bible),严格以 JSON 格式返回。", ); @@ -421,6 +426,11 @@ function buildWriterContextParts(session: Session): string[] { // ── 1. session scalars ──────────────────────────────────────────────── parts.push(`世界观:${session.worldSetting}`); parts.push(`画风:${session.styleGuide}`); + if (session.playerName) { + parts.push( + `玩家名字:${session.playerName}(NPC 对话时用此名字称呼玩家;speaker 字段仍固定为 "你" 不变)`, + ); + } parts.push(""); // ── 2. story bible — spine only (stable) ────────────────────────────── @@ -874,26 +884,38 @@ STRICT RULES: } // ────────────────────────────────────────────────────────────────────── -// Insert-Beat — given a freeform vision action that is judged to stay -// *within* the current scene, generate one transient beat. +// Insert-Beat — given a freeform action (background click or typed +// input) that stays *within* the current scene, generate one beat +// with meaningful character interaction. // Single-agent path; no character design / no rendering involved. // ────────────────────────────────────────────────────────────────────── -export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个**不会换场景的自由动作**(比如看一眼桌上的相框、想了想刚才那句话)。请基于此动作,写出一个**单独的、过渡性的 beat**:可以是旁白、角色台词、或两者结合。 +export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**一个有实质内容的 beat**。 + +核心原则——**玩家的动作必须得到回应**: +- 如果当前场景有 NPC 在场,NPC **必须对玩家的动作做出反应**(说话、表情变化、动作回应)。用 narration 描述玩家的动作,用 speaker + line 写 NPC 的回应。 +- 如果场景中没有 NPC(纯环境),可以用 narration 描述玩家的观察/发现,给玩家一个新细节或情绪波动。 +- 不要写"你想做什么但没做"这种无意义的犹豫——玩家已经做了,世界要有反馈。 文本风格约束: -- narration / line 用中文,**纯净可显示文本**,不要写 (叹气) 这类配音标注 -- narration 与 line 加起来 ≤80 字 -- 不要打破当前场景的物理状态(玩家仍在原地、对面仍是同一个角色) +- narration / line 用中文,**纯净可显示文本**,不要写 (叹气)(语速快) 这类配音标注 +- narration 与 line 加起来 ≤100 字 +- 不要打破当前场景的物理状态(玩家仍在原地) - 不要生成选项或下一步指引 —— 玩家点击会自然回到原 beat -- 这个 beat 也要"有所得"——给玩家一个新细节、一丝潜台词或情绪波动(show, don't tell),别写成无意义的空台词 +- 内容要"有所得"——一个新细节、一丝潜台词、一次真实的交流(show, don't tell) speaker 字段允许的取值**只有两种**(与主路径 Writer 一致 — Pattern B galgame 标准): 1. **已登记角色**里的 NPC 真名(**绝不允许引入新角色**) -2. **"你"** — 玩家本人在自言自语 / 说一句过渡性的话(对白框显示,但不调 TTS) +2. **"你"** — 玩家本人开口说话(对白框显示,但不调 TTS) 其它任何 POV 变体(玩家 / 我 / 主角 / protagonist / player / MC / I / me)**一律错误**,请用 "你" 代替。 +推荐模式(有 NPC 在场时): + narration = 描述玩家做了什么(动作/表情/心理) + speaker = NPC 真名 + line = NPC 的回应台词 + lineDelivery = 配音导演指令 + - 如果有 line 且 speaker = NPC,**必须**给出 lineDelivery(配音导演指令) - 如果有 line 且 speaker = "你",lineDelivery 可以留空(玩家对白不调 TTS) @@ -913,6 +935,11 @@ export function buildInsertBeatUserMessage( ): string { const parts: string[] = []; parts.push(`世界观:${session.worldSetting}`); + if (session.playerName) { + parts.push( + `玩家名字:${session.playerName}(NPC 对话时用此名字称呼玩家;speaker 字段仍固定为 "你" 不变)`, + ); + } if (session.characters.length > 0) { parts.push("\n已登记角色(speaker 只能用这些名字):"); @@ -935,8 +962,17 @@ export function buildInsertBeatUserMessage( } } + if (current) { + const lastBeatId2 = current.visitedBeatIds.at(-1) ?? current.scene.entryBeatId; + const lastBeat2 = current.scene.beats.find((b) => b.id === lastBeatId2); + const activeNpcs = lastBeat2?.activeCharacters?.map((c) => c.name) ?? []; + if (activeNpcs.length > 0) { + parts.push(`当前画面中在场的 NPC:${activeNpcs.join("、")}(优先让在场 NPC 回应玩家)`); + } + } + parts.push(`\n玩家此刻的自由动作:${freeformAction}`); - parts.push("\n请生成一个过渡性 beat,严格以 JSON 格式返回。"); + parts.push("\n请生成一个有实质回应的 beat,严格以 JSON 格式返回。"); return parts.join("\n"); } @@ -971,4 +1007,41 @@ export function buildVisionUserPrompt(scene: Scene | null): string { 红点位置即为玩家点击位置。请判断玩家意图与分类,以 JSON 格式返回。`; } +// ────────────────────────────────────────────────────────────────────── +// Freeform Classify — classifies a player's freeform text input at a +// choice node into one of: match an existing choice, insert a beat +// in-scene, or trigger a scene change. +// ────────────────────────────────────────────────────────────────────── + +export const FREEFORM_CLASSIFY_SYSTEM = `你是交互视觉小说的意图分类助手。玩家在一个选择节点输入了自由文本(而非点击已有选项)。你要判断这个输入最适合走哪条路径: + +1. "insert-beat":玩家想在当前场景内与角色互动(问一句话、做一个动作、表达情绪、调查某个东西)→ NPC 会对玩家的动作做出回应,但不切换场景 +2. "change-scene":玩家想去别的地方、做出重大决定、推动剧情到新阶段 → 切换到全新场景 + +判断准则: +- 大多数对话类输入(问问题、说一句话、对角色做出反应)→ "insert-beat" +- 明确要离开当前场景、去别的地方、跳过时间、做出改变人物关系的重大决定 → "change-scene" +- 拿不准时偏向 "insert-beat"(场内互动成本低,体验更流畅) + +必须输出严格 JSON: +{ + "classify": "insert-beat" 或 "change-scene", + "freeformAction": "玩家想做什么的一句中文描述(用于后续编剧参考)" +} + +不要输出 JSON 以外的任何文本。`; + +export function buildFreeformClassifyUserMessage( + freeformText: string, + scenePrompt: string | undefined, +): string { + const parts: string[] = []; + if (scenePrompt) { + parts.push(`当前场景:${scenePrompt}`); + } + parts.push(`\n玩家输入:「${freeformText}」`); + parts.push("\n请判断分类,以 JSON 格式返回。"); + return parts.join("\n"); +} + export type PainterCharacterInput = Pick; diff --git a/lib/types/index.ts b/lib/types/index.ts index 11bc429..00381c3 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -280,6 +280,12 @@ export type Session = { * share one aspect ratio. Absent → "landscape" (back-compat). */ orientation?: Orientation; + /** + * Optional player-chosen display name. When set, NPC dialogue will address + * the player by this name instead of the generic "你". Stored client-side + * only (localStorage); never persisted server-side. + */ + playerName?: string; }; // ────────────────────────────────────────────────────────────────────── @@ -372,6 +378,8 @@ export type StartRequest = { * (default) keeps 16:9 widescreen. Locked for the whole session. */ orientation?: Orientation; + /** Optional player display name — see Session.playerName. */ + playerName?: string; }; // /api/parse-style-image — vision LLM extracts a textual painting-style @@ -458,6 +466,21 @@ export type VisionResponse = { classify: VisionClassify; }; +// /api/classify-freeform — classifies a player's freeform text input +// into one of three paths: match an existing choice, insert a beat +// in-scene, or trigger a scene change. +export type FreeformClassifyRequest = { + session: Session; + freeformText: string; +}; + +export type FreeformClassify = "insert-beat" | "change-scene"; + +export type FreeformClassifyResponse = { + classify: FreeformClassify; + freeformAction: string; +}; + // /api/insert-beat — generates a single transient beat in response to // a freeform vision action. Does NOT regenerate the image. export type InsertBeatRequest = {