feat(web): fallback to server API routes when no client-side model config is set
When a user has not configured their own model keys in localStorage, engine calls now automatically route through /api/* server routes instead of throwing "模型配置未设置". This lets Vercel deploys with server-side environment variables work out of the box. - Add lib/engineClient.ts as a unified client-side routing layer: checks localStorage for BYO config, falls back to POST /api/start, /api/scene, /api/vision, /api/classify-freeform, /api/insert-beat - Update app/play/page.tsx to use engineClient instead of direct engine imports; remove buildEngineConfig() - Update app/page.tsx style-image parsing to also fall back to /api/parse-style-image when no local model config exists Signed-off-by: zhi <zhi@peropero.net>
This commit is contained in:
+23
-11
@@ -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);
|
||||
|
||||
+18
-44
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<T>(path: string, body: unknown): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
// ── 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<StartResponse> {
|
||||
const config = getClientConfig();
|
||||
if (config) {
|
||||
return startSessionClient(config, req);
|
||||
}
|
||||
return postJson<StartResponse>("/api/start", req);
|
||||
}
|
||||
|
||||
export async function requestScene(req: SceneRequest): Promise<SceneResponse> {
|
||||
const config = getClientConfig();
|
||||
if (config) {
|
||||
return requestSceneClient(config, req);
|
||||
}
|
||||
return postJson<SceneResponse>("/api/scene", req);
|
||||
}
|
||||
|
||||
export async function visionDecide(req: VisionRequest): Promise<VisionResponse> {
|
||||
const config = getClientConfig();
|
||||
if (config) {
|
||||
return visionDecideClient(config, req);
|
||||
}
|
||||
return postJson<VisionResponse>("/api/vision", req);
|
||||
}
|
||||
|
||||
export async function classifyFreeform(
|
||||
req: FreeformClassifyRequest,
|
||||
): Promise<FreeformClassifyResponse> {
|
||||
const config = getClientConfig();
|
||||
if (config) {
|
||||
return classifyFreeformClient(config, req);
|
||||
}
|
||||
return postJson<FreeformClassifyResponse>("/api/classify-freeform", req);
|
||||
}
|
||||
|
||||
export async function requestInsertBeat(
|
||||
req: InsertBeatRequest,
|
||||
): Promise<InsertBeatResponse> {
|
||||
const config = getClientConfig();
|
||||
if (config) {
|
||||
return requestInsertBeatClient(config, req);
|
||||
}
|
||||
return postJson<InsertBeatResponse>("/api/insert-beat", req);
|
||||
}
|
||||
Reference in New Issue
Block a user