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,让角色以你的名字称呼你,配音体验也更稳定。
)}
- {ttsOpen && (
- setTtsOpen(false)}
- onSaved={(configured) => {
- setTtsConfigured(configured);
- // 启用自带 Key 时顺手把「语音配音」拨到「开启」——否则用户配了 Key
- // 却还是静音,体验自相矛盾。停用时不动其选择,尊重用户原本的偏好。
- if (configured && voiceRow >= 0) {
- const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
- if (onIdx >= 0)
- setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v)));
- }
+ {settingsOpen && (
+ setSettingsOpen(false)}
+ onSaved={(settings) => {
+ setTtsConfigured(settings.ttsConfigured);
+ setPlayerName(settings.playerName);
+ setAudioEnabled(settings.audioEnabled);
}}
/>
)}
diff --git a/app/play/page.tsx b/app/play/page.tsx
index e42761f..0f2a5e0 100644
--- a/app/play/page.tsx
+++ b/app/play/page.tsx
@@ -18,6 +18,7 @@ import {
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
import { TtsKeyModal } from "@/components/TtsKeyModal";
+import { readStoredPlayerName } from "@/components/SettingsModal";
import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { PRESETS } from "@/lib/presets";
@@ -27,6 +28,7 @@ import type {
BeatChoice,
Character,
CharacterVoice,
+ FreeformClassifyResponse,
InsertBeatResponse,
Orientation,
Scene,
@@ -1107,11 +1109,12 @@ function PlayInner() {
styleGuide: string;
styleReferenceImage?: string;
orientation?: Orientation;
+ playerName?: string;
} | null = null;
if (!cardName) {
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
- if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
+ if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined };
} else if (isCustom) {
const stored = sessionStorage.getItem("infiplot:custom");
if (stored) {
@@ -1121,11 +1124,13 @@ function PlayInner() {
styleGuide: string;
audioEnabled?: boolean;
styleReferenceImage?: string;
+ playerName?: string;
};
livePayload = {
worldSetting: parsed.worldSetting,
styleGuide: parsed.styleGuide,
styleReferenceImage: parsed.styleReferenceImage || undefined,
+ playerName: parsed.playerName || undefined,
};
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
} catch {
@@ -1224,6 +1229,7 @@ function PlayInner() {
storyState: data.storyState,
styleReferenceImage: data.styleReferenceImage,
orientation: data.scene.orientation ?? sessionOrientation,
+ playerName: livePayload?.playerName || readStoredPlayerName() || undefined,
};
visitedBeatsRef.current = [data.scene.entryBeatId];
setSession(initial);
@@ -1436,6 +1442,135 @@ function PlayInner() {
void performSceneTransition(promise, exit, visited, choice.label);
}
+ async function onFreeformInput(text: string) {
+ if (phase !== "ready" || !session || !currentScene) return;
+
+ track("freeform_input", {
+ scene_index: session.history.length,
+ text_length: text.length,
+ });
+
+ setPhase("vision-thinking");
+
+ try {
+ const classifyRes = await fetch("/api/classify-freeform", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ session: stripVoicesForTransport(session),
+ freeformText: text,
+ }),
+ });
+ if (!classifyRes.ok) {
+ const j = (await classifyRes.json().catch(() => ({}))) as { error?: string };
+ throw new Error(j.error ?? classifyRes.statusText);
+ }
+ const decision = (await classifyRes.json()) as FreeformClassifyResponse;
+
+ if (decision.classify === "insert-beat") {
+ // Interactive beat: NPC responds to the player's action, scene stays
+ setPhase("inserting-beat");
+ const insertRes = await fetch("/api/insert-beat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ session: stripVoicesForTransport(session),
+ freeformAction: decision.freeformAction,
+ clientTts: !!byoTtsRef.current,
+ }),
+ });
+ if (!insertRes.ok) {
+ const j = (await insertRes.json().catch(() => ({}))) as { error?: string };
+ throw new Error(j.error ?? insertRes.statusText);
+ }
+ const { partial, characters: insertChars } =
+ (await insertRes.json()) as InsertBeatResponse;
+
+ const fromBeatId =
+ currentBeatRef.current?.id ?? currentScene.entryBeatId;
+ const newBeatId = `b_ins_${Date.now()}_${Math.random()
+ .toString(36)
+ .slice(2, 6)}`;
+ const newBeat: Beat = {
+ id: newBeatId,
+ narration: partial.narration,
+ speaker: partial.speaker,
+ line: partial.line,
+ lineDelivery: partial.lineDelivery,
+ next: { type: "continue", nextBeatId: fromBeatId },
+ };
+
+ const patched: Scene = {
+ ...currentScene,
+ beats: [...currentScene.beats, newBeat],
+ };
+ const nextSession: Session = {
+ ...session,
+ history: session.history.map((h, i, arr) =>
+ i === arr.length - 1 ? { ...h, scene: patched } : h,
+ ),
+ characters: mergeCharactersPreserveVoice(
+ session.characters,
+ insertChars,
+ ),
+ };
+ setSession(nextSession);
+ setCurrentScene(patched);
+ setCurrentBeatId(newBeatId);
+ if (newBeat.speaker && newBeat.line) {
+ void fetchBeatAudio(nextSession, {
+ id: newBeatId,
+ speaker: newBeat.speaker,
+ line: newBeat.line,
+ lineDelivery: newBeat.lineDelivery,
+ });
+ }
+ setLastExitLabel(decision.freeformAction);
+ setPhase("ready");
+ return;
+ }
+
+ // change-scene path
+ const visited = [...visitedBeatsRef.current];
+ const exit: SceneExit = {
+ kind: "freeform",
+ action: decision.freeformAction,
+ };
+ clearPool(poolRef.current);
+
+ const specSession: Session = {
+ ...session,
+ history: session.history.map((h, i, arr) =>
+ i === arr.length - 1
+ ? { ...h, visitedBeatIds: visited, exit }
+ : h,
+ ),
+ };
+
+ const promise = (async () => {
+ const res = await fetch("/api/scene", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ session: stripVoicesForTransport(specSession),
+ clientTts: !!byoTtsRef.current,
+ }),
+ });
+ if (!res.ok) {
+ const j = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(j.error ?? res.statusText);
+ }
+ return (await res.json()) as SceneResponse;
+ })();
+
+ setPendingClick(null);
+ void performSceneTransition(promise, exit, visited, decision.freeformAction);
+ } catch (e) {
+ setError(String(e));
+ setPhase("ready");
+ }
+ }
+
async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
setPhase("vision-thinking");
@@ -1623,7 +1758,9 @@ function PlayInner() {
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
+ onFreeformInput={onFreeformInput}
orientation={orientation}
+ playerName={session?.playerName}
fullViewport
dialogueHistory={dialogueHistory}
/>
@@ -1698,7 +1835,9 @@ function PlayInner() {
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
+ onFreeformInput={onFreeformInput}
orientation={orientation}
+ playerName={session?.playerName}
dialogueHistory={dialogueHistory}
aboveCanvas={