"use client"; import { type ReactNode, useEffect, useState } from "react"; import type { ProviderProtocol } from "@infiplot/types"; import { clearStoredModelConfig, readStoredModelConfig, writeStoredModelConfig, } from "@/lib/clientModelConfig"; 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; } } const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [ { value: "", label: "自动推断(推荐)" }, { value: "openai_compatible", label: "OpenAI Compatible" }, { value: "runware", label: "Runware" }, ]; type ModelGroup = { key: "text" | "image" | "vision"; label: string; icon: string; baseUrl: string; apiKey: string; model: string; provider: string; }; type TabKey = "general" | "models"; export function SettingsModal({ initialTab = "general", initialVisionClickEnabled = true, onClose, onSaved, footerNote, }: { initialTab?: TabKey; initialVisionClickEnabled?: boolean; onClose: () => void; onSaved: (settings: { playerName: string; visionClickEnabled: boolean; ttsConfigured: boolean; }) => void; footerNote?: ReactNode; }) { const [activeTab, setActiveTab] = useState(initialTab); // ── General tab state ── const [playerName, setPlayerName] = useState(() => readStoredPlayerName()); const [visionClick, setVisionClick] = useState(initialVisionClickEnabled); // ── Models tab state ── const initial = readStoredModelConfig(); const [groups, setGroups] = useState([ { key: "text", label: "文本模型", icon: "fa-solid fa-pen-nib", baseUrl: initial?.textBaseUrl ?? "", apiKey: initial?.textApiKey ?? "", model: initial?.textModel ?? "", provider: initial?.textProvider ?? "", }, { key: "image", label: "绘图模型", icon: "fa-solid fa-palette", baseUrl: initial?.imageBaseUrl ?? "", apiKey: initial?.imageApiKey ?? "", model: initial?.imageModel ?? "", provider: initial?.imageProvider ?? "", }, { key: "vision", label: "识图模型", icon: "fa-solid fa-eye", baseUrl: initial?.visionBaseUrl ?? "", apiKey: initial?.visionApiKey ?? "", model: initial?.visionModel ?? "", provider: initial?.visionProvider ?? "", }, ]); const [showKeys, setShowKeys] = useState>({}); // TTS state 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 [ttsApiKey, setTtsApiKey] = useState(initialTts?.apiKey ?? ""); const [showTtsKey, setShowTtsKey] = useState(false); const expectedPrefix = keyType === "payg" ? "sk-" : "tp-"; const prefixMismatch = ttsApiKey.trim().length > 0 && !ttsApiKey.trim().startsWith(expectedPrefix); // ── Animation ── const [shown, setShown] = useState(false); useEffect(() => { const id = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(id); }, []); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); const close = () => { setShown(false); setTimeout(onClose, 280); }; // ── General actions ── const saveGeneral = () => { const name = playerName.trim(); writeStoredPlayerName(name); try { localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0"); } catch { /* ignore */ } }; const clearGeneral = () => { writeStoredPlayerName(""); try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ } setPlayerName(""); setVisionClick(true); }; const hasGeneralSetting = readStoredPlayerName().length > 0; // ── Models actions ── const updateGroup = ( key: string, field: keyof Omit, value: string, ) => { setGroups((prev) => prev.map((g) => (g.key === key ? { ...g, [field]: value } : g)), ); }; const saveModels = () => { const [text, image, vision] = groups; if (text && image && vision) { writeStoredModelConfig({ textBaseUrl: text.baseUrl, textApiKey: text.apiKey, textModel: text.model, textProvider: (text.provider as ProviderProtocol) || undefined, imageBaseUrl: image.baseUrl, imageApiKey: image.apiKey, imageModel: image.model, imageProvider: (image.provider as ProviderProtocol) || undefined, visionBaseUrl: vision.baseUrl, visionApiKey: vision.apiKey, visionModel: vision.model, visionProvider: (vision.provider as ProviderProtocol) || undefined, }); } const key = ttsApiKey.trim(); if (key) { const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId; writeStoredTtsConfig({ presetId, apiKey: key }); } else { clearStoredTtsConfig(); } }; const clearModels = () => { clearStoredModelConfig(); clearStoredTtsConfig(); setGroups((prev) => prev.map((g) => ({ ...g, baseUrl: "", apiKey: "", model: "", provider: "" })), ); setTtsApiKey(""); }; const hasModelSetting = groups.some((g) => g.baseUrl.trim() && g.apiKey.trim() && g.model.trim()) || initialTts != null; // ── Global save / clear ── const save = () => { saveGeneral(); saveModels(); const ttsConfigured = ttsApiKey.trim().length > 0; onSaved({ playerName: playerName.trim(), visionClickEnabled: visionClick, ttsConfigured, }); close(); }; const clearAll = () => { clearGeneral(); clearModels(); onSaved({ playerName: "", visionClickEnabled: true, ttsConfigured: false }); close(); }; const hasAnySetting = hasGeneralSetting || hasModelSetting; const tabs: { key: TabKey; label: string; icon: string }[] = [ { key: "general", label: "通用", icon: "fa-solid fa-sliders" }, { key: "models", label: "模型", icon: "fa-solid fa-microchip" }, ]; return (
e.stopPropagation()} className={ "flex w-[640px] max-w-[96vw] max-h-[90vh] 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 */}
设置 可选 · 这些设置仅保存在本地浏览器
{/* Tab bar */}
{tabs.map((t) => { const active = activeTab === t.key; return ( ); })}
{/* Content */}
{activeTab === "general" && ( <> {/* ── 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 识图并生成新的剧情分支。
{footerNote && (

{footerNote}

)} )} {activeTab === "models" && ( <>

请确保你的 API 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。

{groups.map((g, idx) => (
{idx > 0 && (
)}
{g.label}
BASE URL updateGroup(g.key, "baseUrl", e.target.value)} type="text" autoComplete="off" spellCheck={false} placeholder="https://api.example.com/v1" className="h-10 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" />
API Key
updateGroup(g.key, "apiKey", e.target.value)} type={showKeys[g.key] ? "text" : "password"} autoComplete="off" spellCheck={false} placeholder="sk-..." className="h-10 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" />
Model updateGroup(g.key, "model", e.target.value)} type="text" autoComplete="off" spellCheck={false} placeholder="gpt-4o / claude-3-5-sonnet / flux-1-dev ..." className="h-10 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" />
Provider(可选) 留空时系统会根据 Base URL 自动推断协议。
))}
{/* ── TTS Key Section ── */}
配音模型

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

Key 类型
{( [ { 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 ( ); })}
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
)}
API Key
setTtsApiKey(e.target.value)} type={showTtsKey ? "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?查看图文教程
)}
{/* Footer */}
{hasAnySetting && ( )}
); }