diff --git a/app/api/beat-audio/route.ts b/app/api/beat-audio/route.ts index bc84417..d3cf81b 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(); + const config = loadEngineConfig(req.headers); const result = await requestBeatAudio(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/api/insert-beat/route.ts b/app/api/insert-beat/route.ts index 467392c..ca36c55 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 config = loadEngineConfig(); + const config = loadEngineConfig(req.headers); const result = await requestInsertBeat(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/api/parse-style-image/route.ts b/app/api/parse-style-image/route.ts index 02d165e..51151c3 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(); + const config = loadEngineConfig(req.headers); const raw = await analyzeImageDataUrl( config.vision, body.imageDataUrl, diff --git a/app/api/scene/route.ts b/app/api/scene/route.ts index 2fc432f..11cb26b 100644 --- a/app/api/scene/route.ts +++ b/app/api/scene/route.ts @@ -23,7 +23,7 @@ export async function POST(req: Request) { } try { - const config = loadEngineConfig(); + const config = loadEngineConfig(req.headers); const result = await requestScene(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/api/start/route.ts b/app/api/start/route.ts index ecd5312..378850e 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 config = loadEngineConfig(); + const config = loadEngineConfig(req.headers); const result = await startSession(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/api/vision/route.ts b/app/api/vision/route.ts index 6f294df..e311288 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(); + const config = loadEngineConfig(req.headers); const result = await visionDecide(config, body); return NextResponse.json(result); } catch (err) { diff --git a/app/page.tsx b/app/page.tsx index e39ab0d..d5b8251 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { track } from "@/lib/analytics"; import { ART_STYLES, @@ -17,7 +17,7 @@ import { "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -705,6 +705,111 @@ 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: "", + }, +}; + +const BYO_STORAGE_KEY = "infiplot:byoApi"; + +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; + } +} + +function getByoHeaders(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(BYO_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed.llm?.enabled || parsed.painter?.enabled) { + return { "x-byo-api": raw }; + } + } + } catch { + /* ignore */ + } + return {}; +} + /* ---------- typewriter ---------- */ // 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句 @@ -980,7 +1085,10 @@ function StyleModal({ const resized = await resizeImageToDataUrl(file); const res = await fetch("/api/parse-style-image", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ imageDataUrl: resized }), }); if (!res.ok) { @@ -1369,6 +1477,270 @@ 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() { @@ -1377,6 +1749,11 @@ 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)。 // 仅在内存里持有——刷新即丢,符合「这就是一次性试玩」的语义。 @@ -1436,6 +1813,19 @@ export default function HomePage() { } }, []); + useEffect(() => { + setByoApi(loadByoConfig()); + }, []); + + const saveByoApi = (cfg: ByoApiConfig) => { + setByoApi(cfg); + try { + localStorage.setItem(BYO_STORAGE_KEY, JSON.stringify(cfg)); + } catch { + /* ignore */ + } + }; + // 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。 useEffect(() => { const el = inputRef.current; @@ -1564,6 +1954,22 @@ export default function HomePage() { InfiPlot
+ )} + {byoApiOpen && ( + setByoApiOpen(false)} /> + )}
); } diff --git a/app/play/page.tsx b/app/play/page.tsx index e5a62fa..5900733 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -29,6 +29,23 @@ import type { import { track } from "@/lib/analytics"; const MUTED_STORAGE_KEY = "infiplot:muted"; +const BYO_STORAGE_KEY = "infiplot:byoApi"; + +function getByoHeaders(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(BYO_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed.llm?.enabled || parsed.painter?.enabled) { + return { "x-byo-api": raw }; + } + } + } catch { + /* ignore */ + } + return {}; +} // Cap how long we wait for the browser to download + decode a scene image // before giving up and rendering anyway. Runware's CDN is usually <2s for a @@ -267,7 +284,10 @@ function prefetchScenePath( const promise = (async () => { const res = await fetch("/api/scene", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ session: specSession }), signal: abort.signal, }); @@ -483,7 +503,10 @@ function PlayInner() { try { const res = await fetch("/api/beat-audio", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ beat: { id: beat.id, line: beat.line, lineDelivery: beat.lineDelivery }, voice: speaker.voice, @@ -693,7 +716,10 @@ function PlayInner() { ) : fetch("/api/start", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify(livePayload), }).then(async (r) => { if (!r.ok) { @@ -918,7 +944,10 @@ function PlayInner() { const promise = (async () => { const res = await fetch("/api/scene", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ session: specSession }), }); if (!res.ok) { @@ -940,7 +969,10 @@ function PlayInner() { const annotatedImageBase64 = await annotateClick(imageUrl, click); const visionRes = await fetch("/api/vision", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ session, annotatedImageBase64 }), }); if (!visionRes.ok) { @@ -956,7 +988,10 @@ function PlayInner() { setPhase("inserting-beat"); const insertRes = await fetch("/api/insert-beat", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ session, freeformAction: decision.intent.freeformAction, @@ -1036,7 +1071,10 @@ function PlayInner() { const promise = (async () => { const res = await fetch("/api/scene", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...getByoHeaders(), + }, body: JSON.stringify({ session: specSession }), }); if (!res.ok) { @@ -1065,18 +1103,34 @@ function PlayInner() { // ── Render ──────────────────────────────────────────────────────────── if (error) { + const isByoActive = typeof window !== "undefined" && (() => { + try { + const raw = localStorage.getItem(BYO_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return parsed.llm?.enabled || parsed.painter?.enabled; + } + } catch {} + return false; + })(); + return (

出 · 了 · 点 · 状 · 况

-

+

{error}

+ {isByoActive && ( +

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

+ )} 返 回 diff --git a/lib/config.ts b/lib/config.ts index a51508b..576199b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -22,8 +22,8 @@ function loadTtsConfig(): TtsConfig | undefined { return { baseUrl, apiKey, speechModel }; } -export function loadEngineConfig(): EngineConfig { - return { +export function loadEngineConfig(headers?: Headers): EngineConfig { + const config: EngineConfig = { text: { baseUrl: readVar("TEXT_BASE_URL"), apiKey: readVar("TEXT_API_KEY"), @@ -42,4 +42,30 @@ export function loadEngineConfig(): EngineConfig { tts: loadTtsConfig(), mockImage: readOptionalVar("MOCK_IMAGE") === "true", }; + + const byoHeader = headers?.get("x-byo-api"); + if (byoHeader) { + try { + const byo = JSON.parse(byoHeader); + if (byo.llm?.enabled) { + if (byo.llm.endpoint) config.text.baseUrl = byo.llm.endpoint; + if (byo.llm.apiKey) config.text.apiKey = byo.llm.apiKey; + if (byo.llm.model) config.text.model = byo.llm.model; + + // Also override vision if llm is enabled + if (byo.llm.endpoint) config.vision.baseUrl = byo.llm.endpoint; + if (byo.llm.apiKey) config.vision.apiKey = byo.llm.apiKey; + if (byo.llm.model) config.vision.model = byo.llm.model; + } + if (byo.painter?.enabled) { + if (byo.painter.endpoint) config.image.baseUrl = byo.painter.endpoint; + if (byo.painter.apiKey) config.image.apiKey = byo.painter.apiKey; + if (byo.painter.model) config.image.model = byo.painter.model; + } + } catch (e) { + console.error("Failed to parse x-byo-api header in loadEngineConfig:", e); + } + } + + return config; } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.