diff --git a/app/api/beat-audio/route.ts b/app/api/beat-audio/route.ts index ace4684..5c7c8ee 100644 --- a/app/api/beat-audio/route.ts +++ b/app/api/beat-audio/route.ts @@ -24,7 +24,7 @@ export async function POST(req: Request) { } try { - const config = loadEngineConfig(req.headers); + const config = loadEngineConfig(); const result = await requestBeatAudio(config, body); if (!result.audio) return new Response(null, { status: 204 }); const binary = Buffer.from(result.audio.base64, "base64"); diff --git a/app/api/insert-beat/route.ts b/app/api/insert-beat/route.ts index 8dda7f1..2c82897 100644 --- a/app/api/insert-beat/route.ts +++ b/app/api/insert-beat/route.ts @@ -22,7 +22,7 @@ export async function POST(req: Request) { } try { - const base = loadEngineConfig(req.headers); + const base = loadEngineConfig(); // See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS. const config = body.clientTts === true ? { ...base, tts: undefined } : base; const result = await requestInsertBeat(config, body); diff --git a/app/api/parse-style-image/route.ts b/app/api/parse-style-image/route.ts index 51151c3..02d165e 100644 --- a/app/api/parse-style-image/route.ts +++ b/app/api/parse-style-image/route.ts @@ -51,7 +51,7 @@ export async function POST(req: Request) { } try { - const config = loadEngineConfig(req.headers); + const config = loadEngineConfig(); const raw = await analyzeImageDataUrl( config.vision, body.imageDataUrl, diff --git a/app/api/scene/route.ts b/app/api/scene/route.ts index c59c406..bc9bcf1 100644 --- a/app/api/scene/route.ts +++ b/app/api/scene/route.ts @@ -32,7 +32,7 @@ export async function POST(req: Request) { } try { - const base = loadEngineConfig(req.headers); + const base = loadEngineConfig(); // See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS. const config = body.clientTts === true ? { ...base, tts: undefined } : base; const result = await requestScene(config, body); diff --git a/app/api/start/route.ts b/app/api/start/route.ts index 167c097..467680b 100644 --- a/app/api/start/route.ts +++ b/app/api/start/route.ts @@ -41,7 +41,7 @@ export async function POST(req: Request) { } try { - const base = loadEngineConfig(req.headers); + const base = loadEngineConfig(); // BYO key: the browser provisions + synths voices directly against Xiaomi // (key never reaches us), so strip server-side TTS so the engine skips all // provisioning + synth. See StartRequest.clientTts. diff --git a/app/api/vision/route.ts b/app/api/vision/route.ts index e311288..6f294df 100644 --- a/app/api/vision/route.ts +++ b/app/api/vision/route.ts @@ -42,7 +42,7 @@ export async function POST(req: Request) { } try { - const config = loadEngineConfig(req.headers); + const config = loadEngineConfig(); const result = await visionDecide(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/page.tsx b/app/page.tsx index db64177..10e2c58 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { track } from "@/lib/analytics"; import { ART_STYLES, @@ -11,7 +11,6 @@ import { type Gender, } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; -import { BYO_STORAGE_KEY, getByoHeaders } from "@/lib/byoHeaders"; import { TtsKeyModal } from "@/components/TtsKeyModal"; /* ============================================================================ @@ -700,93 +699,6 @@ const DISPLAY_ORDER: Record = { 女性向: Array.from({ length: 30 }, (_, i) => i), }; -/* ---------- BYO API config ---------- */ -/* 「自带 API」首页右上角入口里持有的配置:LLM / 画师两段独立 toggle,因为很多 - 用户只想换其中一段(画师 key 比 LLM key 难拿)。整份配置只落 localStorage, - 后端路由如何消费(请求头夹带 / sessionStorage 透传等)由后续 PR 处理。 */ - -type ByoSection = { - enabled: boolean; - provider: string; - endpoint: string; - apiKey: string; - model: string; -}; - -type ByoApiConfig = { - llm: ByoSection; - painter: ByoSection; -}; - -type ByoProvider = { label: string; endpoint: string; modelHint: string }; - -const LLM_PROVIDERS: Record = { - openai: { label: "OpenAI", endpoint: "https://api.openai.com/v1", modelHint: "gpt-5 / gpt-4o" }, - anthropic: { label: "Anthropic", endpoint: "https://api.anthropic.com/v1", modelHint: "claude-opus-4-7 …" }, - compatible: { label: "OpenAI 兼容", endpoint: "", modelHint: "你部署的模型 ID" }, - custom: { label: "自定义", endpoint: "", modelHint: "" }, -}; - -const PAINTER_PROVIDERS: Record = { - runware: { label: "Runware", endpoint: "https://api.runware.ai/v1", modelHint: "runware:101@1 …" }, - replicate: { label: "Replicate", endpoint: "https://api.replicate.com/v1", modelHint: "black-forest-labs/flux-1.1-pro" }, - custom: { label: "自定义", endpoint: "", modelHint: "" }, -}; - -const DEFAULT_BYO: ByoApiConfig = { - llm: { - enabled: false, - provider: "openai", - endpoint: LLM_PROVIDERS.openai!.endpoint, - apiKey: "", - model: "", - }, - painter: { - enabled: false, - provider: "runware", - endpoint: PAINTER_PROVIDERS.runware!.endpoint, - apiKey: "", - model: "", - }, -}; - -function normalizeByoSection( - s: unknown, - providers: Record, - defaultProvider: string, -): ByoSection { - const obj = (s && typeof s === "object" ? s : {}) as Record; - const provider = - typeof obj.provider === "string" && providers[obj.provider] - ? obj.provider - : defaultProvider; - return { - enabled: obj.enabled === true, - provider, - endpoint: - typeof obj.endpoint === "string" - ? obj.endpoint - : providers[provider]?.endpoint ?? "", - apiKey: typeof obj.apiKey === "string" ? obj.apiKey : "", - model: typeof obj.model === "string" ? obj.model : "", - }; -} - -function loadByoConfig(): ByoApiConfig { - try { - const raw = localStorage.getItem(BYO_STORAGE_KEY); - if (!raw) return DEFAULT_BYO; - const parsed: unknown = JSON.parse(raw); - const obj = (parsed && typeof parsed === "object" ? parsed : {}) as Record; - return { - llm: normalizeByoSection(obj.llm, LLM_PROVIDERS, "openai"), - painter: normalizeByoSection(obj.painter, PAINTER_PROVIDERS, "runware"), - }; - } catch { - return DEFAULT_BYO; - } -} - /* ---------- typewriter ---------- */ // 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句 @@ -1062,10 +974,7 @@ function StyleModal({ const resized = await resizeImageToDataUrl(file); const res = await fetch("/api/parse-style-image", { method: "POST", - headers: { - "Content-Type": "application/json", - ...getByoHeaders(), - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imageDataUrl: resized }), }); if (!res.ok) { @@ -1454,270 +1363,6 @@ function StyleModal({ ); } -/* ---------- BYO API modal ---------- */ - -function ByoField({ label, children }: { label: string; children: ReactNode }) { - return ( -
- - {label} - - {children} -
- ); -} - -function ByoSectionCard({ - title, - subtitle, - iconClass, - value, - onChange, - providers, -}: { - title: string; - subtitle: string; - iconClass: string; - value: ByoSection; - onChange: (patch: Partial) => void; - providers: Record; -}) { - const [showKey, setShowKey] = useState(false); - const providerMeta = providers[value.provider]; - // 换 provider 时把 endpoint 重置为该 provider 的默认值——切到「自定义」就清空让用户自己填。 - // 即便用户手动改过 endpoint 这里也会覆盖:换 provider 后旧 endpoint 多半已经无效。 - const onProvider = (p: string) => { - const meta = providers[p]; - if (!meta) return; - onChange({ provider: p, endpoint: meta.endpoint }); - }; - return ( -
-
-
- - - -
- {title} - - {subtitle} - -
-
-
- - -
-
- -
- - - - - - onChange({ endpoint: e.target.value })} - placeholder="https://api.example.com/v1" - spellCheck={false} - className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-50 px-3 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" - /> - - - -
- onChange({ apiKey: e.target.value })} - placeholder="sk-•••" - autoComplete="off" - spellCheck={false} - className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-50 pl-3 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" - /> - -
-
- - - onChange({ model: e.target.value })} - placeholder={providerMeta?.modelHint || "模型名 / ID"} - spellCheck={false} - className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-50 px-3 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" - /> - -
-
- ); -} - -function ByoApiModal({ - value, - onSave, - onClose, -}: { - value: ByoApiConfig; - onSave: (cfg: ByoApiConfig) => void; - onClose: () => void; -}) { - const [shown, setShown] = useState(false); - // 把 draft 与外部 value 分开:取消就丢弃,保存才落 localStorage。 - // 这样用户可以随便拨 toggle / 改字段试,不满意点取消立刻回滚。 - const [draft, setDraft] = useState(value); - useEffect(() => { - const id = requestAnimationFrame(() => setShown(true)); - return () => cancelAnimationFrame(id); - }, []); - const close = () => { - setShown(false); - setTimeout(onClose, 280); - }; - const save = () => { - onSave(draft); - close(); - }; - return ( -
-
e.stopPropagation()} - className={ - "flex max-h-[86vh] w-[680px] max-w-[94vw] 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 ? "scale-100 opacity-100" : "scale-95 opacity-0") - } - > -
-
- 自带 API - - 默认使用 InfiPlot 提供的画师与 LLM。填入你自己的 key 后,这一台机器的所有生成都会走你的服务,不再消耗我们的额度。 - -
- -
- -
- - setDraft((d) => ({ ...d, llm: { ...d.llm, ...patch } })) - } - providers={LLM_PROVIDERS} - /> - - setDraft((d) => ({ ...d, painter: { ...d.painter, ...patch } })) - } - providers={PAINTER_PROVIDERS} - /> - -

- - API key 只保存在你的浏览器(localStorage),不会上传到我们的服务器。 -

-
- -
- -
- - -
-
-
-
- ); -} - /* ---------- page ---------- */ export default function HomePage() { @@ -1726,11 +1371,6 @@ export default function HomePage() { const [sel, setSel] = useState(OPTS.map((o) => o.defaultIndex ?? 0)); const [open, setOpen] = useState(-1); const [styleOpen, setStyleOpen] = useState(false); - // 「自带 API」配置入口。byoActive 用于在 header 入口上挂 ember 小圆点表示已启用。 - // 这份配置目前只落 localStorage——后端路由消费由后续 PR 处理。 - const [byoApiOpen, setByoApiOpen] = useState(false); - const [byoApi, setByoApi] = useState(DEFAULT_BYO); - const byoActive = byoApi.llm.enabled || byoApi.painter.enabled; const [prompt, setPrompt] = useState(""); // 用户在「自定义」入口里填的 styleGuide 文本(中/英文都行,原样喂给 LLM)。 // 仅在内存里持有——刷新即丢,符合「这就是一次性试玩」的语义。 @@ -1795,19 +1435,6 @@ export default function HomePage() { } }, []); - useEffect(() => { - setByoApi(loadByoConfig()); - }, []); - - const saveByoApi = (cfg: ByoApiConfig) => { - setByoApi(cfg); - try { - localStorage.setItem(BYO_STORAGE_KEY, JSON.stringify(cfg)); - } catch { - /* ignore */ - } - }; - // 启动时回填「已启用」徽标——读 localStorage 判断用户是否已存过 Key。 useEffect(() => { setTtsConfigured(readStoredTtsConfig() != null); @@ -1941,22 +1568,6 @@ export default function HomePage() { InfiPlot
- )} - {byoApiOpen && ( - setByoApiOpen(false)} /> - )} - {ttsOpen && ( setTtsOpen(false)} diff --git a/app/play/page.tsx b/app/play/page.tsx index da8beea..528da77 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -33,7 +33,6 @@ import type { VisionResponse, } from "@infiplot/types"; import { track } from "@/lib/analytics"; -import { getByoHeaders, isByoActive } from "@/lib/byoHeaders"; const MUTED_STORAGE_KEY = "infiplot:muted"; @@ -328,7 +327,6 @@ function prefetchScenePath( method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ session: stripVoicesForTransport(specSession), clientTts }), signal: abort.signal, @@ -651,7 +649,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ beat: { id: beat.id, line: beat.line, lineDelivery: beat.lineDelivery }, @@ -933,7 +930,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ ...livePayload, @@ -1168,7 +1164,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ session: stripVoicesForTransport(specSession), @@ -1196,7 +1191,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ session: stripVoicesForTransport(session), annotatedImageBase64 }), }); @@ -1215,7 +1209,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ session: stripVoicesForTransport(session), @@ -1302,7 +1295,6 @@ function PlayInner() { method: "POST", headers: { "Content-Type": "application/json", - ...getByoHeaders(), }, body: JSON.stringify({ session: stripVoicesForTransport(specSession), @@ -1335,8 +1327,6 @@ function PlayInner() { // ── Render ──────────────────────────────────────────────────────────── if (error) { - const byoOn = isByoActive(); - return (
@@ -1346,14 +1336,9 @@ function PlayInner() {

{error}

- {byoOn && ( -

- 提示:当前已启用「自带 API」。如果请求失败,请返回首页并检查右上角 API 配置的 Key、Endpoint 和 Model 是否正确,并确认您的服务额度充足。 -

- )} 返 回 diff --git a/lib/byoHeaders.ts b/lib/byoHeaders.ts deleted file mode 100644 index 1e110f5..0000000 --- a/lib/byoHeaders.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const BYO_STORAGE_KEY = "infiplot:byoApi"; - -const MAX_HEADER_SIZE = 2048; - -export function getByoHeaders(): Record { - if (typeof window === "undefined") return {}; - try { - const raw = localStorage.getItem(BYO_STORAGE_KEY); - if (raw && raw.length <= MAX_HEADER_SIZE) { - const parsed = JSON.parse(raw); - if (parsed.llm?.enabled || parsed.painter?.enabled) { - return { "x-byo-api": raw }; - } - } - } catch { - /* ignore */ - } - return {}; -} - -export function isByoActive(): boolean { - return Object.keys(getByoHeaders()).length > 0; -} diff --git a/lib/config.ts b/lib/config.ts index dde7482..e30ea70 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -3,7 +3,6 @@ import type { ProviderProtocol, TtsConfig, } from "@infiplot/types"; -import { isPublicUrl } from "./validateUrl"; const VALID_PROTOCOLS = [ "openai_compatible", @@ -50,23 +49,8 @@ function loadTtsConfig(): TtsConfig | undefined { return { baseUrl, apiKey, speechModel }; } -function safeEndpoint(v: unknown): string | undefined { - if (typeof v !== "string" || v.length === 0) return undefined; - const trimmed = v.trim(); - if (!trimmed || !isPublicUrl(trimmed)) { - console.error(`BYO endpoint rejected (not a public HTTPS URL): ${v.slice(0, 100).replace(/[\r\n]/g, "")}`); - return undefined; - } - return trimmed; -} - -function safeString(v: unknown, max: number): string | undefined { - if (typeof v !== "string" || v.length === 0) return undefined; - return v.slice(0, max).replace(/[\x00-\x1f]/g, ""); -} - -export function loadEngineConfig(headers?: Headers): EngineConfig { - const config: EngineConfig = { +export function loadEngineConfig(): EngineConfig { + return { text: { baseUrl: readVar("TEXT_BASE_URL"), apiKey: readVar("TEXT_API_KEY"), @@ -88,35 +72,4 @@ export function loadEngineConfig(headers?: Headers): EngineConfig { tts: loadTtsConfig(), mockImage: readOptionalVar("MOCK_IMAGE") === "true", }; - - const byoHeader = headers?.get("x-byo-api"); - if (byoHeader) { - if (byoHeader.length > 2048) { - console.error("x-byo-api header exceeds 2 KB limit, ignoring"); - } else { - try { - const byo = JSON.parse(byoHeader); - if (byo.llm?.enabled) { - const ep = safeEndpoint(byo.llm?.endpoint); - const key = safeString(byo.llm?.apiKey, 256); - const model = safeString(byo.llm?.model, 128); - if (ep) { config.text.baseUrl = ep; config.vision.baseUrl = ep; } - if (key) { config.text.apiKey = key; config.vision.apiKey = key; } - if (model) { config.text.model = model; config.vision.model = model; } - } - if (byo.painter?.enabled) { - const ep = safeEndpoint(byo.painter?.endpoint); - const key = safeString(byo.painter?.apiKey, 256); - const model = safeString(byo.painter?.model, 128); - if (ep) config.image.baseUrl = ep; - if (key) config.image.apiKey = key; - if (model) config.image.model = model; - } - } catch (e) { - console.error("Failed to parse x-byo-api header:", e); - } - } - } - - return config; } diff --git a/lib/validateUrl.ts b/lib/validateUrl.ts deleted file mode 100644 index a4a3504..0000000 --- a/lib/validateUrl.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Block SSRF: only allow HTTPS URLs pointing to public internet hosts. - -const ALLOWED_HOSTS = new Set([ - "api.openai.com", - "api.anthropic.com", - "generativelanguage.googleapis.com", - "api.runware.ai", - "api.replicate.com", - "api.deepseek.com", - "dashscope.aliyuncs.com", - "api.siliconflow.cn", - "api.together.xyz", - "openrouter.ai", - "api.mistral.ai", - "api.groq.com", - "api.fireworks.ai", - "api.cohere.com", -]); - -const BLOCKED_HOSTS = new Set([ - "localhost", - "metadata.google.internal", - "metadata.internal", -]); - -const PRIVATE_RANGES = [ - { start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) }, - { start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) }, - { start: ip4ToNum(100, 64, 0, 0), end: ip4ToNum(100, 127, 255, 255) }, - { start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) }, - { start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) }, - { start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) }, - { start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) }, - { start: ip4ToNum(224, 0, 0, 0), end: ip4ToNum(239, 255, 255, 255) }, - { start: ip4ToNum(240, 0, 0, 0), end: ip4ToNum(255, 255, 255, 255) }, -]; - -function ip4ToNum(a: number, b: number, c: number, d: number): number { - return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; -} - -function parseIp4(ip: string): number | null { - const parts = ip.split("."); - if (parts.length !== 4) return null; - for (const p of parts) { - const n = Number(p); - if (!Number.isInteger(n) || n < 0 || n > 255) return null; - } - return ip4ToNum( - Number(parts[0]), - Number(parts[1]), - Number(parts[2]), - Number(parts[3]), - ); -} - -function isPrivateIp(ip: string): boolean { - const n = parseIp4(ip); - if (n === null) return true; - return PRIVATE_RANGES.some((r) => n >= r.start && n <= r.end); -} - -export function isAllowlistedHost(hostname: string): boolean { - return ALLOWED_HOSTS.has(hostname); -} - -export function isPublicUrl(raw: string): boolean { - let url: URL; - try { - url = new URL(raw); - } catch { - return false; - } - - if (url.protocol !== "https:") return false; - if (url.username || url.password) return false; - - const host = url.hostname.toLowerCase(); - - if (BLOCKED_HOSTS.has(host)) return false; - // Reject all IPv6 addresses (including ::ffff:127.0.0.1 mapped forms) - if (host.includes(":")) return false; - - // Fast path: known API providers always pass - if (ALLOWED_HOSTS.has(host)) return true; - - // For unknown domains, block IP literals pointing to private ranges - const ipv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); - if (ipv4) return !isPrivateIp(host); - - // Domain names: allow — DNS rebinding is mitigated by redirect: "manual" - // on fetchWithRetry and the fact that Vercel's runtime resolves DNS once - // per fetch (no keep-alive reuse across requests). - return true; -}