266 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|