Merge pull request #64 from zonghaoyuan/refactor/settings-modal

feat: add client-side model configuration and server fallback
This commit is contained in:
baizhi958216
2026-06-12 22:09:43 +08:00
committed by GitHub
18 changed files with 1167 additions and 780 deletions
+24
View File
@@ -88,6 +88,30 @@
.vn-scrollbar::-webkit-scrollbar-corner { .vn-scrollbar::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
/* 极细滚动条 · 无轨道背景 */
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(195, 155, 75, 0.5) transparent;
}
.thin-scrollbar::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(195, 155, 75, 0.45);
border-radius: 999px;
}
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(220, 180, 95, 0.7);
}
} }
@keyframes infiplot-ripple { @keyframes infiplot-ripple {
+38 -14
View File
@@ -12,6 +12,9 @@ import {
} from "@/lib/options"; } from "@/lib/options";
import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
import { analyzeImageDataUrl } from "@infiplot/ai-client";
import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig";
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
/* ============================================================================ /* ============================================================================
@@ -976,17 +979,33 @@ function StyleModal({
setParsing(true); setParsing(true);
try { try {
const resized = await resizeImageToDataUrl(file); const resized = await resizeImageToDataUrl(file);
const res = await fetch("/api/parse-style-image", { const modelCfg = readStoredModelConfig();
method: "POST", let stylePrompt: string;
headers: { "Content-Type": "application/json" }, if (modelCfg) {
body: JSON.stringify({ imageDataUrl: resized }), const config = resolveEngineConfig(modelCfg, null);
}); const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT);
if (!res.ok) { let parsed: { stylePrompt?: string };
const j = (await res.json().catch(() => ({}))) as { error?: string }; try {
throw new Error(j.error ?? `${res.status}`); 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 data = (await res.json()) as { stylePrompt: string }; if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
setDraft(data.stylePrompt); setDraft(stylePrompt);
setCustomStyleRefImage(resized); setCustomStyleRefImage(resized);
track("style_image_upload", { ok: true }); track("style_image_upload", { ok: true });
} catch (err) { } catch (err) {
@@ -1256,8 +1275,9 @@ export default function HomePage() {
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
const [hintClosed, setHintClosed] = useState(false); const [hintClosed, setHintClosed] = useState(false);
// 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。 // 统一设置弹窗(通用 + 模型):可选增强,数据只存浏览器。
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState<"general" | "models">("general");
const [ttsConfigured, setTtsConfigured] = useState(false); const [ttsConfigured, setTtsConfigured] = useState(false);
const [playerName, setPlayerName] = useState(""); const [playerName, setPlayerName] = useState("");
const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [visionClickEnabled, setVisionClickEnabled] = useState(true);
@@ -1477,7 +1497,10 @@ export default function HomePage() {
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<button <button
type="button" type="button"
onClick={() => setSettingsOpen(true)} onClick={() => {
setSettingsTab("general");
setSettingsOpen(true);
}}
aria-label="设置" aria-label="设置"
title="设置" title="设置"
className="text-base text-clay-500 hover:text-ember-500 transition-colors" className="text-base text-clay-500 hover:text-ember-500 transition-colors"
@@ -1614,7 +1637,7 @@ export default function HomePage() {
<p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500"> <p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500">
{" "} {" "}
<em className="not-italic text-ember-500">InfiPlot</em> <em className="not-italic text-ember-500">InfiPlot</em>
<span className="text-ember-500"></span> <span className="inline-flex items-center gap-1 text-ember-500"><i className="fa-solid fa-gear text-[10px]" /></span>
API Key API Key
</p> </p>
<button <button
@@ -1775,12 +1798,13 @@ export default function HomePage() {
)} )}
{settingsOpen && ( {settingsOpen && (
<SettingsModal <SettingsModal
initialTab={settingsTab}
initialVisionClickEnabled={visionClickEnabled} initialVisionClickEnabled={visionClickEnabled}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
onSaved={(settings) => { onSaved={(settings) => {
setTtsConfigured(settings.ttsConfigured);
setPlayerName(settings.playerName); setPlayerName(settings.playerName);
setVisionClickEnabled(settings.visionClickEnabled); setVisionClickEnabled(settings.visionClickEnabled);
setTtsConfigured(settings.ttsConfigured);
if (settings.ttsConfigured && voiceRow >= 0) { if (settings.ttsConfigured && voiceRow >= 0) {
const onIdx = OPTS[voiceRow]!.items.indexOf("开启"); const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
if (onIdx >= 0) if (onIdx >= 0)
+50 -267
View File
@@ -29,13 +29,18 @@ import {
storyShareFilename, storyShareFilename,
} from "@/lib/storyShare"; } from "@/lib/storyShare";
import { provisionVoice, synthesize } from "@infiplot/tts-client"; import { provisionVoice, synthesize } from "@infiplot/tts-client";
import {
startSession,
requestScene,
visionDecide,
classifyFreeform,
requestInsertBeat,
} from "@/lib/engineClient";
import type { import type {
Beat, Beat,
BeatChoice, BeatChoice,
Character, Character,
CharacterVoice, CharacterVoice,
FreeformClassifyResponse,
InsertBeatResponse,
Orientation, Orientation,
Scene, Scene,
SceneExit, SceneExit,
@@ -43,45 +48,11 @@ import type {
Session, Session,
StartResponse, StartResponse,
TtsConfig, TtsConfig,
VisionResponse,
} from "@infiplot/types"; } from "@infiplot/types";
import { track } from "@/lib/analytics"; import { track } from "@/lib/analytics";
const MUTED_STORAGE_KEY = "infiplot:muted"; const MUTED_STORAGE_KEY = "infiplot:muted";
// ── FOT reduction helpers ──────────────────────────────────────────────
// Strip bulky voice.referenceAudioBase64 from the session before sending it to
// the server. The engine only needs character names + visualDescriptions for
// scene generation; voice data is only used by /api/beat-audio (which receives
// the voice directly, not via session). The client retains voices locally and
// re-merges them from the response via mergeCharactersPreserveVoice.
function stripVoicesForTransport(session: Session): Session {
return {
...session,
characters: session.characters.map((c) => ({ ...c, voice: undefined })),
};
}
// Merge server-returned characters with locally-held voices. The server strips
// voice from already-known characters (P0), so only NEW characters carry voice.
// For existing characters, re-attach the voice the client already holds.
function mergeCharactersPreserveVoice(
local: Character[],
remote: Character[],
): Character[] {
const localByName = new Map(local.map((c) => [c.name, c]));
return remote.map((c) => {
const prev = localByName.get(c.name);
if (!prev) return c;
return { ...c, voice: c.voice ?? prev.voice };
});
}
// Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a
// non-BYO, unmuted player. Set high enough that one transient miss won't trip
// it, low enough to catch a scene that's clearly being rate-limited.
const SILENCE_NUDGE_THRESHOLD = 3;
// Mobile-portrait users get a 9:16 scene image painted for them; everyone else // 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 // (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a
// touch device (coarse pointer) held upright counts as "portrait" — a mouse // touch device (coarse pointer) held upright counts as "portrait" — a mouse
@@ -396,19 +367,8 @@ function prefetchScenePath(
const specSession = buildSpeculativeSession(baseSession, steps); const specSession = buildSpeculativeSession(baseSession, steps);
const abort = new AbortController(); const abort = new AbortController();
const promise = (async () => { const promise = (async () => {
const res = await fetch("/api/scene", { const data = await requestScene({ session: specSession, clientTts });
method: "POST", if (abort.signal.aborted) throw new Error("aborted");
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ session: stripVoicesForTransport(specSession), clientTts }),
signal: abort.signal,
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
const data = (await res.json()) as SceneResponse;
// Record this resolved alternate for the gallery export. Key is // Record this resolved alternate for the gallery export. Key is
// (parent scene id at the choice point) : (choice id). Includes the // (parent scene id at the choice point) : (choice id). Includes the
@@ -426,12 +386,6 @@ function prefetchScenePath(
// transition path awaits the same cached promise via getOrCreateBlobUrl. // transition path awaits the same cached promise via getOrCreateBlobUrl.
void getOrCreateBlobUrl(data.imageUrl); void getOrCreateBlobUrl(data.imageUrl);
// Re-attach locally-held voices the server stripped from known characters.
data.characters = mergeCharactersPreserveVoice(
baseSession.characters,
data.characters,
);
// Recursive: if the resulting scene has exactly one change-scene exit, // Recursive: if the resulting scene has exactly one change-scene exit,
// it is a must-pass node — prefetch its child too. // it is a must-pass node — prefetch its child too.
if (depth + 1 < PREFETCH_MAX_DEPTH) { if (depth + 1 < PREFETCH_MAX_DEPTH) {
@@ -580,12 +534,6 @@ function PlayInner() {
const [orientation, setOrientation] = useState<Orientation>("landscape"); const [orientation, setOrientation] = useState<Orientation>("landscape");
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null); const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio). // Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
// Climbs when the shared server key is rate-limited by MiMo — the exact pain
// BYO fixes — so the play page can nudge non-BYO users to add their own key.
// Reset to 0 on any successful synth. Only the server path touches it.
const [silenceStrikes, setSilenceStrikes] = useState(0);
// Once the player dismisses the silence nudge, keep it gone for this session.
const [nudgeDismissed, setNudgeDismissed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [visionClickEnabled, setVisionClickEnabled] = useState(true);
// Top-of-screen progress toast for the gallery / story export pipeline. // Top-of-screen progress toast for the gallery / story export pipeline.
@@ -752,8 +700,7 @@ function PlayInner() {
let audioUrl: string | null = null; let audioUrl: string | null = null;
if (byo) { if (byo) {
// Client-direct: provision (once per speaker, cached) + synth against // Client-direct: provision (once per speaker, cached) + synth against
// Xiaomi with the user's own key — no /api/beat-audio round-trip and // Xiaomi with the user's own key — the key never touches our server.
// the key never touches our server.
const voice = await resolveByoVoice( const voice = await resolveByoVoice(
provisionedVoicesRef.current, provisionedVoicesRef.current,
byo, byo,
@@ -769,28 +716,8 @@ function PlayInner() {
); );
audioUrl = `data:${out.mimeType};base64,${out.audioBase64}`; audioUrl = `data:${out.mimeType};base64,${out.audioBase64}`;
} else { } else {
const res = await fetch("/api/beat-audio", { // No TTS configured — silent.
method: "POST", return;
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
beat: { id: beat.id, line: beat.line, lineDelivery: beat.lineDelivery },
voice: speaker.voice,
}),
signal: abort.signal,
});
if (res.status === 204) {
setSilenceStrikes((n) => Math.min(n + 1, 99));
return;
}
if (!res.ok) {
setSilenceStrikes((n) => Math.min(n + 1, 99));
return;
}
const blob = await res.blob();
audioUrl = URL.createObjectURL(blob);
setSilenceStrikes(0);
} }
// Skip the state write if we've been aborted between the await and // Skip the state write if we've been aborted between the await and
// here — beat ids are scene-local, so a late arrival from a prior // here — beat ids are scene-local, so a late arrival from a prior
@@ -798,8 +725,6 @@ function PlayInner() {
// same id. // same id.
if (audioUrl && !abort.signal.aborted) { if (audioUrl && !abort.signal.aborted) {
setBeatAudioMap((m) => ({ ...m, [beat.id]: audioUrl })); setBeatAudioMap((m) => ({ ...m, [beat.id]: audioUrl }));
} else if (audioUrl?.startsWith("blob:")) {
URL.revokeObjectURL(audioUrl);
} }
} catch { } catch {
// aborted / network / Xiaomi rate-limit — silent fallback (no audio) // aborted / network / Xiaomi rate-limit — silent fallback (no audio)
@@ -888,26 +813,12 @@ function PlayInner() {
}, [muted, prefetchSceneAudio]); }, [muted, prefetchSceneAudio]);
const handleSettingsSaved = useCallback( const handleSettingsSaved = useCallback(
(settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => { (settings: { playerName: string; visionClickEnabled: boolean; ttsConfigured: boolean }) => {
setVisionClickEnabled(settings.visionClickEnabled); setVisionClickEnabled(settings.visionClickEnabled);
const nextPlayerName = settings.playerName || undefined; const nextPlayerName = settings.playerName || undefined;
setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev); setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev);
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
byoTtsRef.current = cfg;
setByoTtsConfig(cfg);
if (cfg) {
setSilenceStrikes(0);
cancelBeatAudioFetches();
setBeatAudioMap((prev) => {
for (const url of Object.values(prev)) {
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
}
return {};
});
prefetchSceneAudio();
}
}, },
[prefetchSceneAudio], [],
); );
function detachRecordedReplay(): void { function detachRecordedReplay(): void {
@@ -1389,31 +1300,21 @@ function PlayInner() {
throw new Error(`找不到精选剧情:${cardName}`); throw new Error(`找不到精选剧情:${cardName}`);
}, },
) )
: fetch("/api/start", { : (async () => {
method: "POST", const data = await startSession({
headers: { ...livePayload!,
"Content-Type": "application/json",
},
body: JSON.stringify({
...livePayload,
clientTts: !!byoTtsRef.current, clientTts: !!byoTtsRef.current,
}), });
}).then(async (r) => { // startSession doesn't echo ws/sg back — splice in what we sent.
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
const data = (await r.json()) as StartResponse;
// Live /api/start doesn't echo ws/sg back — splice in what we sent.
// styleReferenceImage is similarly not in StartResponse; tag it on so // styleReferenceImage is similarly not in StartResponse; tag it on so
// the session we build below carries it for every /api/scene call. // the session we build below carries it for every scene call.
return { return {
...data, ...data,
worldSetting: livePayload!.worldSetting, worldSetting: livePayload!.worldSetting,
styleGuide: livePayload!.styleGuide, styleGuide: livePayload!.styleGuide,
styleReferenceImage: livePayload!.styleReferenceImage, styleReferenceImage: livePayload!.styleReferenceImage,
}; };
}); })();
fetchStart fetchStart
.then(async (data) => { .then(async (data) => {
@@ -1559,10 +1460,7 @@ function PlayInner() {
storyStateAfter: result.storyState, storyStateAfter: result.storyState,
}, },
], ],
characters: mergeCharactersPreserveVoice( characters: result.characters,
base.characters,
result.characters,
),
storyState: result.storyState, storyState: result.storyState,
}; };
visitedBeatsRef.current = [result.scene.entryBeatId]; visitedBeatsRef.current = [result.scene.entryBeatId];
@@ -1785,21 +1683,11 @@ function PlayInner() {
clearPool(poolRef.current); clearPool(poolRef.current);
const promise = (async () => { const promise = (async () => {
const res = await fetch("/api/scene", { const data = await requestScene({
method: "POST", session: specSession,
headers: { clientTts: !!byoTtsRef.current,
"Content-Type": "application/json",
},
body: JSON.stringify({
session: stripVoicesForTransport(specSession),
clientTts: !!byoTtsRef.current,
}),
}); });
if (!res.ok) { return data;
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
return (await res.json()) as SceneResponse;
})(); })();
void performSceneTransition(promise, exit, visited, choice.label); void performSceneTransition(promise, exit, visited, choice.label);
@@ -1817,38 +1705,19 @@ function PlayInner() {
setPhase("vision-thinking"); setPhase("vision-thinking");
try { try {
const classifyRes = await fetch("/api/classify-freeform", { const decision = await classifyFreeform({
method: "POST", session,
headers: { "Content-Type": "application/json" }, freeformText: text,
body: JSON.stringify({
session: stripVoicesForTransport(session),
freeformText: text,
}),
}); });
if (!classifyRes.ok) {
const j = (await classifyRes.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? classifyRes.statusText);
}
const decision = (await classifyRes.json()) as FreeformClassifyResponse;
if (decision.classify === "insert-beat") { if (decision.classify === "insert-beat") {
// Interactive beat: NPC responds to the player's action, scene stays // Interactive beat: NPC responds to the player's action, scene stays
setPhase("inserting-beat"); setPhase("inserting-beat");
const insertRes = await fetch("/api/insert-beat", { const { partial, characters: insertChars } = await requestInsertBeat({
method: "POST", session,
headers: { "Content-Type": "application/json" }, freeformAction: decision.freeformAction,
body: JSON.stringify({ clientTts: !!byoTtsRef.current,
session: stripVoicesForTransport(session),
freeformAction: decision.freeformAction,
clientTts: !!byoTtsRef.current,
}),
}); });
if (!insertRes.ok) {
const j = (await insertRes.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? insertRes.statusText);
}
const { partial, characters: insertChars } =
(await insertRes.json()) as InsertBeatResponse;
const fromBeatId = const fromBeatId =
currentBeatRef.current?.id ?? currentScene.entryBeatId; currentBeatRef.current?.id ?? currentScene.entryBeatId;
@@ -1875,10 +1744,7 @@ function PlayInner() {
history: session.history.map((h, i, arr) => history: session.history.map((h, i, arr) =>
i === arr.length - 1 ? { ...h, scene: patched, visitedBeatIds: nextVisited } : h, i === arr.length - 1 ? { ...h, scene: patched, visitedBeatIds: nextVisited } : h,
), ),
characters: mergeCharactersPreserveVoice( characters: insertChars,
session.characters,
insertChars,
),
}; };
setSession(nextSession); setSession(nextSession);
setCurrentScene(patched); setCurrentScene(patched);
@@ -1914,19 +1780,11 @@ function PlayInner() {
}; };
const promise = (async () => { const promise = (async () => {
const res = await fetch("/api/scene", { const data = await requestScene({
method: "POST", session: specSession,
headers: { "Content-Type": "application/json" }, clientTts: !!byoTtsRef.current,
body: JSON.stringify({
session: stripVoicesForTransport(specSession),
clientTts: !!byoTtsRef.current,
}),
}); });
if (!res.ok) { return data;
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
return (await res.json()) as SceneResponse;
})(); })();
setPendingClick(null); setPendingClick(null);
@@ -1945,43 +1803,19 @@ function PlayInner() {
try { try {
const annotatedImageBase64 = await annotateClick(imageUrl, click); const annotatedImageBase64 = await annotateClick(imageUrl, click);
const visionRes = await fetch("/api/vision", { const decision = await visionDecide({
method: "POST", session,
headers: { annotatedImageBase64,
"Content-Type": "application/json",
},
body: JSON.stringify({ session: stripVoicesForTransport(session), annotatedImageBase64 }),
}); });
if (!visionRes.ok) {
const j = (await visionRes.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(j.error ?? visionRes.statusText);
}
const decision = (await visionRes.json()) as VisionResponse;
track("vision_click", { result: decision.classify }); track("vision_click", { result: decision.classify });
if (decision.classify === "insert-beat") { if (decision.classify === "insert-beat") {
setPhase("inserting-beat"); setPhase("inserting-beat");
const insertRes = await fetch("/api/insert-beat", { const { partial, characters: insertChars } = await requestInsertBeat({
method: "POST", session,
headers: { freeformAction: decision.intent.freeformAction,
"Content-Type": "application/json", clientTts: !!byoTtsRef.current,
},
body: JSON.stringify({
session: stripVoicesForTransport(session),
freeformAction: decision.intent.freeformAction,
clientTts: !!byoTtsRef.current,
}),
}); });
if (!insertRes.ok) {
const j = (await insertRes.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(j.error ?? insertRes.statusText);
}
const { partial, characters: insertChars } =
(await insertRes.json()) as InsertBeatResponse;
const fromBeatId = const fromBeatId =
currentBeatRef.current?.id ?? currentScene.entryBeatId; currentBeatRef.current?.id ?? currentScene.entryBeatId;
@@ -2007,10 +1841,7 @@ function PlayInner() {
history: session.history.map((h, i, arr) => history: session.history.map((h, i, arr) =>
i === arr.length - 1 ? { ...h, scene: patched } : h, i === arr.length - 1 ? { ...h, scene: patched } : h,
), ),
characters: mergeCharactersPreserveVoice( characters: insertChars,
session.characters,
insertChars,
),
}; };
setSession(nextSession); setSession(nextSession);
setCurrentScene(patched); setCurrentScene(patched);
@@ -2049,23 +1880,11 @@ function PlayInner() {
clearPool(poolRef.current); clearPool(poolRef.current);
const promise = (async () => { const promise = (async () => {
const res = await fetch("/api/scene", { const data = await requestScene({
method: "POST", session: specSession,
headers: { clientTts: !!byoTtsRef.current,
"Content-Type": "application/json",
},
body: JSON.stringify({
session: stripVoicesForTransport(specSession),
clientTts: !!byoTtsRef.current,
}),
}); });
if (!res.ok) { return data;
const j = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(j.error ?? res.statusText);
}
return (await res.json()) as SceneResponse;
})(); })();
await performSceneTransition( await performSceneTransition(
@@ -2183,16 +2002,6 @@ function PlayInner() {
const sceneCount = session?.history.length ?? 0; const sceneCount = session?.history.length ?? 0;
const beatCount = visitedBeatsRef.current.length; const beatCount = visitedBeatsRef.current.length;
// Surface the BYO-key nudge only to an unmuted, non-BYO player whose last few
// beats came back silent (shared key rate-limited) — the exact pain BYO fixes.
// Dismissible for the session.
const showSilenceNudge =
phase === "ready" &&
!muted &&
!byoTtsConfig &&
!nudgeDismissed &&
silenceStrikes >= SILENCE_NUDGE_THRESHOLD;
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{exportProgress && ( {exportProgress && (
@@ -2298,32 +2107,6 @@ function PlayInner() {
/> />
{muted ? "静 · 音" : "有 · 声"} {muted ? "静 · 音" : "有 · 声"}
</button> </button>
{/* Silence nudge — a compact pill right beside the mute toggle.
Clicking opens the BYO-key modal in place (no trip to the
homepage). The × dismisses it for the session. */}
{showSilenceNudge && (
<span className="flex items-center gap-1 animate-fade-in">
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
title="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
>
<i className="fa-solid fa-volume-xmark text-[9px]" />
Key
</button>
<button
type="button"
onClick={() => setNudgeDismissed(true)}
aria-label="关闭提示"
title="关闭"
className="text-clay-400 hover:text-clay-700 transition-colors"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
)}
</> </>
} }
/> />
+504 -222
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import type { ProviderProtocol } from "@infiplot/types";
import {
clearStoredModelConfig,
readStoredModelConfig,
writeStoredModelConfig,
} from "@/lib/clientModelConfig";
import { import {
clearStoredTtsConfig, clearStoredTtsConfig,
readStoredTtsConfig, readStoredTtsConfig,
@@ -44,17 +50,81 @@ export function readStoredVisionClick(): boolean {
} }
} }
const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [
{ value: "", label: "自动推断(推荐)" },
{ value: "openai_compatible", label: "OpenAI Compatible" },
{ value: "runware", label: "Runware" },
];
type ModelGroup = {
key: "text" | "image" | "vision";
label: string;
icon: string;
baseUrl: string;
apiKey: string;
model: string;
provider: string;
};
type TabKey = "general" | "models";
export function SettingsModal({ export function SettingsModal({
initialTab = "general",
initialVisionClickEnabled = true, initialVisionClickEnabled = true,
onClose, onClose,
onSaved, onSaved,
footerNote, footerNote,
}: { }: {
initialTab?: TabKey;
initialVisionClickEnabled?: boolean; initialVisionClickEnabled?: boolean;
onClose: () => void; onClose: () => void;
onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => void; onSaved: (settings: {
playerName: string;
visionClickEnabled: boolean;
ttsConfigured: boolean;
}) => void;
footerNote?: ReactNode; footerNote?: ReactNode;
}) { }) {
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
// ── General tab state ──
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
// ── Models tab state ──
const initial = readStoredModelConfig();
const [groups, setGroups] = useState<ModelGroup[]>([
{
key: "text",
label: "文本模型",
icon: "fa-solid fa-pen-nib",
baseUrl: initial?.textBaseUrl ?? "",
apiKey: initial?.textApiKey ?? "",
model: initial?.textModel ?? "",
provider: initial?.textProvider ?? "",
},
{
key: "image",
label: "绘图模型",
icon: "fa-solid fa-palette",
baseUrl: initial?.imageBaseUrl ?? "",
apiKey: initial?.imageApiKey ?? "",
model: initial?.imageModel ?? "",
provider: initial?.imageProvider ?? "",
},
{
key: "vision",
label: "识图模型",
icon: "fa-solid fa-eye",
baseUrl: initial?.visionBaseUrl ?? "",
apiKey: initial?.visionApiKey ?? "",
model: initial?.visionModel ?? "",
provider: initial?.visionProvider ?? "",
},
]);
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
// TTS state
const [initialTts] = useState(() => readStoredTtsConfig()); const [initialTts] = useState(() => readStoredTtsConfig());
const initialKind = findTtsPreset(initialTts?.presetId)?.kind ?? "payg"; const initialKind = findTtsPreset(initialTts?.presetId)?.kind ?? "payg";
const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind); const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind);
@@ -63,61 +133,130 @@ export function SettingsModal({
? (initialTts?.presetId ?? TTS_REGION_PRESETS[0]!.id) ? (initialTts?.presetId ?? TTS_REGION_PRESETS[0]!.id)
: TTS_REGION_PRESETS[0]!.id, : TTS_REGION_PRESETS[0]!.id,
); );
const [apiKey, setApiKey] = useState<string>(initialTts?.apiKey ?? ""); const [ttsApiKey, setTtsApiKey] = useState<string>(initialTts?.apiKey ?? "");
const [showKey, setShowKey] = useState(false); const [showTtsKey, setShowTtsKey] = useState(false);
const ttsAlreadyConfigured = initialTts != null;
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
const [shown, setShown] = useState(false);
const expectedPrefix = keyType === "payg" ? "sk-" : "tp-"; const expectedPrefix = keyType === "payg" ? "sk-" : "tp-";
const prefixMismatch = const prefixMismatch =
apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix); ttsApiKey.trim().length > 0 && !ttsApiKey.trim().startsWith(expectedPrefix);
// ── Animation ──
const [shown, setShown] = useState(false);
useEffect(() => { useEffect(() => {
const id = requestAnimationFrame(() => setShown(true)); const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, []); }, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const close = () => { const close = () => {
setShown(false); setShown(false);
setTimeout(onClose, 280); setTimeout(onClose, 280);
}; };
const save = () => { // ── General actions ──
const saveGeneral = () => {
const name = playerName.trim(); const name = playerName.trim();
writeStoredPlayerName(name); writeStoredPlayerName(name);
try { try {
localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0"); localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0");
} catch { /* ignore */ } } catch { /* ignore */ }
};
const key = apiKey.trim(); const clearGeneral = () => {
let ttsConfigured = false; writeStoredPlayerName("");
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ }
setPlayerName("");
setVisionClick(true);
};
const hasGeneralSetting = readStoredPlayerName().length > 0;
// ── Models actions ──
const updateGroup = (
key: string,
field: keyof Omit<ModelGroup, "key" | "label" | "icon">,
value: string,
) => {
setGroups((prev) =>
prev.map((g) => (g.key === key ? { ...g, [field]: value } : g)),
);
};
const saveModels = () => {
const [text, image, vision] = groups;
if (text && image && vision) {
writeStoredModelConfig({
textBaseUrl: text.baseUrl,
textApiKey: text.apiKey,
textModel: text.model,
textProvider: (text.provider as ProviderProtocol) || undefined,
imageBaseUrl: image.baseUrl,
imageApiKey: image.apiKey,
imageModel: image.model,
imageProvider: (image.provider as ProviderProtocol) || undefined,
visionBaseUrl: vision.baseUrl,
visionApiKey: vision.apiKey,
visionModel: vision.model,
visionProvider: (vision.provider as ProviderProtocol) || undefined,
});
}
const key = ttsApiKey.trim();
if (key) { if (key) {
const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId; const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId;
writeStoredTtsConfig({ presetId, apiKey: key }); writeStoredTtsConfig({ presetId, apiKey: key });
ttsConfigured = true;
} else { } else {
clearStoredTtsConfig(); clearStoredTtsConfig();
ttsConfigured = false;
} }
};
onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick }); const clearModels = () => {
clearStoredModelConfig();
clearStoredTtsConfig();
setGroups((prev) =>
prev.map((g) => ({ ...g, baseUrl: "", apiKey: "", model: "", provider: "" })),
);
setTtsApiKey("");
};
const hasModelSetting =
groups.some((g) => g.baseUrl.trim() && g.apiKey.trim() && g.model.trim()) ||
initialTts != null;
// ── Global save / clear ──
const save = () => {
saveGeneral();
saveModels();
const ttsConfigured = ttsApiKey.trim().length > 0;
onSaved({
playerName: playerName.trim(),
visionClickEnabled: visionClick,
ttsConfigured,
});
close(); close();
}; };
const clearAll = () => { const clearAll = () => {
clearStoredTtsConfig(); clearGeneral();
writeStoredPlayerName(""); clearModels();
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ } onSaved({ playerName: "", visionClickEnabled: true, ttsConfigured: false });
onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: true });
close(); close();
}; };
const hasAnySetting = ttsAlreadyConfigured || readStoredPlayerName().length > 0; const hasAnySetting = hasGeneralSetting || hasModelSetting;
const tabs: { key: TabKey; label: string; icon: string }[] = [
{ key: "general", label: "通用", icon: "fa-solid fa-sliders" },
{ key: "models", label: "模型", icon: "fa-solid fa-microchip" },
];
return ( return (
<div <div
@@ -132,7 +271,7 @@ export function SettingsModal({
<div <div
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
className={ className={
"flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " + "flex w-[640px] max-w-[96vw] max-h-[90vh] 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 ? "opacity-100 scale-100" : "opacity-0 scale-95") (shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
} }
> >
@@ -156,226 +295,369 @@ export function SettingsModal({
</button> </button>
</div> </div>
<div className="flex flex-col gap-0 overflow-y-auto"> {/* Tab bar */}
{/* ── Player Name Section ── */} <div className="flex border-b border-clay-900/8 px-6 md:px-8">
<div className="flex flex-col gap-3 px-6 md:px-8 py-5"> {tabs.map((t) => {
<div className="flex items-center gap-2.5"> const active = activeTab === t.key;
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400"> return (
<i className="fa-solid fa-user-pen text-[11px]" /> <button
</span> key={t.key}
<span className="font-serif text-base text-clay-900"> type="button"
onClick={() => setActiveTab(t.key)}
</span> className={
</div> "flex items-center gap-2 px-4 py-3 text-[13px] font-sans transition-colors border-b-2 -mb-px " +
<input (active
value={playerName} ? "border-ember-500 text-clay-900"
onChange={(e) => setPlayerName(e.target.value)} : "border-transparent text-clay-500 hover:text-clay-700")
type="text" }
maxLength={20} >
autoComplete="off" <i className={`${t.icon} text-[11px]`} />
spellCheck={false} {t.label}
placeholder="不填则使用「你」" </button>
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" );
/> })}
<span className="text-[11px] text-clay-400"> </div>
NPC
</span>
</div>
<div className="border-t border-clay-900/8 mx-6 md:mx-8" /> {/* Content */}
<div className="thin-scrollbar flex flex-col gap-0 overflow-y-auto flex-1">
{/* ── Vision Click Section ── */} {activeTab === "general" && (
<div className="flex flex-col gap-3 px-6 md:px-8 py-5"> <>
<div className="flex items-center gap-2.5"> {/* ── Player Name Section ── */}
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400"> <div className="flex flex-col gap-3 px-6 md:px-8 py-5">
<i className="fa-solid fa-eye text-[11px]" /> <div className="flex items-center gap-2.5">
</span> <span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<span className="font-serif text-base text-clay-900"> <i className="fa-solid fa-user-pen text-[11px]" />
</span>
</span> <span className="font-serif text-base text-clay-900">
</div>
<div className="grid grid-cols-2 gap-2"> </span>
{( </div>
[ <input
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" }, value={playerName}
{ on: false, label: "关闭", icon: "fa-solid fa-ban" }, onChange={(e) => setPlayerName(e.target.value)}
] as const type="text"
).map((t) => { maxLength={20}
const active = visionClick === t.on; autoComplete="off"
return ( spellCheck={false}
<button placeholder="不填则使用「你」"
key={String(t.on)} className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
type="button" />
onClick={() => setVisionClick(t.on)} <span className="text-[11px] text-clay-400">
className={ NPC
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<i className={t.icon + " text-[11px]"} />
{t.label}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
AI
</span>
</div>
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
{/* ── TTS Key Section ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className="fa-solid fa-key text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
Key
</span>
<span className="text-[10px] text-clay-400"></span>
</div>
<p className="text-[12px] leading-relaxed text-clay-500">
<span className="text-clay-800"> MiMo API Key</span>
Key MiMo
TTS
<span className="text-clay-800"></span>
使
</p>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
K e y ·
</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{
kind: "payg",
label: "按量付费 Pay-as-you-go",
sub: "sk- 开头",
},
{
kind: "token-plan",
label: "套餐 Token Plan",
sub: "tp- 开头",
},
] as const
).map((t) => {
const active = keyType === t.kind;
return (
<button
key={t.kind}
type="button"
onClick={() => setKeyType(t.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<span className="text-[13px]">{t.label}</span>
<span className="text-[10px] text-clay-400">
{t.sub}
</span>
</button>
);
})}
</div>
</div>
{keyType === "token-plan" && (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
</span> </span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> </div>
{TTS_REGION_PRESETS.map((p) => {
const active = p.id === regionId; <div className="border-t border-clay-900/8 mx-6 md:mx-8" />
{/* ── Vision Click Section ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className="fa-solid fa-eye text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
] as const
).map((t) => {
const active = visionClick === t.on;
return ( return (
<button <button
key={p.id} key={String(t.on)}
type="button" type="button"
onClick={() => setRegionId(p.id)} onClick={() => setVisionClick(t.on)}
className={ className={
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " + "flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
(active (active
? "border-ember-500 bg-ember-500/5 text-clay-900" ? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100") : "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
} }
> >
{p.label} <i className={t.icon + " text-[11px]"} />
{t.label}
</button> </button>
); );
})} })}
</div> </div>
<span className="text-[11px] text-clay-400"> <span className="text-[11px] text-clay-400">
AI
</span> </span>
</div> </div>
)}
<div className="flex flex-col gap-2"> {footerNote && (
<span className="text-[10px] smallcaps text-clay-500"> <div className="px-6 md:px-8 pb-5">
A P I · K e y <p className="text-[11px] leading-relaxed text-clay-400">
</span> {footerNote}
<div className="relative"> </p>
<input </div>
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
type={showKey ? "text" : "password"}
autoComplete="off"
spellCheck={false}
placeholder={
keyType === "payg"
? "粘贴 sk- 开头的按量 Key"
: "粘贴 tp- 开头的套餐 Key"
}
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<button
type="button"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
</button>
</div>
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg"
? "按量付费 Pay-as-you-go"
: "套餐 Token Plan"}
</span>
)} )}
<a </>
href={TTS_KEY_DOC_URL} )}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
</a>
</div>
{footerNote && ( {activeTab === "models" && (
<p className="text-[11px] leading-relaxed text-clay-400"> <>
{footerNote} {groups.map((g, idx) => (
</p> <div key={g.key}>
)} {idx > 0 && (
</div> <div className="border-t border-clay-900/8 mx-6 md:mx-8" />
)}
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className={`${g.icon} text-[11px]`} />
</span>
<span className="font-serif text-base text-clay-900">
{g.label}
</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
B A S E · U R L
</span>
<input
value={g.baseUrl}
onChange={(e) => updateGroup(g.key, "baseUrl", e.target.value)}
type="text"
autoComplete="off"
spellCheck={false}
placeholder="https://api.example.com/v1"
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
A P I · K e y
</span>
<div className="relative">
<input
value={g.apiKey}
onChange={(e) => updateGroup(g.key, "apiKey", e.target.value)}
type={showKeys[g.key] ? "text" : "password"}
autoComplete="off"
spellCheck={false}
placeholder="sk-..."
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<button
type="button"
onClick={() =>
setShowKeys((prev) => ({
...prev,
[g.key]: !prev[g.key],
}))
}
aria-label={showKeys[g.key] ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
className={`fa-solid ${showKeys[g.key] ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
M o d e l
</span>
<input
value={g.model}
onChange={(e) => updateGroup(g.key, "model", e.target.value)}
type="text"
autoComplete="off"
spellCheck={false}
placeholder="gpt-4o / claude-3-5-sonnet / flux-1-dev ..."
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
P r o v i d e r
</span>
<select
value={g.provider}
onChange={(e) => updateGroup(g.key, "provider", e.target.value)}
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500"
>
{PROVIDER_OPTIONS.map((opt) => (
<option key={opt.value || "auto"} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="text-[11px] text-clay-400">
Base URL
</span>
</div>
</div>
</div>
))}
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
{/* ── TTS Key Section ── */}
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
<i className="fa-solid fa-key text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
Key
</span>
<span className="text-[10px] text-clay-400"></span>
</div>
<p className="text-[12px] leading-relaxed text-clay-500">
<span className="text-clay-800"> MiMo API Key</span>
Key MiMo
TTS
<span className="text-clay-800"></span>
使
</p>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
K e y ·
</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{
kind: "payg",
label: "按量付费 Pay-as-you-go",
sub: "sk- 开头",
},
{
kind: "token-plan",
label: "套餐 Token Plan",
sub: "tp- 开头",
},
] as const
).map((t) => {
const active = keyType === t.kind;
return (
<button
key={t.kind}
type="button"
onClick={() => setKeyType(t.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<span className="text-[13px]">{t.label}</span>
<span className="text-[10px] text-clay-400">
{t.sub}
</span>
</button>
);
})}
</div>
</div>
{keyType === "token-plan" && (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{TTS_REGION_PRESETS.map((p) => {
const active = p.id === regionId;
return (
<button
key={p.id}
type="button"
onClick={() => setRegionId(p.id)}
className={
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
(active
? "border-ember-500 bg-ember-500/5 text-clay-900"
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
{p.label}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
A P I · K e y
</span>
<div className="relative">
<input
value={ttsApiKey}
onChange={(e) => setTtsApiKey(e.target.value)}
type={showTtsKey ? "text" : "password"}
autoComplete="off"
spellCheck={false}
placeholder={
keyType === "payg"
? "粘贴 sk- 开头的按量 Key"
: "粘贴 tp- 开头的套餐 Key"
}
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<button
type="button"
onClick={() => setShowTtsKey((v) => !v)}
aria-label={showTtsKey ? "隐藏" : "显示"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
className={`fa-solid ${showTtsKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
/>
</button>
</div>
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg"
? "按量付费 Pay-as-you-go"
: "套餐 Token Plan"}
</span>
)}
<a
href={TTS_KEY_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
</a>
</div>
</div>
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
<div className="px-6 md:px-8 py-4">
<p className="text-[11px] leading-relaxed text-clay-400">
<i className="fa-solid fa-circle-info mr-1.5" />
API CORSOpenAIAnthropicGeminiRunware
</p>
</div>
</>
)}
</div> </div>
{/* Footer */} {/* Footer */}
+29 -34
View File
@@ -1,29 +1,24 @@
import { generateText } from "ai"; import OpenAI from "openai";
import type { LanguageModelUsage, ModelMessage } from "ai";
import type { ProviderConfig } from "@infiplot/types"; import type { ProviderConfig } from "@infiplot/types";
import { createLanguageModel, resolveProtocol } from "./model"; import { normalizeBaseUrl } from "./normalizeUrl";
export type ChatMessage = { export type ChatMessage = {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
}; };
// AI SDK 6 unifies cache stats across providers into usage.inputTokenDetails,
// so a single shape covers Anthropic, Gemini, and OpenAI-compatible providers.
function summarizeSdkUsage( function summarizeSdkUsage(
tag: string, tag: string,
usage: LanguageModelUsage | undefined, usage: OpenAI.Completions.CompletionUsage | undefined,
): string { ): string {
if (!usage) return `[cache] ${tag} no-usage`; if (!usage) return `[cache] ${tag} no-usage`;
const input = usage.inputTokens ?? 0; const input = usage.prompt_tokens ?? 0;
const output = usage.outputTokens ?? 0; const output = usage.completion_tokens ?? 0;
const read = usage.inputTokenDetails?.cacheReadTokens; const details = (usage as { prompt_tokens_details?: { cached_tokens?: number } }).prompt_tokens_details;
const write = usage.inputTokenDetails?.cacheWriteTokens; const cached = details?.cached_tokens;
if (typeof read === "number" || typeof write === "number") { if (typeof cached === "number") {
const hit = read ?? 0; const rate = input > 0 ? ((cached / input) * 100).toFixed(1) : "n/a";
const create = write ?? 0; return `[cache] ${tag} hit=${cached} input=${input} rate=${rate}% completion=${output}`;
const rate = input > 0 ? ((hit / input) * 100).toFixed(1) : "n/a";
return `[cache] ${tag} hit=${hit} create=${create} input=${input} rate=${rate}% completion=${output}`;
} }
return `[cache] ${tag} input=${input} completion=${output} (provider didn't report cache stats)`; return `[cache] ${tag} input=${input} completion=${output} (provider didn't report cache stats)`;
} }
@@ -36,28 +31,28 @@ export async function chat(
tag?: string; tag?: string;
}, },
): Promise<string> { ): Promise<string> {
const protocol = resolveProtocol(config); const client = new OpenAI({
const model = createLanguageModel(config, protocol); apiKey: config.apiKey,
baseURL: normalizeBaseUrl(config.baseUrl, "openai_compatible"),
const system = messages.find((m) => m.role === "system")?.content; maxRetries: 0,
const convo: ModelMessage[] = messages dangerouslyAllowBrowser: true,
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
const { text, usage } = await generateText({
model,
system,
messages: convo,
temperature: opts?.temperature ?? 0.9,
}); });
console.log(summarizeSdkUsage(opts?.tag ?? "chat", usage)); const completion = await client.chat.completions.create({
model: config.model,
messages: messages.map((m) => ({
role: m.role as "system" | "user" | "assistant",
content: m.content,
})),
temperature: opts?.temperature ?? 0.9,
stream: false,
});
if (typeof text !== "string" || text.length === 0) { const text = completion.choices[0]?.message?.content ?? "";
throw new Error(`Chat API (AI SDK ${protocol}) returned no content.`); console.log(summarizeSdkUsage(opts?.tag ?? "chat", completion.usage ?? undefined));
if (text.length === 0) {
throw new Error(`Chat API returned no content.`);
} }
return text; return text;
} }
+107 -48
View File
@@ -1,6 +1,4 @@
import { generateImage as generateImageSdk } from "ai"; import OpenAI, { toFile, type Uploadable } from "openai";
import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import type { Orientation, ProviderConfig, ProviderProtocol } from "@infiplot/types"; import type { Orientation, ProviderConfig, ProviderProtocol } from "@infiplot/types";
import { fetchWithRetry } from "./fetchWithRetry"; import { fetchWithRetry } from "./fetchWithRetry";
import { normalizeBaseUrl } from "./normalizeUrl"; import { normalizeBaseUrl } from "./normalizeUrl";
@@ -48,8 +46,8 @@ export type GenerateImageOptions = {
/** /**
* Reference images (UUIDs, URLs, or base64) to condition generation on — * Reference images (UUIDs, URLs, or base64) to condition generation on —
* typically character portraits + the prior scene image. Runware caps at 4; * typically character portraits + the prior scene image. Runware caps at 4;
* we silently truncate beyond that. On the OpenAI/Gemini AI SDK paths these * we silently truncate beyond that. On the native OpenAI path these are
* map to `prompt.images` (the SDK accepts public URLs or data URLs). * fetched/decoded and sent to `images.edit`.
*/ */
referenceImages?: string[]; referenceImages?: string[];
/** 01, FLUX needs ≥ 0.8 to actually have an effect. Runware-only. */ /** 01, FLUX needs ≥ 0.8 to actually have an effect. Runware-only. */
@@ -58,7 +56,7 @@ export type GenerateImageOptions = {
* Output aspect, locked per session. "portrait" → 9:16 vertical for mobile; * Output aspect, locked per session. "portrait" → 9:16 vertical for mobile;
* default/"landscape" → 16:9 widescreen. Mapped to each provider's nearest * default/"landscape" → 16:9 widescreen. Mapped to each provider's nearest
* supported size: Runware 1024×1792, OpenAI-compatible REST 1024x1792, * supported size: Runware 1024×1792, OpenAI-compatible REST 1024x1792,
* native gpt-image 1024x1536, Gemini aspectRatio 9:16. * native gpt-image 1024x1536.
*/ */
orientation?: Orientation; orientation?: Orientation;
}; };
@@ -66,8 +64,8 @@ export type GenerateImageOptions = {
export type GenerateImageResult = { export type GenerateImageResult = {
/** /**
* Image the client can render directly. A Runware CDN URL on the Runware * Image the client can render directly. A Runware CDN URL on the Runware
* path; a `data:<mime>;base64,...` URI on the AI SDK paths (OpenAI/Gemini * path; a `data:<mime>;base64,...` URI on the native OpenAI path when GPT
* return raw bytes, not a hosted URL). * image models return raw bytes instead of a hosted URL.
*/ */
imageUrl: string; imageUrl: string;
/** /**
@@ -117,63 +115,124 @@ export async function generateImage(
const protocol = resolveImageProtocol(config); const protocol = resolveImageProtocol(config);
switch (protocol) { switch (protocol) {
case "openai": case "openai":
case "google": return generateImageOpenAi(config, prompt, options);
return generateImageViaAiSdk(config, prompt, options, protocol);
case "runware": case "runware":
return generateImageRunware(config, prompt, options); return generateImageRunware(config, prompt, options);
case "anthropic":
throw new Error(
'IMAGE_PROVIDER "anthropic" does not generate images. Use "openai", "google", "runware", or "openai_compatible".',
);
case "openai_compatible": case "openai_compatible":
default: default:
return generateImageOpenAiCompatible(config, prompt, options); return generateImageOpenAiCompatible(config, prompt, options);
} }
} }
// Native OpenAI (gpt-image) / Gemini (Nano Banana) via the Vercel AI SDK. // Native OpenAI (gpt-image) via the official OpenAI SDK. Unlike the compatible
// Unlike the fetch path, this supports reference-image editing via // fetch path, this supports reference-image editing through `images.edit`.
// `prompt.images`. The SDK returns raw bytes (no hosted URL), so we hand the // GPT image models return raw bytes, so we hand the client a data URI and
// client a data URI and synthesize a UUID; continuity references reuse the // synthesize a UUID; continuity references reuse the data URI rather than a
// data URI rather than a provider UUID. // provider UUID.
async function generateImageViaAiSdk( async function generateImageOpenAi(
config: ProviderConfig, config: ProviderConfig,
prompt: string, prompt: string,
options: GenerateImageOptions | undefined, options?: GenerateImageOptions,
protocol: "openai" | "google",
): Promise<GenerateImageResult> { ): Promise<GenerateImageResult> {
const baseURL = normalizeBaseUrl(config.baseUrl, protocol); const client = new OpenAI({
const imageModel = apiKey: config.apiKey,
protocol === "openai" baseURL: normalizeBaseUrl(config.baseUrl, "openai"),
? createOpenAI({ apiKey: config.apiKey, baseURL }).image(config.model) maxRetries: 2,
: createGoogleGenerativeAI({ apiKey: config.apiKey, baseURL }).image( dangerouslyAllowBrowser: true,
config.model,
);
const refs = (options?.referenceImages ?? []).slice(0, MAX_REFERENCE_IMAGES);
const promptArg =
refs.length > 0 ? { text: prompt, images: refs } : prompt;
// Session-locked aspect. gpt-image takes an explicit `size` (portrait /
// landscape options are 1024x1536 / 1536x1024); Gemini takes an `aspectRatio`.
const portrait = options?.orientation === "portrait";
const { image } = await generateImageSdk({
model: imageModel,
prompt: promptArg,
...(protocol === "openai"
? { size: (portrait ? "1024x1536" : "1536x1024") as `${number}x${number}` }
: { aspectRatio: (portrait ? "9:16" : "16:9") as `${number}:${number}` }),
}); });
const refs = (options?.referenceImages ?? []).slice(0, MAX_REFERENCE_IMAGES);
const portrait = options?.orientation === "portrait";
const size = portrait ? "1024x1536" : "1536x1024";
return { const response =
imageUrl: `data:${image.mediaType};base64,${image.base64}`, refs.length > 0
imageUuid: crypto.randomUUID(), ? await client.images.edit({
}; model: config.model,
prompt,
image: await Promise.all(refs.map(referenceImageToUploadable)),
n: 1,
size,
})
: await client.images.generate({
model: config.model,
prompt,
n: 1,
size,
});
return imageResponseToResult(response);
}
async function referenceImageToUploadable(ref: string): Promise<Uploadable> {
if (ref.startsWith("data:")) {
const response = await fetch(ref);
if (!response.ok) {
throw new Error(`Failed to read data URL reference image.`);
}
const mediaType = response.headers.get("content-type") ?? "image/png";
return toFile(response, `reference.${extensionFromMediaType(mediaType)}`, {
type: mediaType,
});
}
if (/^https?:\/\//i.test(ref)) {
const response = await fetch(ref);
if (!response.ok) {
throw new Error(
`Failed to fetch reference image ${ref}: HTTP ${response.status}`,
);
}
const mediaType = response.headers.get("content-type") ?? "image/png";
return toFile(response, filenameFromUrl(ref, mediaType), {
type: mediaType,
});
}
throw new Error(
`Native OpenAI image editing requires reference image URLs or data URLs; got "${ref.slice(0, 32)}...".`,
);
}
function imageResponseToResult(
response: OpenAI.Images.ImagesResponse,
): GenerateImageResult {
const data = response.data?.[0];
const b64 = data?.b64_json;
if (b64) {
const format = response.output_format ?? "png";
return {
imageUrl: `data:image/${format};base64,${b64}`,
imageUuid: crypto.randomUUID(),
};
}
const imageUrl = data?.url;
if (imageUrl) {
return { imageUrl, imageUuid: crypto.randomUUID() };
}
throw new Error(`No image data in OpenAI response.`);
}
function filenameFromUrl(url: string, mediaType: string): string {
try {
const name = new URL(url).pathname.split("/").filter(Boolean).at(-1);
if (name && /\.[a-z0-9]+$/i.test(name)) return name;
} catch {
// Fall back to the media type below.
}
return `reference.${extensionFromMediaType(mediaType)}`;
}
function extensionFromMediaType(mediaType: string): string {
if (mediaType.includes("jpeg") || mediaType.includes("jpg")) return "jpg";
if (mediaType.includes("webp")) return "webp";
return "png";
} }
// OpenAI-compatible REST route (GPTGod, DALL-E proxies, etc.). Basic // OpenAI-compatible REST route (GPTGod, DALL-E proxies, etc.). Basic
// text-to-image only — no reference images on this path; for editing/anchoring // text-to-image only — no reference images on this path; for editing/anchoring
// set IMAGE_PROVIDER=openai (or google) to take the AI SDK path above. // set IMAGE_PROVIDER=openai to take the native OpenAI path above.
async function generateImageOpenAiCompatible( async function generateImageOpenAiCompatible(
config: ProviderConfig, config: ProviderConfig,
prompt: string, prompt: string,
-23
View File
@@ -1,23 +0,0 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import type { ProviderConfig, ProviderProtocol } from "@infiplot/types";
import { normalizeBaseUrl } from "./normalizeUrl";
export function resolveProtocol(config: ProviderConfig): ProviderProtocol {
return config.provider ?? "openai_compatible";
}
export function createLanguageModel(config: ProviderConfig, protocol: ProviderProtocol) {
const baseURL = normalizeBaseUrl(config.baseUrl, protocol);
switch (protocol) {
case "anthropic":
return createAnthropic({ apiKey: config.apiKey, baseURL })(config.model);
case "google":
return createGoogleGenerativeAI({ apiKey: config.apiKey, baseURL })(config.model);
case "openai_compatible":
case "openai":
default:
return createOpenAI({ apiKey: config.apiKey, baseURL }).chat(config.model);
}
}
-2
View File
@@ -31,8 +31,6 @@ const ENDPOINT_SUFFIX =
const DEFAULT_VERSION_SEGMENT: Record<ProviderProtocol, string | null> = { const DEFAULT_VERSION_SEGMENT: Record<ProviderProtocol, string | null> = {
openai_compatible: "v1", openai_compatible: "v1",
openai: "v1", openai: "v1",
anthropic: "v1",
google: "v1beta",
// Runware posts to the bare base URL with no version-pathed sub-resource, // Runware posts to the bare base URL with no version-pathed sub-resource,
// so never inject a segment for it. // so never inject a segment for it.
runware: null, runware: null,
+27 -30
View File
@@ -1,7 +1,6 @@
import { generateText } from "ai"; import OpenAI from "openai";
import type { ModelMessage } from "ai";
import type { ProviderConfig } from "@infiplot/types"; import type { ProviderConfig } from "@infiplot/types";
import { createLanguageModel, resolveProtocol } from "./model"; import { normalizeBaseUrl } from "./normalizeUrl";
const VISION_TIMEOUT_MS = 60_000; const VISION_TIMEOUT_MS = 60_000;
@@ -22,34 +21,32 @@ export async function analyzeImageDataUrl(
imageDataUrl: string, imageDataUrl: string,
prompt: string, prompt: string,
): Promise<string> { ): Promise<string> {
const protocol = resolveProtocol(config); const client = new OpenAI({
const model = createLanguageModel(config, protocol); apiKey: config.apiKey,
baseURL: normalizeBaseUrl(config.baseUrl, "openai_compatible"),
maxRetries: 0,
timeout: VISION_TIMEOUT_MS,
dangerouslyAllowBrowser: true,
});
const messages: ModelMessage[] = [ const completion = await client.chat.completions.create({
{ model: config.model,
role: "user", messages: [
content: [ {
{ type: "text", text: prompt }, role: "user",
{ type: "image", image: imageDataUrl }, content: [
], { type: "text", text: prompt },
}, { type: "image_url", image_url: { url: imageDataUrl } },
]; ],
},
],
temperature: 0.2,
stream: false,
});
const timeoutCtrl = new AbortController(); const text = completion.choices[0]?.message?.content ?? "";
const timeoutId = setTimeout(() => timeoutCtrl.abort(), VISION_TIMEOUT_MS); if (text.length === 0) {
try { throw new Error(`Vision API returned no content.`);
const { text } = await generateText({
model,
messages,
temperature: 0.2,
maxRetries: 0,
abortSignal: timeoutCtrl.signal,
});
if (typeof text !== "string" || text.length === 0) {
throw new Error(`Vision API (AI SDK ${protocol}) returned no content.`);
}
return text;
} finally {
clearTimeout(timeoutId);
} }
return text;
} }
+160
View File
@@ -0,0 +1,160 @@
import type { EngineConfig, ProviderProtocol } from "@infiplot/types";
// Bring-your-own model keys — stored CLIENT-SIDE ONLY.
//
// When a user supplies their own text/image/vision API credentials, we persist
// them in localStorage and the browser talks to providers directly. The keys
// are therefore never sent to our server: no request body, no header, no log.
const STORAGE_KEY = "infiplot:model";
const VALID_PROTOCOLS: ProviderProtocol[] = [
"openai_compatible",
"openai",
"runware",
];
export type StoredModelConfig = {
textBaseUrl: string;
textApiKey: string;
textModel: string;
textProvider?: ProviderProtocol;
imageBaseUrl: string;
imageApiKey: string;
imageModel: string;
imageProvider?: ProviderProtocol;
visionBaseUrl: string;
visionApiKey: string;
visionModel: string;
visionProvider?: ProviderProtocol;
};
function isValidProtocol(p: string): p is ProviderProtocol {
return (VALID_PROTOCOLS as readonly string[]).includes(p);
}
function readProtocol(raw: unknown): ProviderProtocol | undefined {
if (typeof raw === "string" && isValidProtocol(raw)) return raw;
return undefined;
}
/** Read + validate the persisted model config. Returns null when running on the
* server, when nothing is stored, on parse failure, or when required fields are
* missing. */
export function readStoredModelConfig(): StoredModelConfig | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredModelConfig>;
const textBaseUrl = typeof parsed.textBaseUrl === "string" ? parsed.textBaseUrl.trim() : "";
const textApiKey = typeof parsed.textApiKey === "string" ? parsed.textApiKey.trim() : "";
const textModel = typeof parsed.textModel === "string" ? parsed.textModel.trim() : "";
const imageBaseUrl = typeof parsed.imageBaseUrl === "string" ? parsed.imageBaseUrl.trim() : "";
const imageApiKey = typeof parsed.imageApiKey === "string" ? parsed.imageApiKey.trim() : "";
const imageModel = typeof parsed.imageModel === "string" ? parsed.imageModel.trim() : "";
const visionBaseUrl = typeof parsed.visionBaseUrl === "string" ? parsed.visionBaseUrl.trim() : "";
const visionApiKey = typeof parsed.visionApiKey === "string" ? parsed.visionApiKey.trim() : "";
const visionModel = typeof parsed.visionModel === "string" ? parsed.visionModel.trim() : "";
if (
!textBaseUrl ||
!textApiKey ||
!textModel ||
!imageBaseUrl ||
!imageApiKey ||
!imageModel ||
!visionBaseUrl ||
!visionApiKey ||
!visionModel
) {
return null;
}
return {
textBaseUrl,
textApiKey,
textModel,
textProvider: readProtocol(parsed.textProvider),
imageBaseUrl,
imageApiKey,
imageModel,
imageProvider: readProtocol(parsed.imageProvider),
visionBaseUrl,
visionApiKey,
visionModel,
visionProvider: readProtocol(parsed.visionProvider),
};
} catch {
return null;
}
}
/** Persist the model config. Trims all string fields so trailing whitespace
* from pastes never breaks headers. */
export function writeStoredModelConfig(config: StoredModelConfig): void {
if (typeof window === "undefined") return;
try {
const payload: StoredModelConfig = {
textBaseUrl: config.textBaseUrl.trim(),
textApiKey: config.textApiKey.trim(),
textModel: config.textModel.trim(),
textProvider: config.textProvider,
imageBaseUrl: config.imageBaseUrl.trim(),
imageApiKey: config.imageApiKey.trim(),
imageModel: config.imageModel.trim(),
imageProvider: config.imageProvider,
visionBaseUrl: config.visionBaseUrl.trim(),
visionApiKey: config.visionApiKey.trim(),
visionModel: config.visionModel.trim(),
visionProvider: config.visionProvider,
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
// Storage disabled / quota / private mode — BYO simply stays off.
}
}
export function clearStoredModelConfig(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
/** Build a full EngineConfig from stored model config + optional TTS config.
* Throws when model config is missing so callers can surface a friendly
* "please configure" message. */
export function resolveEngineConfig(
model: StoredModelConfig | null,
tts: import("@infiplot/types").TtsConfig | null,
): EngineConfig {
if (!model) {
throw new Error("模型配置未设置。请返回首页,点击「模型设置」配置 API 参数。");
}
return {
text: {
baseUrl: model.textBaseUrl,
apiKey: model.textApiKey,
model: model.textModel,
provider: model.textProvider,
},
image: {
baseUrl: model.imageBaseUrl,
apiKey: model.imageApiKey,
model: model.imageModel,
provider: model.imageProvider,
},
vision: {
baseUrl: model.visionBaseUrl,
apiKey: model.visionApiKey,
model: model.visionModel,
provider: model.visionProvider,
},
tts: tts ?? undefined,
mockImage: false,
};
}
-2
View File
@@ -6,8 +6,6 @@ import type {
const VALID_PROTOCOLS = [ const VALID_PROTOCOLS = [
"openai_compatible", "openai_compatible",
"anthropic",
"google",
"openai", "openai",
"runware", "runware",
] as const; ] as const;
+77 -3
View File
@@ -3,8 +3,9 @@ import { jsonrepair, JSONRepairError } from "jsonrepair";
// Strict-then-forgiving JSON parser for LLM output. Tries in order: // Strict-then-forgiving JSON parser for LLM output. Tries in order:
// 1. Direct JSON.parse on the trimmed text. // 1. Direct JSON.parse on the trimmed text.
// 2. Extract from ```json``` fenced block. // 2. Extract from ```json``` fenced block.
// 3. Slice between first { and last } and parse. // 3. Parse the first complete JSON value prefix (handles duplicated objects).
// 4. Apply targeted regex pre-repairs (see preRepair) and try jsonrepair. // 4. Slice between first { and last } and parse.
// 5. Apply targeted regex pre-repairs (see preRepair) and try jsonrepair.
// //
// On final failure, logs the first 800 chars of the raw model output so we // On final failure, logs the first 800 chars of the raw model output so we
// can diagnose the actual syntax error without flooding logs or leaking // can diagnose the actual syntax error without flooding logs or leaking
@@ -40,6 +41,67 @@ function preRepair(s: string): string {
return s.replace(/"([^"\n:]+):(\s+)"/g, '"$1":$2"'); return s.replace(/"([^"\n:]+):(\s+)"/g, '"$1":$2"');
} }
function firstJsonStart(s: string): number {
const objectStart = s.indexOf("{");
const arrayStart = s.indexOf("[");
if (objectStart === -1) return arrayStart;
if (arrayStart === -1) return objectStart;
return Math.min(objectStart, arrayStart);
}
function firstCompleteJsonValue(s: string): string | undefined {
const start = firstJsonStart(s);
if (start === -1) return undefined;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let i = start; i < s.length; i += 1) {
const ch = s[i]!;
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
stack.push("}");
continue;
}
if (ch === "[") {
stack.push("]");
continue;
}
if (ch === "}" || ch === "]") {
if (stack.at(-1) !== ch) return undefined;
stack.pop();
if (stack.length === 0) return s.slice(start, i + 1);
}
}
return undefined;
}
function parseFirstCompleteJsonValue<T>(s: string): T | undefined {
const value = firstCompleteJsonValue(s);
if (!value) return undefined;
return JSON.parse(value) as T;
}
export function parseJsonLoose<T>(raw: string): T { export function parseJsonLoose<T>(raw: string): T {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -54,10 +116,22 @@ export function parseJsonLoose<T>(raw: string): T {
try { try {
return JSON.parse(fenced[1]) as T; return JSON.parse(fenced[1]) as T;
} catch { } catch {
// fall through try {
const parsed = parseFirstCompleteJsonValue<T>(fenced[1]);
if (parsed !== undefined) return parsed;
} catch {
// fall through
}
} }
} }
try {
const parsed = parseFirstCompleteJsonValue<T>(trimmed);
if (parsed !== undefined) return parsed;
} catch {
// fall through
}
const first = trimmed.indexOf("{"); const first = trimmed.indexOf("{");
const last = trimmed.lastIndexOf("}"); const last = trimmed.lastIndexOf("}");
const slice = const slice =
+101
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
export const STYLE_EXTRACTION_PROMPT = `You are a senior concept artist helping describe an image's visual style so that a text-to-image diffusion model (FLUX) can reproduce the same aesthetic on different subjects.
Look at the attached image and produce a single English style-prompt string that captures ONLY its visual style — NOT its subject matter. Focus on:
- Medium / technique (e.g., watercolor, oil painting, cel-shaded anime, 3D render, pixel art)
- Line work and rendering (sharp ink outlines, soft shading, painterly brushstrokes, flat colors)
- Color palette and lighting (pastel, saturated, monochrome, warm golden-hour, cool neon, high contrast)
- Mood and atmosphere (dreamy, melancholic, cinematic, nostalgic, gritty)
- Any recognizable artistic influence (Ghibli, Makoto Shinkai, ukiyo-e, vaporwave, cyberpunk anime, etc.)
Do NOT describe the characters, objects, or scene contents. Output exactly one JSON object:
{"stylePrompt": "<comma-separated English visual-style attributes, ~30-60 words>"}`;
+11 -3
View File
@@ -8,6 +8,16 @@ import type { CharacterVoice, TtsConfig } from "@infiplot/types";
// top-N candidates so multiple similar characters don't collapse onto the // top-N candidates so multiple similar characters don't collapse onto the
// same voice. Provision is a pure function — no network call needed. // same voice. Provision is a pure function — no network call needed.
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}
const OUTPUT_FORMAT = "mp3"; const OUTPUT_FORMAT = "mp3";
const OUTPUT_MIME = "audio/mpeg"; const OUTPUT_MIME = "audio/mpeg";
@@ -183,8 +193,6 @@ export async function stepfunSynthesize(
} }
const ab = await res.arrayBuffer(); const ab = await res.arrayBuffer();
// Buffer is fine here — TTS routes run on runtime="nodejs". Falls back to const audioBase64 = arrayBufferToBase64(ab);
// btoa+chunks if we ever target Edge.
const audioBase64 = Buffer.from(ab).toString("base64");
return { audioBase64, mimeType: OUTPUT_MIME }; return { audioBase64, mimeType: OUTPUT_MIME };
} }
+4 -8
View File
@@ -327,19 +327,15 @@ export type VisionClassify = "insert-beat" | "change-scene";
* openai_compatible text / vision / image — OpenAI Chat Completions + * openai_compatible text / vision / image — OpenAI Chat Completions +
* `/images/generations` (self-implemented fetch; the * `/images/generations` (self-implemented fetch; the
* default for text/vision when unset) * default for text/vision when unset)
* anthropic text / vision — native Anthropic Messages (AI SDK) * openai image only — OpenAI gpt-image via the
* google text / vision / image — native Gemini (AI SDK); image * official OpenAI SDK, unlocks reference-image editing
* uses the Nano Banana family * (for text/vision use openai_compatible, which already
* openai image only — OpenAI gpt-image via AI SDK, * speaks OpenAI's format)
* unlocks reference-image editing (for text/vision use
* openai_compatible, which already speaks OpenAI's format)
* runware image only — Runware task-array protocol * runware image only — Runware task-array protocol
* (self-implemented; the default for runware.ai URLs) * (self-implemented; the default for runware.ai URLs)
*/ */
export type ProviderProtocol = export type ProviderProtocol =
| "openai_compatible" | "openai_compatible"
| "anthropic"
| "google"
| "openai" | "openai"
| "runware"; | "runware";
+1 -4
View File
@@ -20,13 +20,10 @@
"deploy:cf": "opennextjs-cloudflare deploy" "deploy:cf": "opennextjs-cloudflare deploy"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.67",
"ai": "^6.0.196",
"jsonrepair": "^3.14.0", "jsonrepair": "^3.14.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"next": "^16.0.0", "next": "^16.0.0",
"openai": "^6.42.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
+23 -120
View File
@@ -8,18 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@ai-sdk/anthropic':
specifier: ^3.0.81
version: 3.0.81(zod@4.4.3)
'@ai-sdk/google':
specifier: ^3.0.80
version: 3.0.80(zod@4.4.3)
'@ai-sdk/openai':
specifier: ^3.0.67
version: 3.0.67(zod@4.4.3)
ai:
specifier: ^6.0.196
version: 6.0.196(zod@4.4.3)
jsonrepair: jsonrepair:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.14.0 version: 3.14.0
@@ -29,6 +17,9 @@ importers:
next: next:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
openai:
specifier: ^6.42.0
version: 6.42.0(ws@8.20.1)(zod@4.4.3)
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7 version: 19.2.7
@@ -69,40 +60,6 @@ importers:
packages: packages:
'@ai-sdk/anthropic@3.0.81':
resolution: {integrity: sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.124':
resolution: {integrity: sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@3.0.80':
resolution: {integrity: sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.67':
resolution: {integrity: sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.27':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.10':
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0': '@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1257,9 +1214,6 @@ packages:
'@speed-highlight/core@1.2.15': '@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1283,10 +1237,6 @@ packages:
'@types/react@19.2.16': '@types/react@19.2.16':
resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
abort-controller@3.0.0: abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'} engines: {node: '>=6.5'}
@@ -1304,12 +1254,6 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'} engines: {node: '>= 8.0.0'}
ai@6.0.196:
resolution: {integrity: sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ansi-colors@4.1.3: ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1618,10 +1562,6 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
execa@5.1.1: execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1833,9 +1773,6 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
jsonrepair@3.14.0: jsonrepair@3.14.0:
resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==} resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==}
hasBin: true hasBin: true
@@ -2028,6 +1965,17 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} engines: {node: '>=6'}
openai@6.42.0:
resolution: {integrity: sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==}
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2495,42 +2443,6 @@ packages:
snapshots: snapshots:
'@ai-sdk/anthropic@3.0.81(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/gateway@3.0.124(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
'@ai-sdk/google@3.0.80(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/openai@3.0.67(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.1.0
zod: 4.4.3
'@ai-sdk/provider@3.0.10':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@ast-grep/napi-darwin-arm64@0.40.5': '@ast-grep/napi-darwin-arm64@0.40.5':
@@ -3632,7 +3544,8 @@ snapshots:
- encoding - encoding
- supports-color - supports-color
'@opentelemetry/api@1.9.1': {} '@opentelemetry/api@1.9.1':
optional: true
'@poppinss/colors@4.1.6': '@poppinss/colors@4.1.6':
dependencies: dependencies:
@@ -3844,8 +3757,6 @@ snapshots:
'@speed-highlight/core@1.2.15': {} '@speed-highlight/core@1.2.15': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -3873,8 +3784,6 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@vercel/oidc@3.2.0': {}
abort-controller@3.0.0: abort-controller@3.0.0:
dependencies: dependencies:
event-target-shim: 5.0.1 event-target-shim: 5.0.1
@@ -3890,14 +3799,6 @@ snapshots:
dependencies: dependencies:
humanize-ms: 1.2.1 humanize-ms: 1.2.1
ai@6.0.196(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 3.0.124(zod@4.4.3)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@opentelemetry/api': 1.9.1
zod: 4.4.3
ansi-colors@4.1.3: {} ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@@ -4213,8 +4114,6 @@ snapshots:
event-target-shim@5.0.1: {} event-target-shim@5.0.1: {}
eventsource-parser@3.1.0: {}
execa@5.1.1: execa@5.1.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@@ -4460,8 +4359,6 @@ snapshots:
jiti@1.21.7: {} jiti@1.21.7: {}
json-schema@0.4.0: {}
jsonrepair@3.14.0: {} jsonrepair@3.14.0: {}
jszip@3.10.1: jszip@3.10.1:
@@ -4617,6 +4514,11 @@ snapshots:
dependencies: dependencies:
mimic-fn: 2.1.0 mimic-fn: 2.1.0
openai@6.42.0(ws@8.20.1)(zod@4.4.3):
optionalDependencies:
ws: 8.20.1
zod: 4.4.3
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
pako@1.0.11: {} pako@1.0.11: {}
@@ -5132,4 +5034,5 @@ snapshots:
cookie: 1.1.1 cookie: 1.1.1
youch-core: 0.3.3 youch-core: 0.3.3
zod@4.4.3: {} zod@4.4.3:
optional: true