diff --git a/app/page.tsx b/app/page.tsx index 5d44036..99be34f 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 { SettingsModal, readStoredPlayerName } from "@/components/SettingsModal"; +import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -47,6 +47,7 @@ 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 }, ]; @@ -1252,13 +1253,14 @@ export default function HomePage() { // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 const [hintClosed, setHintClosed] = useState(false); - // 统一设置弹窗(名字 + 配音 + TTS Key):可选增强,数据只存浏览器。 + // 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。 const [settingsOpen, setSettingsOpen] = useState(false); const [ttsConfigured, setTtsConfigured] = useState(false); const [playerName, setPlayerName] = useState(""); - const [audioEnabled, setAudioEnabled] = useState(true); + const [visionClickEnabled, setVisionClickEnabled] = 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]; @@ -1300,14 +1302,11 @@ 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 */ } + setVisionClickEnabled(readStoredVisionClick()); }, []); // 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。 @@ -1334,7 +1333,9 @@ export default function HomePage() { prompt.trim() || (phrases[phraseIdx] ?? "").trim(); const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动"; const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折"; - const pace = PACINGS[sel[3] ?? 1] ?? "紧凑爽快"; + const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!; + const audioEnabled = voice === "开启"; + const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快"; // worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、 // 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出 @@ -1407,6 +1408,8 @@ export default function HomePage() { // 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」 // 的红果默认基调,对精选卡不再生效。 const onCardClick = (idx: number, _card: StoryContent) => { + const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!; + const audioEnabled = voice === "开启"; sessionStorage.setItem( "infiplot:custom", JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled, playerName }), @@ -1428,6 +1431,15 @@ export default function HomePage() { InfiPlot
+
))} - {/* 设置入口:与 CategorySelect 视觉一致,点击打开 modal */} -
- -
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */} @@ -1714,12 +1709,17 @@ export default function HomePage() { )} {settingsOpen && ( setSettingsOpen(false)} onSaved={(settings) => { setTtsConfigured(settings.ttsConfigured); setPlayerName(settings.playerName); - setAudioEnabled(settings.audioEnabled); + setVisionClickEnabled(settings.visionClickEnabled); + if (settings.ttsConfigured && voiceRow >= 0) { + const onIdx = OPTS[voiceRow]!.items.indexOf("开启"); + if (onIdx >= 0) + setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v))); + } }} /> )} diff --git a/app/play/page.tsx b/app/play/page.tsx index 0f2a5e0..739a8b4 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -17,8 +17,7 @@ import { } from "@/components/PlayCanvas"; 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 { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { PRESETS } from "@/lib/presets"; @@ -580,9 +579,11 @@ function PlayInner() { const [silenceStrikes, setSilenceStrikes] = useState(0); // Once the player dismisses the silence nudge, keep it gone for this session. const [nudgeDismissed, setNudgeDismissed] = useState(false); - // The in-place BYO-key modal, opened from the silence nudge so the player can - // add a key without leaving the play page. - const [ttsModalOpen, setTtsModalOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [visionClickEnabled, setVisionClickEnabled] = useState(() => { + if (typeof window === "undefined") return true; + return readStoredVisionClick(); + }); const startedRef = useRef(false); const poolRef = useRef>(new Map()); @@ -852,15 +853,10 @@ function PlayInner() { prefetchSceneAudio(); }, [muted, prefetchSceneAudio]); - // ── BYO key enabled/disabled from the play page (silence nudge → modal) ─ - // On enable: point the synth path at the user's key and immediately - // re-synthesize the current scene in-browser, so the voices the player just - // missed come back without a reload (their characters already carry - // server-provisioned `voice`, which resolveByoVoice reuses with the new key). - // On disable: just stop using it; later scenes fall back to the server. - const handleByoSaved = useCallback( - (configured: boolean) => { - const cfg = configured ? loadClientTtsConfig() : null; + const handleSettingsSaved = useCallback( + (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => { + setVisionClickEnabled(settings.visionClickEnabled); + const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null; byoTtsRef.current = cfg; setByoTtsConfig(cfg); if (cfg) { @@ -1761,6 +1757,8 @@ function PlayInner() { onFreeformInput={onFreeformInput} orientation={orientation} playerName={session?.playerName} + visionClickEnabled={visionClickEnabled} + onOpenSettings={() => setSettingsOpen(true)} fullViewport dialogueHistory={dialogueHistory} /> @@ -1788,6 +1786,14 @@ function PlayInner() { )} + {settingsOpen && ( + setSettingsOpen(false)} + onSaved={handleSettingsSaved} + footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。" + /> + )} ); } @@ -1838,6 +1844,8 @@ function PlayInner() { onFreeformInput={onFreeformInput} orientation={orientation} playerName={session?.playerName} + visionClickEnabled={visionClickEnabled} + onOpenSettings={() => setSettingsOpen(true)} dialogueHistory={dialogueHistory} aboveCanvas={ + )} + - ); - })} - - + {/* ── TTS Key Section ── */} +
+
+ + + + + 自带配音 Key + + 可选 +
+

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

- {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" - /> +
+ + 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 ( -
- {prefixMismatch && ( - - - 此 Key 不是 {expectedPrefix} 开头,可能与所选「 - {keyType === "payg" - ? "按量付费 Pay-as-you-go" - : "套餐 Token Plan"} - 」类型不符,请确认是否填错。 - - )} -
- - 如何免费申请 Key?查看图文教程 - -
- - {footerNote && ( -

- {footerNote} -

- )} + ); + })}
+
+ + {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} +

)}
diff --git a/components/TtsKeyModal.tsx b/components/TtsKeyModal.tsx deleted file mode 100644 index 1713d15..0000000 --- a/components/TtsKeyModal.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -// Bring-your-own Xiaomi MiMo TTS key modal — shared by the homepage and the -// play page. Two-step picker (key family → region for Token Plan only), key -// stored CLIENT-SIDE ONLY (see lib/clientTtsConfig). `onSaved(configured)` -// fires after a save/disable so each host can react (homepage flips the -// 语音配音 toggle; the play page re-synthesizes the current scene in-browser). -// `footerNote` lets the host tailor the closing hint to its own context. - -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 DEFAULT_FOOTER_NOTE: ReactNode = - "提示:需将上方「语音配音」设为「开启」配音才会生效。保存后本设备后续游玩会自动使用此 Key。"; - -export function TtsKeyModal({ - onClose, - onSaved, - footerNote = DEFAULT_FOOTER_NOTE, -}: { - onClose: () => void; - onSaved: (configured: boolean) => void; - footerNote?: ReactNode; -}) { - // Read storage once; useState initializers ignore later renders, so local - // edits aren't clobbered and we don't re-hit localStorage every render. - const [initial] = useState(() => readStoredTtsConfig()); - // Two-step picker: choose key family first, then — only for Token Plan — a - // region. Pay-as-you-go (`sk-`) keys hit one fixed endpoint, so no region. - const initialKind = findTtsPreset(initial?.presetId)?.kind ?? "token-plan"; - const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind); - const [regionId, setRegionId] = useState( - initialKind === "token-plan" - ? (initial?.presetId ?? TTS_REGION_PRESETS[0]!.id) - : TTS_REGION_PRESETS[0]!.id, - ); - const [apiKey, setApiKey] = useState(initial?.apiKey ?? ""); - const [showKey, setShowKey] = useState(false); - const [shown, setShown] = useState(false); - const alreadyConfigured = initial != null; - // Soft guard: tp- keys belong to Token Plan, sk- to pay-as-you-go. A - // mismatched pairing hits the wrong endpoint → guaranteed auth failure → - // silent playback (the very symptom BYO exists to kill). Warn, but never - // block: prefix conventions could change and a hard gate would lock out an - // otherwise-valid key. - 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 key = apiKey.trim(); - if (!key) return; - const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId; - writeStoredTtsConfig({ presetId, apiKey: key }); - onSaved(true); - close(); - }; - const disable = () => { - clearStoredTtsConfig(); - onSaved(false); - close(); - }; - - 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") - } - > -
-
- - 自带配音 Key - - - 可选 · 用你自己的小米 MiMo 免费额度,配音更稳定、延迟更低 - -
- -
- -
-

- 经常没有声音?公共语音模型有调用频率限额(RPM / TPM),同时游玩的人多时很容易撞到限额而静音。填入你自己的小米 MiMo API Key 后,配音将 - 直接在你的浏览器里合成 - 、使用你自己的免费额度 ——{" "} - Key 只保存在本地浏览器、绝不经过我们的服务器 - 。 -

- -
- K e y · 类 型 -
- {( - [ - { kind: "token-plan", label: "套餐 Token Plan", sub: "tp- 开头" }, - { kind: "payg", label: "按量付费 Pay-as-you-go", sub: "sk- 开头" }, - ] 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.xiaomimimo.com - ,无需选择区域。 - -
- )} - -
- - 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}

-
- -
- {alreadyConfigured && ( - - )} - -
-
-
- ); -}