"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"; const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick"; 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 readStoredVisionClick(): boolean { try { return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0"; } catch { return true; } } export function SettingsModal({ initialVisionClickEnabled = true, onClose, onSaved, footerNote, }: { initialVisionClickEnabled?: boolean; onClose: () => void; onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: 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 [visionClick, setVisionClick] = useState(initialVisionClickEnabled); 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(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0"); } 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 { clearStoredTtsConfig(); ttsConfigured = false; } onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick }); close(); }; const clearAll = () => { clearStoredTtsConfig(); writeStoredPlayerName(""); try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ } onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: 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 会在对话中用这个名字称呼你。不填则默认以「你」称呼。
{/* ── Vision Click Section ── */}
点击画面识别
{( [ { on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" }, { on: false, label: "关闭", icon: "fa-solid fa-ban" }, ] as const ).map((t) => { const active = visionClick === t.on; return ( ); })}
开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。
{/* ── TTS Key Section ── */}
自带配音 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-100 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 && ( )}
); }