diff --git a/app/page.tsx b/app/page.tsx index 1f3673a..6d61a19 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -980,18 +980,30 @@ function StyleModal({ try { const resized = await resizeImageToDataUrl(file); const modelCfg = readStoredModelConfig(); - if (!modelCfg) { - throw new Error("请先点击首页右上角的「模型设置」配置视觉模型参数"); + let stylePrompt: string; + if (modelCfg) { + const config = resolveEngineConfig(modelCfg, null); + const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT); + let parsed: { stylePrompt?: string }; + try { + parsed = JSON.parse(raw); + } catch { + parsed = { stylePrompt: raw }; + } + stylePrompt = (parsed.stylePrompt ?? "").trim(); + } else { + const r = await fetch("/api/parse-style-image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageDataUrl: resized }), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${r.status}`); + } + const data = (await r.json()) as { stylePrompt?: string }; + stylePrompt = (data.stylePrompt ?? "").trim(); } - const config = resolveEngineConfig(modelCfg, null); - const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT); - let parsed: { stylePrompt?: string }; - try { - parsed = JSON.parse(raw); - } catch { - parsed = { stylePrompt: raw }; - } - const stylePrompt = (parsed.stylePrompt ?? "").trim(); if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述"); setDraft(stylePrompt); setCustomStyleRefImage(resized); diff --git a/app/play/page.tsx b/app/play/page.tsx index 4b40f56..156695b 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -34,14 +34,12 @@ import { visionDecide, classifyFreeform, requestInsertBeat, -} from "@infiplot/engine"; -import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig"; +} from "@/lib/engineClient"; import type { Beat, BeatChoice, Character, CharacterVoice, - EngineConfig, Orientation, Scene, SceneExit, @@ -54,17 +52,6 @@ import { track } from "@/lib/analytics"; const MUTED_STORAGE_KEY = "infiplot:muted"; -// ── Client-side engine config builder ────────────────────────────────── -// Reads model credentials from localStorage and assembles the EngineConfig -// that the engine expects. Called at the point of use (inside async handlers) -// so mid-session settings changes are picked up immediately. -function buildEngineConfig(): EngineConfig { - const modelCfg = readStoredModelConfig(); - const ttsCfg = loadClientTtsConfig(); - return resolveEngineConfig(modelCfg, ttsCfg); -} - - // Mobile-portrait users get a 9:16 scene image painted for them; everyone else // (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a // touch device (coarse pointer) held upright counts as "portrait" — a mouse @@ -379,8 +366,7 @@ function prefetchScenePath( const specSession = buildSpeculativeSession(baseSession, steps); const abort = new AbortController(); const promise = (async () => { - const config = buildEngineConfig(); - const data = await requestScene(config, { session: specSession, clientTts }); + const data = await requestScene({ session: specSession, clientTts }); if (abort.signal.aborted) throw new Error("aborted"); // Record this resolved alternate for the gallery export. Key is @@ -1186,8 +1172,7 @@ function PlayInner() { }, ) : (async () => { - const config = buildEngineConfig(); - const data = await startSession(config, { + const data = await startSession({ ...livePayload!, clientTts: !!byoTtsRef.current, }); @@ -1569,8 +1554,7 @@ function PlayInner() { clearPool(poolRef.current); const promise = (async () => { - const config = buildEngineConfig(); - const data = await requestScene(config, { + const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); @@ -1592,8 +1576,7 @@ function PlayInner() { setPhase("vision-thinking"); try { - const config = buildEngineConfig(); - const decision = await classifyFreeform(config, { + const decision = await classifyFreeform({ session, freeformText: text, }); @@ -1601,14 +1584,11 @@ function PlayInner() { if (decision.classify === "insert-beat") { // Interactive beat: NPC responds to the player's action, scene stays setPhase("inserting-beat"); - const { partial, characters: insertChars } = await requestInsertBeat( - config, - { - session, - freeformAction: decision.freeformAction, - clientTts: !!byoTtsRef.current, - }, - ); + const { partial, characters: insertChars } = await requestInsertBeat({ + session, + freeformAction: decision.freeformAction, + clientTts: !!byoTtsRef.current, + }); const fromBeatId = currentBeatRef.current?.id ?? currentScene.entryBeatId; @@ -1671,8 +1651,7 @@ function PlayInner() { }; const promise = (async () => { - const config = buildEngineConfig(); - const data = await requestScene(config, { + const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); @@ -1695,8 +1674,7 @@ function PlayInner() { try { const annotatedImageBase64 = await annotateClick(imageUrl, click); - const config = buildEngineConfig(); - const decision = await visionDecide(config, { + const decision = await visionDecide({ session, annotatedImageBase64, }); @@ -1704,14 +1682,11 @@ function PlayInner() { if (decision.classify === "insert-beat") { setPhase("inserting-beat"); - const { partial, characters: insertChars } = await requestInsertBeat( - config, - { - session, - freeformAction: decision.intent.freeformAction, - clientTts: !!byoTtsRef.current, - }, - ); + const { partial, characters: insertChars } = await requestInsertBeat({ + session, + freeformAction: decision.intent.freeformAction, + clientTts: !!byoTtsRef.current, + }); const fromBeatId = currentBeatRef.current?.id ?? currentScene.entryBeatId; @@ -1776,8 +1751,7 @@ function PlayInner() { clearPool(poolRef.current); const promise = (async () => { - const config = buildEngineConfig(); - const data = await requestScene(config, { + const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); diff --git a/lib/engineClient.ts b/lib/engineClient.ts new file mode 100644 index 0000000..066e342 --- /dev/null +++ b/lib/engineClient.ts @@ -0,0 +1,101 @@ +import { + startSession as startSessionClient, + requestScene as requestSceneClient, + visionDecide as visionDecideClient, + classifyFreeform as classifyFreeformClient, + requestInsertBeat as requestInsertBeatClient, +} from "@infiplot/engine"; +import { + readStoredModelConfig, + resolveEngineConfig, +} from "@/lib/clientModelConfig"; +import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; +import type { + FreeformClassifyRequest, + FreeformClassifyResponse, + EngineConfig, + InsertBeatRequest, + InsertBeatResponse, + SceneRequest, + SceneResponse, + StartRequest, + StartResponse, + VisionRequest, + VisionResponse, +} from "@infiplot/types"; + +function getClientConfig(): EngineConfig | null { + const modelCfg = readStoredModelConfig(); + const ttsCfg = loadClientTtsConfig(); + if (!modelCfg) return null; + return resolveEngineConfig(modelCfg, ttsCfg); +} + +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const data = (await res.json()) as { error?: string }; + if (data.error) message = data.error; + } catch { + // ignore parse failure, keep HTTP status message + } + throw new Error(message); + } + return res.json() as Promise; +} + +// ── Unified entry points ─────────────────────────────────────────────── +// When the browser has a BYO model config in localStorage, these call the +// client-side engine directly (talking to providers from the browser). +// Otherwise they fall back to the server-side API routes, which read +// environment variables — useful for Vercel deploys that already supply keys. + +export async function startSession(req: StartRequest): Promise { + const config = getClientConfig(); + if (config) { + return startSessionClient(config, req); + } + return postJson("/api/start", req); +} + +export async function requestScene(req: SceneRequest): Promise { + const config = getClientConfig(); + if (config) { + return requestSceneClient(config, req); + } + return postJson("/api/scene", req); +} + +export async function visionDecide(req: VisionRequest): Promise { + const config = getClientConfig(); + if (config) { + return visionDecideClient(config, req); + } + return postJson("/api/vision", req); +} + +export async function classifyFreeform( + req: FreeformClassifyRequest, +): Promise { + const config = getClientConfig(); + if (config) { + return classifyFreeformClient(config, req); + } + return postJson("/api/classify-freeform", req); +} + +export async function requestInsertBeat( + req: InsertBeatRequest, +): Promise { + const config = getClientConfig(); + if (config) { + return requestInsertBeatClient(config, req); + } + return postJson("/api/insert-beat", req); +}