diff --git a/app/globals.css b/app/globals.css index 9cb7d82..fe95fca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,6 +88,30 @@ .vn-scrollbar::-webkit-scrollbar-corner { background: transparent; } + + /* 极细滚动条 · 无轨道背景 */ + .thin-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(195, 155, 75, 0.5) transparent; + } + + .thin-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + .thin-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .thin-scrollbar::-webkit-scrollbar-thumb { + background: rgba(195, 155, 75, 0.45); + border-radius: 999px; + } + + .thin-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(220, 180, 95, 0.7); + } } @keyframes infiplot-ripple { diff --git a/app/page.tsx b/app/page.tsx index 59e69e9..1f3673a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,7 +12,6 @@ import { } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; -import { ModelSettingsModal } from "@/components/ModelSettingsModal"; import { analyzeImageDataUrl } from "@infiplot/ai-client"; import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig"; import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction"; @@ -1264,9 +1263,9 @@ export default function HomePage() { // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 const [hintClosed, setHintClosed] = useState(false); - // 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。 + // 统一设置弹窗(通用 + 模型):可选增强,数据只存浏览器。 const [settingsOpen, setSettingsOpen] = useState(false); - const [modelSettingsOpen, setModelSettingsOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState<"general" | "models">("general"); const [ttsConfigured, setTtsConfigured] = useState(false); const [playerName, setPlayerName] = useState(""); const [visionClickEnabled, setVisionClickEnabled] = useState(true); @@ -1486,16 +1485,10 @@ export default function HomePage() {
- -
- -
- {groups.map((g, idx) => ( -
- {idx > 0 && ( -
- )} -
-
- - - - - {g.label} - -
- -
- - B A S E · U R L - - 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" - /> -
- -
- - A P I · K e y - -
- 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" - /> - -
-
- -
- - M o d e l - - 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" - /> -
- -
- - P r o v i d e r(可选) - - - - 留空时系统会根据 Base URL 自动推断协议。 - -
-
-
- ))} - -
- - {/* ── 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 - -
- 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?查看图文教程 - -
-
- -
- -
-

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

-
-
- - {/* Footer */} -
- {hasAnySetting && ( - - )} - -
-
-
- ); -} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 44da808..083bc55 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,6 +1,23 @@ "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"; @@ -33,52 +50,216 @@ export function readStoredVisionClick(): boolean { } } +const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [ + { value: "", label: "自动推断(推荐)" }, + { value: "openai_compatible", label: "OpenAI Compatible" }, + { value: "openai", label: "OpenAI (Native)" }, + { value: "anthropic", label: "Anthropic" }, + { value: "google", label: "Google Gemini" }, + { 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 }) => 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); - const [shown, setShown] = useState(false); + // ── 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); }; - const save = () => { + // ── General actions ── + const saveGeneral = () => { const name = playerName.trim(); writeStoredPlayerName(name); - try { localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0"); } catch { /* ignore */ } + }; - onSaved({ playerName: name, visionClickEnabled: visionClick }); + 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 = () => { - writeStoredPlayerName(""); - try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ } - onSaved({ playerName: "", visionClickEnabled: true }); + clearGeneral(); + clearModels(); + onSaved({ playerName: "", visionClickEnabled: true, ttsConfigured: false }); close(); }; - const hasAnySetting = readStoredPlayerName().length > 0; + 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-[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 " + + "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") } > @@ -117,81 +298,368 @@ export function SettingsModal({
-
- {/* ── 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 会在对话中用这个名字称呼你。不填则默认以「你」称呼。 - -
+ {/* 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" && ( + <> + {groups.map((g, idx) => ( +
+ {idx > 0 && ( +
+ )} +
+
+ + + + + {g.label} + +
+ +
+ + B A S E · U R L + + 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" + /> +
+ +
+ + A P I · K e y + +
+ 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" + /> + +
+
+ +
+ + M o d e l + + 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" + /> +
+ +
+ + P r o v i d e r(可选) + + + + 留空时系统会根据 Base URL 自动推断协议。 + +
+
+
+ ))} + +
+ + {/* ── 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 + +
+ 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"} + 」类型不符,请确认是否填错。 + + )} + - - {t.label} - - ); - })} -
- - 开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。 - -
+ + 如何免费申请 Key?查看图文教程 + +
+
- {footerNote && ( -
-

- {footerNote} -

-
+
+ +
+

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

+
+ )}