Merge pull request #44 from zonghaoyuan/feat/vision-toggle

feat(play): add vision click setting
This commit is contained in:
Zonghao Yuan
2026-06-07 14:24:14 +08:00
committed by GitHub
5 changed files with 267 additions and 500 deletions
+29 -28
View File
@@ -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,15 @@ 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 paceRow = 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 +1303,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 +1334,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[paceRow] ?? 1] ?? "紧凑爽快";
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
@@ -1407,6 +1409,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 +1432,15 @@ export default function HomePage() {
Infi<em className="italic font-light text-ember-500">Plot</em>
</span>
<div className="flex items-center gap-5">
<button
type="button"
onClick={() => setSettingsOpen(true)}
aria-label="设置"
title="设置"
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
>
<i className="fa-solid fa-gear" />
</button>
<a
href="https://github.com/zonghaoyuan/infiplot"
target="_blank"
@@ -1528,23 +1541,6 @@ export default function HomePage() {
/>
</div>
))}
{/* 设置入口:与 CategorySelect 视觉一致,点击打开 modal */}
<div className="text-left">
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="group flex items-center gap-2.5 pb-1.5 border-b border-clay-900/20 hover:border-clay-900/45 transition-colors"
>
<span className="text-[10px] smallcaps text-clay-500"></span>
<span className={
"font-serif text-base md:text-lg " +
(ttsConfigured || playerName ? "text-ember-500" : "text-clay-900")
}>
{playerName || (ttsConfigured ? "已配置" : "未配置")}
</span>
<i className="fa-solid fa-gear text-[9px] text-clay-400" />
</button>
</div>
</div>
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed */}
@@ -1714,12 +1710,17 @@ export default function HomePage() {
)}
{settingsOpen && (
<SettingsModal
initialAudioEnabled={audioEnabled}
initialVisionClickEnabled={visionClickEnabled}
onClose={() => 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)));
}
}}
/>
)}
+29 -20
View File
@@ -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,8 @@ 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(true);
const startedRef = useRef(false);
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
@@ -652,6 +650,9 @@ function PlayInner() {
useEffect(() => {
mutedRef.current = muted;
}, [muted]);
useEffect(() => {
setVisionClickEnabled(readStoredVisionClick());
}, []);
// Coarse liveness ping for active-time analytics. /play is a single SPA
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
@@ -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() {
</button>
</div>
)}
{settingsOpen && (
<SettingsModal
initialVisionClickEnabled={visionClickEnabled}
onClose={() => setSettingsOpen(false)}
onSaved={handleSettingsSaved}
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
/>
)}
</div>
);
}
@@ -1838,6 +1844,8 @@ function PlayInner() {
onFreeformInput={onFreeformInput}
orientation={orientation}
playerName={session?.playerName}
visionClickEnabled={visionClickEnabled}
onOpenSettings={() => setSettingsOpen(true)}
dialogueHistory={dialogueHistory}
aboveCanvas={
<button
@@ -1887,7 +1895,7 @@ function PlayInner() {
<span className="flex items-center gap-1 animate-fade-in">
<button
type="button"
onClick={() => setTtsModalOpen(true)}
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
title="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
>
@@ -1925,11 +1933,12 @@ function PlayInner() {
</main>
{ttsModalOpen && (
<TtsKeyModal
onClose={() => setTtsModalOpen(false)}
onSaved={handleByoSaved}
footerNote="保存后会立即用这把 Key 在你的浏览器里合成当前这一幕的配音;本设备后续游玩也会自动使用此 Key。"
{settingsOpen && (
<SettingsModal
initialVisionClickEnabled={visionClickEnabled}
onClose={() => setSettingsOpen(false)}
onSaved={handleSettingsSaved}
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
/>
)}
</div>
+38 -21
View File
@@ -178,6 +178,8 @@ export function PlayCanvas({
fullViewport = false,
orientation = "landscape",
playerName,
visionClickEnabled = true,
onOpenSettings,
aboveCanvas,
aboveCanvasLeft,
belowCanvas,
@@ -197,6 +199,9 @@ export function PlayCanvas({
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
orientation?: Orientation;
playerName?: string;
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
visionClickEnabled?: boolean;
onOpenSettings?: () => void;
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
aboveCanvas?: ReactNode;
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
@@ -276,14 +281,18 @@ export function PlayCanvas({
}
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
if (phase !== "ready" || !imgRef.current || !beat) return;
if (phase !== "ready" || !beat) return;
if (!typingDone) {
skipTypewriter();
return;
}
if (beat.next.type === "continue") {
onAdvance();
return;
}
if (!visionClickEnabled || !imgRef.current) return;
const el = imgRef.current;
const rect = el.getBoundingClientRect();
// Portrait renders with object-fit:cover, which scales the 9:16 image to
// FILL the box and crops the overflow — so the rendered box ≠ the full
// image. Map the click from box-space back into full-image-space via the
// cover geometry so the marker lands where the user tapped. Landscape's box
// matches the image aspect (no crop), so it keeps simple normalization.
let x: number;
let y: number;
if (orientation === "portrait") {
@@ -298,18 +307,6 @@ export function PlayCanvas({
x = (e.clientX - rect.left) / rect.width;
y = (e.clientY - rect.top) / rect.height;
}
// If the typewriter is still printing, a click completes it instantly
// (standard VN affordance) — the page never sees this click.
if (!typingDone) {
skipTypewriter();
return;
}
// For continue-type beats, image click advances; for choice beats,
// image click goes through vision (treat as freeform action).
if (beat.next.type === "continue") {
onAdvance();
return;
}
onBackgroundClick({
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
@@ -329,6 +326,9 @@ export function PlayCanvas({
}
const interactive = phase === "ready" && !!imageUrl;
const imageClickable =
interactive &&
(!typingDone || beat?.next.type === "continue" || visionClickEnabled);
const dimmed = phase === "transitioning";
const portrait = orientation === "portrait";
@@ -393,7 +393,7 @@ export function PlayCanvas({
onClick={handleImageClick}
draggable={false}
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
interactive ? "cursor-pointer" : "cursor-wait"
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
} ${dimmed ? "opacity-40" : "opacity-100"}`}
style={sizeStyle}
/>
@@ -631,7 +631,7 @@ export function PlayCanvas({
{typingDone && beat.next.type === "continue" && (
<span
className="absolute bottom-[6px] right-[42px] text-[10px] animate-slow-pulse"
className={`absolute bottom-[6px] ${onOpenSettings ? "right-[74px]" : "right-[42px]"} text-[10px] animate-slow-pulse`}
style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden
>
@@ -639,13 +639,30 @@ export function PlayCanvas({
</span>
)}
{onOpenSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenSettings();
}}
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
aria-label="打开设置"
title="设置"
>
<i className="fa-solid fa-gear text-[12px]" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setHistoryOpen(true);
}}
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
className={`absolute bottom-[6px] ${
onOpenSettings ? "right-[40px]" : "right-[8px]"
} flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]`}
aria-label="打开剧情回溯"
title="剧情回溯"
>
+171 -160
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/ttsPresets";
const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName";
const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick";
export function readStoredPlayerName(): string {
try {
@@ -35,15 +36,23 @@ export function writeStoredPlayerName(name: string): void {
}
}
export function readStoredVisionClick(): boolean {
try {
return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0";
} catch {
return true;
}
}
export function SettingsModal({
initialAudioEnabled = true,
initialVisionClickEnabled = true,
onClose,
onSaved,
footerNote,
}: {
initialAudioEnabled?: boolean;
initialVisionClickEnabled?: boolean;
onClose: () => void;
onSaved: (settings: { ttsConfigured: boolean; playerName: string; audioEnabled: boolean }) => void;
onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => void;
footerNote?: ReactNode;
}) {
const [initialTts] = useState(() => readStoredTtsConfig());
@@ -59,7 +68,7 @@ export function SettingsModal({
const ttsAlreadyConfigured = initialTts != null;
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
const [voiceOn, setVoiceOn] = useState(initialAudioEnabled);
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
const [shown, setShown] = useState(false);
@@ -82,7 +91,7 @@ export function SettingsModal({
writeStoredPlayerName(name);
try {
localStorage.setItem("infiplot:muted", voiceOn ? "0" : "1");
localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0");
} catch { /* ignore */ }
const key = apiKey.trim();
@@ -97,18 +106,15 @@ export function SettingsModal({
ttsConfigured = true;
}
if (ttsConfigured && !voiceOn) setVoiceOn(true);
const finalVoiceOn = ttsConfigured ? true : voiceOn;
onSaved({ ttsConfigured, playerName: name, audioEnabled: finalVoiceOn });
onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick });
close();
};
const clearAll = () => {
clearStoredTtsConfig();
writeStoredPlayerName("");
try { localStorage.removeItem("infiplot:muted"); } catch { /* ignore */ }
onSaved({ ttsConfigured: false, playerName: "", audioEnabled: true });
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ }
onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: true });
close();
};
@@ -179,29 +185,29 @@ export function SettingsModal({
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
{/* ── Voice Section (toggle + key as child) ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
{/* ── Vision Click Section ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className="fa-solid fa-volume-high text-[11px]" />
<i className="fa-solid fa-eye text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ on: true, label: "开启", icon: "fa-solid fa-volume-high" },
{ on: false, label: "关闭", icon: "fa-solid fa-volume-xmark" },
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
] as const
).map((t) => {
const active = voiceOn === t.on;
const active = visionClick === t.on;
return (
<button
key={String(t.on)}
type="button"
onClick={() => setVoiceOn(t.on)}
onClick={() => setVisionClick(t.on)}
className={
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
(active
@@ -215,155 +221,160 @@ export function SettingsModal({
);
})}
</div>
<span className="text-[11px] text-clay-400">
AI
</span>
</div>
{/* ── TTS Key (sub-section, only when voice is on) ── */}
{voiceOn && (
<div className="mt-3 flex flex-col gap-4 rounded-sm border border-clay-900/8 bg-cream-100/40 p-4">
<div className="flex items-center gap-2">
<i className="fa-solid fa-key text-[10px] text-clay-400" />
<span className="text-[13px] text-clay-800">
Key
</span>
<span className="text-[10px] text-clay-400"></span>
</div>
<p className="text-[12px] leading-relaxed text-clay-500">
<span className="text-clay-800"> MiMo API Key</span>
Key MiMo
TTS
<span className="text-clay-800"></span>
使
</p>
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
K e y ·
</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{
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 (
<button
key={t.kind}
type="button"
onClick={() => setKeyType(t.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<span className="text-[13px]">{t.label}</span>
<span className="text-[10px] text-clay-400">
{t.sub}
</span>
</button>
);
})}
</div>
</div>
{/* ── TTS Key Section ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className="fa-solid fa-key text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
Key
</span>
<span className="text-[10px] text-clay-400"></span>
</div>
<p className="text-[12px] leading-relaxed text-clay-500">
<span className="text-clay-800"> MiMo API Key</span>
Key MiMo
TTS
<span className="text-clay-800"></span>
使
</p>
{keyType === "token-plan" && (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{TTS_REGION_PRESETS.map((p) => {
const active = p.id === regionId;
return (
<button
key={p.id}
type="button"
onClick={() => setRegionId(p.id)}
className={
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
{p.label}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
A P I · K e y
</span>
<div className="relative">
<input
value={apiKey}
onChange={(e) => 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"
/>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
K e y ·
</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{
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 (
<button
key={t.kind}
type="button"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
onClick={() => setKeyType(t.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<i
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
<span className="text-[13px]">{t.label}</span>
<span className="text-[10px] text-clay-400">
{t.sub}
</span>
</button>
</div>
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg"
? "按量付费 Pay-as-you-go"
: "套餐 Token Plan"}
</span>
)}
<a
href={TTS_KEY_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
</a>
</div>
{footerNote && (
<p className="text-[11px] leading-relaxed text-clay-400">
{footerNote}
</p>
)}
);
})}
</div>
</div>
{keyType === "token-plan" && (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{TTS_REGION_PRESETS.map((p) => {
const active = p.id === regionId;
return (
<button
key={p.id}
type="button"
onClick={() => setRegionId(p.id)}
className={
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
{p.label}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
A P I · K e y
</span>
<div className="relative">
<input
value={apiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
</button>
</div>
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg"
? "按量付费 Pay-as-you-go"
: "套餐 Token Plan"}
</span>
)}
<a
href={TTS_KEY_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
</a>
</div>
{footerNote && (
<p className="text-[11px] leading-relaxed text-clay-400">
{footerNote}
</p>
)}
</div>
</div>
-271
View File
@@ -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<string>(
initialKind === "token-plan"
? (initial?.presetId ?? TTS_REGION_PRESETS[0]!.id)
: TTS_REGION_PRESETS[0]!.id,
);
const [apiKey, setApiKey] = useState<string>(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 (
<div
onMouseDown={close}
className={
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
(shown
? "bg-clay-900/30 backdrop-blur-md"
: "bg-clay-900/0 backdrop-blur-0")
}
>
<div
onMouseDown={(e) => 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")
}
>
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
<div className="flex flex-col">
<span className="font-serif text-xl md:text-2xl text-clay-900">
Key
</span>
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
· MiMo
</span>
</div>
<button
type="button"
onClick={close}
aria-label="关闭"
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="flex flex-col gap-6 overflow-y-auto px-6 md:px-8 py-6">
<p className="text-[13px] leading-relaxed text-clay-600">
RPM / TPM MiMo API Key
<span className="text-clay-900"></span>
使 {" "}
<span className="text-clay-900">Key </span>
</p>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">K e y · </span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ 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 (
<button
key={t.kind}
type="button"
onClick={() => setKeyType(t.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<span className="text-[13px]">{t.label}</span>
<span className="text-[10px] text-clay-400">{t.sub}</span>
</button>
);
})}
</div>
</div>
{keyType === "token-plan" ? (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500"> </span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{TTS_REGION_PRESETS.map((p) => {
const active = p.id === regionId;
return (
<button
key={p.id}
type="button"
onClick={() => setRegionId(p.id)}
className={
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
{p.label}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
</span>
</div>
) : (
<div className="flex items-start gap-2 rounded-sm border border-clay-900/10 bg-cream-100/60 px-3.5 py-2.5">
<i className="fa-solid fa-circle-info mt-0.5 text-[11px] text-clay-400" />
<span className="text-[11px] leading-relaxed text-clay-500">
使{" "}
<span className="text-clay-700">api.xiaomimimo.com</span>
</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
A P I · K e y
</span>
<div className="relative">
<input
value={apiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
</button>
</div>
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg" ? "按量付费 Pay-as-you-go" : "套餐 Token Plan"}
</span>
)}
<a
href={TTS_KEY_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
</a>
</div>
<p className="text-[11px] leading-relaxed text-clay-400">{footerNote}</p>
</div>
<div className="flex items-center gap-3 border-t border-clay-900/10 px-6 md:px-8 py-4">
{alreadyConfigured && (
<button
type="button"
onClick={disable}
className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900"
>
<i className="fa-solid fa-rotate-left text-xs" />
</button>
)}
<button
type="button"
onClick={save}
disabled={!apiKey.trim()}
className="ml-auto inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2.5 font-sans text-sm text-cream-50 transition-colors hover:bg-ember-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-check text-xs" />
</button>
</div>
</div>
</div>
);
}