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:
baizhi958216
2026-06-11 12:09:02 +08:00
parent 0f8e641c4c
commit 6cd7d88326
3 changed files with 142 additions and 55 deletions
+18 -44
View File
@@ -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,
});