"use client"; import { 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 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; }; export function ModelSettingsModal({ onClose, onSaved, }: { onClose: () => void; onSaved: (settings: { ttsConfigured: boolean }) => void; }) { 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>({}); const [shown, setShown] = useState(false); // 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); 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 updateGroup = ( key: string, field: keyof Omit, value: string, ) => { setGroups((prev) => prev.map((g) => (g.key === key ? { ...g, [field]: value } : g)), ); }; const save = () => { 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(); let ttsConfigured = false; if (key) { const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId; writeStoredTtsConfig({ presetId, apiKey: key }); ttsConfigured = true; } else { clearStoredTtsConfig(); } onSaved({ ttsConfigured }); close(); }; const clearAll = () => { clearStoredModelConfig(); clearStoredTtsConfig(); setGroups((prev) => prev.map((g) => ({ ...g, baseUrl: "", apiKey: "", model: "", provider: "" })), ); setTtsApiKey(""); onSaved({ ttsConfigured: false }); close(); }; const hasAnySetting = groups.some((g) => g.baseUrl.trim() && g.apiKey.trim() && g.model.trim()) || initialTts != null; return (
e.stopPropagation()} className={ "flex w-[600px] 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 */}
模型设置 API Key 仅保存在浏览器本地,不会发送到服务器
{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 && ( )}
); }