Merge pull request #64 from zonghaoyuan/refactor/settings-modal
feat: add client-side model configuration and server fallback
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
+29
-34
@@ -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
@@ -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[];
|
||||||
/** 0–1, FLUX needs ≥ 0.8 to actually have an effect. Runware-only. */
|
/** 0–1, 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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>"}`;
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+23
-120
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user