Files
MiragenFlow/web/src/stores/use-config-store.ts
T

266 lines
11 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { apiGet } from "@/services/api/request";
import type { AdminPublicSettings } from "@/services/api/admin";
export type AiConfig = {
channelMode: "remote" | "local";
baseUrl: string;
apiKey: string;
model: string;
imageModel: string;
videoModel: string;
textModel: string;
audioModel: string;
audioVoice: string;
audioFormat: string;
audioSpeed: string;
audioInstructions: string;
videoSeconds: string;
vquality: string;
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
models: string[];
imageModels: string[];
videoModels: string[];
textModels: string[];
audioModels: string[];
quality: string;
size: string;
count: string;
canvasImageCount: string;
};
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
export type ModelCapability = "image" | "video" | "text" | "audio";
export const defaultConfig: AiConfig = {
channelMode: "local",
baseUrl: "https://api.openai.com",
apiKey: "",
model: "gpt-image-2",
imageModel: "gpt-image-2",
videoModel: "grok-imagine-video",
textModel: "gpt-5.5",
audioModel: "gpt-4o-mini-tts",
audioVoice: "alloy",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "auto",
size: "1:1",
count: "1",
canvasImageCount: "3",
};
type ConfigStore = {
config: AiConfig;
publicSettings: AdminPublicSettings | null;
isPublicSettingsLoading: boolean;
isConfigOpen: boolean;
shouldPromptContinue: boolean;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
loadPublicSettings: () => Promise<void>;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
openConfigDialog: (shouldPromptContinue?: boolean) => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSettings["modelChannel"] | null) {
const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
const models = modelChannel.availableModels;
const textModels = filterModelsByCapability(models, "text");
const imageModels = filterModelsByCapability(models, "image");
const videoModels = filterModelsByCapability(models, "video");
const audioModels = filterModelsByCapability(models, "audio");
const fallbackTextModel = validDefault(modelChannel.defaultTextModel, textModels) || preferredModel(textModels, isTextModelName);
const fallbackModel = validDefault(modelChannel.defaultModel, textModels) || fallbackTextModel;
const fallbackImageModel = validDefault(modelChannel.defaultImageModel, imageModels) || preferredModel(imageModels, isImageModelName);
const fallbackVideoModel = validDefault(modelChannel.defaultVideoModel, videoModels) || preferredModel(videoModels, isVideoModelName);
const fallbackAudioModel = preferredModel(audioModels, isAudioModelName);
return {
...config,
channelMode,
models,
imageModels,
videoModels,
textModels,
audioModels,
model: textModels.includes(config.model) ? config.model : fallbackModel,
imageModel: imageModels.includes(config.imageModel) ? config.imageModel : fallbackImageModel,
videoModel: videoModels.includes(config.videoModel) ? config.videoModel : fallbackVideoModel,
textModel: textModels.includes(config.textModel) ? config.textModel : fallbackTextModel || fallbackModel,
audioModel: audioModels.includes(config.audioModel) ? config.audioModel : fallbackAudioModel,
systemPrompt: modelChannel.systemPrompt,
};
}
function validDefault(model: string, models: string[]) {
return models.includes(model) ? model : "";
}
function preferredModel(models: string[], predicate: (model: string) => boolean) {
return models.find(predicate) || "";
}
function isVideoModelName(model: string) {
const value = model.toLowerCase();
return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo");
}
function isImageModelName(model: string) {
const value = model.toLowerCase();
return !isVideoModelName(model) && !isAudioModelName(model) && (value.includes("seedream") || value.includes("gpt-image") || value.includes("image") || value.includes("dall-e") || value.includes("dalle") || value.includes("imagen") || value.includes("flux") || value.includes("sdxl") || value.includes("stable-diffusion") || value.includes("midjourney"));
}
function isAudioModelName(model: string) {
const value = model.toLowerCase();
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
}
function isTextModelName(model: string) {
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
}
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
if (!capability) return true;
if (capability === "image") return isImageModelName(model);
if (capability === "video") return isVideoModelName(model);
if (capability === "audio") return isAudioModelName(model);
return isTextModelName(model);
}
export function filterModelsByCapability(models: string[], capability?: ModelCapability) {
return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return config[modelListKey(capability)];
}
function modelListKey(capability: ModelCapability) {
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
}
function isAiConfigReady(config: AiConfig, model: string) {
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
}
export const useConfigStore = create<ConfigStore>()(
persist(
(set, get) => ({
config: defaultConfig,
publicSettings: null,
isPublicSettingsLoading: false,
isConfigOpen: false,
shouldPromptContinue: false,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
loadPublicSettings: async () => {
if (get().isPublicSettingsLoading) return;
set({ isPublicSettingsLoading: true });
try {
set({ publicSettings: await apiGet<AdminPublicSettings>("/api/settings") });
} finally {
set({ isPublicSettingsLoading: false });
}
},
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }),
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}),
{
name: CONFIG_STORE_KEY,
partialize: (state) => ({ config: state.config }),
merge: (persisted, current) => {
const persistedConfig = ((persisted as Partial<ConfigStore>).config || {}) as Partial<AiConfig>;
const config = { ...defaultConfig, ...persistedConfig };
return {
...current,
config: {
...config,
channelMode: config.channelMode || "remote",
imageModel: config.imageModel || config.model,
videoModel: config.videoModel || "grok-imagine-video",
textModel: config.textModel || config.model,
audioModel: config.audioModel || defaultConfig.audioModel,
audioVoice: config.audioVoice || defaultConfig.audioVoice,
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
videoSeconds: config.videoSeconds || "6",
vquality: config.vquality || "720",
videoGenerateAudio: config.videoGenerateAudio || "true",
videoWatermark: config.videoWatermark || "false",
canvasImageCount: config.canvasImageCount || "3",
imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels) : filterModelsByCapability(config.models, "image"),
videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels) : filterModelsByCapability(config.models, "video"),
textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels) : filterModelsByCapability(config.models, "text"),
audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels) : filterModelsByCapability(config.models, "audio"),
},
};
},
},
),
);
function normalizeModelList(models: string[]) {
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)));
}
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]);
}
export function buildApiUrl(baseUrl: string, path: string) {
let normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl);
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
const apiBaseUrl = lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/api/v3") || lowerBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
function normalizeArkPlanBaseUrl(baseUrl: string) {
try {
const url = new URL(baseUrl);
const path = url.pathname.replace(/\/+$/, "");
const lowerPath = path.toLowerCase();
const arkPlanIndex = lowerPath.indexOf("/api/plan/v3");
if (arkPlanIndex < 0) return baseUrl;
const end = arkPlanIndex + "/api/plan/v3".length;
if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl;
url.pathname = path.slice(0, end);
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
} catch {
return baseUrl;
}
}