feat: persist style pro history and model pricing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-16 21:28:00 +08:00
parent 0ac796b9ff
commit e8a3212b35
5 changed files with 518 additions and 47 deletions

View File

@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { getSystemSetting } from "@/lib/db/queries";
import { getSystemSetting, getUserById } from "@/lib/db/queries";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import {
STYLE_PRO_CONFIG_KEY,
type StyleProCategory,
@ -16,6 +20,7 @@ export const dynamic = "force-dynamic";
const generateRequestSchema = z.object({
canvasId: z.string().trim().min(1).max(160).optional(),
sourceImageUrl: z.string().trim().min(1).max(4000),
modelId: z.string().trim().min(1).max(160).optional(),
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
});
@ -79,7 +84,9 @@ function validateSelections(
categories: StyleProCategory[],
rawSelections: Record<string, string[]>
) {
const categoryById = new Map(categories.map((category) => [category.id, category]));
const categoryById = new Map(
categories.map((category) => [category.id, category])
);
const selectedOptions: SelectedOption[] = [];
const normalizedSelections: Record<string, string[]> = {};
@ -121,7 +128,10 @@ function validateSelections(
const missingCategory = categories.find((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = normalizedSelections[category.id]?.length || 0;
return selectedCount < Math.max(category.required ? 1 : 0, category.minSelections);
return (
selectedCount <
Math.max(category.required ? 1 : 0, category.minSelections)
);
});
if (missingCategory) {
return {
@ -141,7 +151,10 @@ export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = generateRequestSchema.parse(await request.json());
@ -170,7 +183,10 @@ export async function POST(request: Request) {
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0);
const selectionResult = validateSelections(enabledCategories, body.selections);
const selectionResult = validateSelections(
enabledCategories,
body.selections
);
if (!selectionResult.ok) {
return NextResponse.json(
{ success: false, error: selectionResult.error },
@ -178,6 +194,29 @@ export async function POST(request: Request) {
);
}
let selectedModelRef = config.modelId || undefined;
if (body.modelId) {
const currentUser = await getUserById({ id: session.user.id }).catch(
() => null
);
const includeHidden =
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
const imageModelEntries = await getStyleProImageModelEntries({
includeHidden,
});
const selectedModel = findStyleProImageModelByRef(
imageModelEntries,
body.modelId
);
if (!selectedModel) {
return NextResponse.json(
{ success: false, error: "所选生图模型不可用" },
{ status: 400 }
);
}
selectedModelRef = selectedModel.model.id;
}
const prompt = composePrompt({
defaultPrompt: config.defaultPrompt,
selectedOptions: selectionResult.selectedOptions,
@ -185,7 +224,7 @@ export async function POST(request: Request) {
const result = await generateCanvasImage({
prompt,
negativePrompt: config.negativePrompt || undefined,
model: config.modelId || undefined,
model: selectedModelRef,
aspectRatio: config.aspectRatio || "auto",
imageSize: config.imageSize || "2K",
imageCount: 1,
@ -194,6 +233,7 @@ export async function POST(request: Request) {
debugLabel: "style-pro",
debugMeta: {
canvasId: body.canvasId || null,
requestedStyleProModelId: body.modelId || null,
selectedOptionCount: selectionResult.selectedOptions.length,
},
});
@ -235,4 +275,3 @@ export async function POST(request: Request) {
);
}
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting, getUserById } from "@/lib/db/queries";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
toPublicStyleProModelOption,
} from "@/lib/style-pro/model-options";
import {
STYLE_PRO_CONFIG_KEY,
normalizeStyleProConfig,
} from "@/lib/style-pro/config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const currentUser = await getUserById({ id: session.user.id }).catch(
() => null
);
const includeHidden =
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
const [entries, setting] = await Promise.all([
getStyleProImageModelEntries({ includeHidden }),
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
]);
const config = normalizeStyleProConfig(setting?.value);
const configuredDefault = findStyleProImageModelByRef(
entries,
config.modelId
);
const defaultEntry =
configuredDefault ||
entries.find((entry) => entry.model.isDefault) ||
entries[0] ||
null;
return NextResponse.json({
success: true,
defaultModelId: defaultEntry?.model.id || null,
models: entries.map(toPublicStyleProModelOption),
});
} catch (error) {
console.error("[Style Pro Model Options] Failed:", error);
return NextResponse.json(
{ success: false, error: "获取风格化 PRO 生图模型失败" },
{ status: 500 }
);
}
}

View File

@ -1,7 +1,7 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { toast } from "sonner";
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
import { canvasService } from "@/services/canvas";
@ -10,6 +10,7 @@ import type {
StyleProHistoryItem,
StyleProPersistedState,
} from "@/lib/style-pro/config";
import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
export default function CanvasStyleProPage() {
const router = useRouter();
@ -21,9 +22,52 @@ export default function CanvasStyleProPage() {
const nodes = useCanvasStore(canvasSelectors.nodes);
const styleProState = useCanvasStore((state) => state.styleProState);
const addNode = useCanvasStore((state) => state.addNode);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const setProject = useCanvasStore((state) => state.setProject);
const setStyleProState = useCanvasStore((state) => state.setStyleProState);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
useEffect(() => {
let cancelled = false;
const loadPersistedStyleProState = async () => {
if (!id) return;
const currentState = useCanvasStore.getState();
if (
currentState.projectId === id &&
(currentState.nodes.length > 0 ||
currentState.styleProState.history.length > 0)
) {
return;
}
const result = await canvasService.getProject(id);
if (cancelled || !result.success || !result.data?.canvasData) return;
const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title);
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
loadCanvas(canvasData);
return;
}
const nextStyleProState = normalizeStyleProPersistedState(
canvasData.styleProState
);
if (nextStyleProState.history.length > 0) {
setStyleProState(nextStyleProState);
}
};
void loadPersistedStyleProState();
return () => {
cancelled = true;
};
}, [id, loadCanvas, setProject, setStyleProState]);
const selectedImageNode =
selectedNodeIds.length === 1
? nodes.find(
@ -46,7 +90,10 @@ export default function CanvasStyleProPage() {
useCanvasStore.getState().getCanvasState()
);
if (!result.success) {
console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error);
console.error(
"[CanvasStyleProPage] 保存风格化 PRO 状态失败:",
result.error
);
}
},
[id]
@ -62,8 +109,10 @@ export default function CanvasStyleProPage() {
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
const stageWidth =
typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight =
typeof window !== "undefined" ? window.innerHeight : 720;
const naturalWidth = Number(item.width || 0);
const naturalHeight = Number(item.height || 0);
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
@ -111,7 +160,15 @@ export default function CanvasStyleProPage() {
}
toast.success("风格化 PRO 结果已添加到画布");
},
[addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom]
[
addNode,
getCanvasState,
id,
selectNodes,
viewport.x,
viewport.y,
viewport.zoom,
]
);
return (
@ -125,4 +182,3 @@ export default function CanvasStyleProPage() {
/>
);
}

View File

@ -1,11 +1,6 @@
"use client";
import {
Clock3,
Loader2,
Sparkles,
Upload,
} from "lucide-react";
import { Clock3, Loader2, Sparkles, Upload } from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@ -41,6 +36,25 @@ type StyleProConfigResponse = {
error?: string;
};
type StyleProModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerName: string;
providerType: string;
isDefault: boolean;
unitCost: number;
creditDiscountLabel?: string | null;
};
type StyleProModelsResponse = {
success: boolean;
defaultModelId?: string | null;
models?: StyleProModelOption[];
error?: string;
};
type StyleProGenerateResponse = {
success: boolean;
error?: string;
@ -63,6 +77,8 @@ type StyleProWorkspaceProps = {
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
};
const STYLE_PRO_HISTORY_LIMIT = 4;
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
@ -89,7 +105,93 @@ function loadImageDimensions(url: string) {
}
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
return Array.isArray(history) ? history.slice(0, 4) : [];
return Array.isArray(history)
? history.slice(0, STYLE_PRO_HISTORY_LIMIT)
: [];
}
function getLocalStyleProStateKey(canvasId: string) {
return `style-pro-state:${canvasId || "draft"}`;
}
function readLocalStyleProState(
canvasId: string
): StyleProPersistedState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(getLocalStyleProStateKey(canvasId));
if (!raw) return null;
const parsed = JSON.parse(raw) as StyleProPersistedState;
return {
sourceImageUrl:
typeof parsed.sourceImageUrl === "string"
? parsed.sourceImageUrl
: undefined,
history: normalizeHistory(
Array.isArray(parsed.history)
? parsed.history.filter(
(item) =>
item &&
typeof item.sourceImageUrl === "string" &&
typeof item.resultImageUrl === "string"
)
: []
),
};
} catch {
return null;
}
}
function writeLocalStyleProState(
canvasId: string,
state: StyleProPersistedState
) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
getLocalStyleProStateKey(canvasId),
JSON.stringify({
sourceImageUrl: state.sourceImageUrl,
history: normalizeHistory(state.history),
})
);
} catch {
// Local persistence is a safety net; server persistence remains authoritative.
}
}
function mergeStyleProHistory(
...historyGroups: Array<StyleProHistoryItem[] | undefined>
) {
const byKey = new Map<string, StyleProHistoryItem>();
for (const history of historyGroups) {
for (const item of history || []) {
if (!item?.sourceImageUrl || !item?.resultImageUrl) continue;
const key = item.id || `${item.sourceImageUrl}::${item.resultImageUrl}`;
if (!byKey.has(key)) {
byKey.set(key, item);
}
}
}
return Array.from(byKey.values())
.sort((left, right) => {
const leftTime = Date.parse(left.createdAt || "") || 0;
const rightTime = Date.parse(right.createdAt || "") || 0;
return rightTime - leftTime;
})
.slice(0, STYLE_PRO_HISTORY_LIMIT);
}
function mergeStyleProState(
...states: Array<StyleProPersistedState | null | undefined>
): StyleProPersistedState {
return {
sourceImageUrl:
states.find((state) => String(state?.sourceImageUrl || "").trim())
?.sourceImageUrl || undefined,
history: mergeStyleProHistory(...states.map((state) => state?.history)),
};
}
function ImagePreview({
@ -361,7 +463,13 @@ export function StyleProWorkspace({
);
const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
const { data: modelsData, isLoading: modelsLoading } =
useSWR<StyleProModelsResponse>("/api/style-pro/model-options", fetcher);
const config = configData?.config;
const modelOptions = useMemo(
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
[modelsData?.models]
);
const enabledCategories = useMemo(
() =>
(config?.categories || [])
@ -375,27 +483,73 @@ export function StyleProWorkspace({
);
const [sourceImageUrl, setSourceImageUrl] = useState(
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
() =>
mergeStyleProState(
persistedState,
readLocalStyleProState(canvasId),
initialSourceImageUrl
? { sourceImageUrl: initialSourceImageUrl, history: [] }
: null
).sourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>(
normalizeHistory(persistedState?.history)[0] || null
);
useState<StyleProHistoryItem | null>(() => {
const merged = mergeStyleProState(
persistedState,
readLocalStyleProState(canvasId)
);
return merged.history[0] || null;
});
const [history, setHistory] = useState<StyleProHistoryItem[]>(
normalizeHistory(persistedState?.history)
() =>
mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
.history
);
const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
if (modelOptions.length === 0) {
setSelectedModelId("");
return;
}
setSelectedModelId((current) => {
if (current && modelOptions.some((model) => model.id === current)) {
return current;
}
return modelsData?.defaultModelId || modelOptions[0]?.id || "";
});
}, [modelOptions, modelsData?.defaultModelId]);
useEffect(() => {
const localState = readLocalStyleProState(canvasId);
const nextSource =
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
persistedState?.sourceImageUrl ||
localState?.sourceImageUrl ||
initialSourceImageUrl ||
"";
setSourceImageUrl((current) => current || nextSource);
const nextHistory = normalizeHistory(persistedState?.history);
setHistory(nextHistory);
setCurrentResult((current) => current || nextHistory[0] || null);
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
setHistory((currentHistory) =>
mergeStyleProHistory(
currentHistory,
persistedState?.history,
localState?.history
)
);
setCurrentResult(
(current) =>
current ||
mergeStyleProHistory(persistedState?.history, localState?.history)[0] ||
null
);
}, [
canvasId,
initialSourceImageUrl,
persistedState?.history,
persistedState?.sourceImageUrl,
]);
const siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
@ -414,6 +568,22 @@ export function StyleProWorkspace({
return labels;
}, [enabledCategories, selections]);
const selectedModel = useMemo(
() =>
modelOptions.find((model) => model.id === selectedModelId) ||
modelOptions[0] ||
null,
[modelOptions, selectedModelId]
);
const selectedModelCostLabel = selectedModel
? selectedModel.unitCost > 0
? `${selectedModel.unitCost} 积分/张`
: "未设置积分"
: modelsLoading
? "加载价格"
: "暂无模型";
const missingRequiredCategories = useMemo(
() =>
enabledCategories.filter((category) => {
@ -429,12 +599,14 @@ export function StyleProWorkspace({
const persistState = useCallback(
async (nextState: StyleProPersistedState) => {
await onPersistState?.({
const normalizedState = {
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
});
};
writeLocalStyleProState(canvasId, normalizedState);
await onPersistState?.(normalizedState);
},
[onPersistState]
[canvasId, onPersistState]
);
const updateSourceImage = useCallback(
@ -515,6 +687,7 @@ export function StyleProWorkspace({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
modelId: selectedModel?.id || selectedModelId || undefined,
sourceImageUrl,
selections,
}),
@ -538,10 +711,10 @@ export function StyleProWorkspace({
width: dimensions.width,
height: dimensions.height,
};
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
0,
4
);
const nextHistory = [
item,
...history.filter((entry) => entry.id !== item.id),
].slice(0, 4);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
@ -558,6 +731,8 @@ export function StyleProWorkspace({
missingRequiredCategories,
persistState,
selectedOptionLabels,
selectedModel?.id,
selectedModelId,
selections,
sourceImageUrl,
]);
@ -570,9 +745,11 @@ export function StyleProWorkspace({
const disabledReason = !sourceImageUrl
? "请先上传图片"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
: modelsData?.success === true && modelOptions.length === 0
? "暂无可用生图模型"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
const promptDisplayMode = config?.promptDisplayMode || "stacked";
const renderPromptCategory = (
category: PublicStyleProCategory,
@ -762,23 +939,69 @@ export function StyleProWorkspace({
生成后拖动滑块查看原图与结果图。
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
<span className="text-black/45">模型</span>
<select
aria-label="选择生图模型"
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
disabled={
modelsLoading ||
modelOptions.length === 0 ||
generating
}
onChange={(event) =>
setSelectedModelId(event.target.value)
}
value={selectedModel?.id || selectedModelId}
>
{modelOptions.length > 0 ? (
modelOptions.map((model) => (
<option key={model.id} value={model.id}>
{model.displayName} ·{" "}
{model.unitCost > 0
? `${model.unitCost}积分/张`
: "未设置积分"}
</option>
))
) : (
<option value="">
{modelsLoading ? "加载模型" : "暂无模型"}
</option>
)}
</select>
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
{selectedModelCostLabel}
</span>
{selectedModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
{selectedModel.creditDiscountLabel}
</span>
) : null}
</div>
<Button
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
title={disabledReason || selectedModelCostLabel}
>
{generating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{generating ? "正在创建图片" : "执行"}
{generating
? "正在创建图片"
: selectedModel?.unitCost
? `执行 · ${selectedModel.unitCost}积分`
: "执行"}
</Button>
<Button
className="h-8 rounded-[14px] border-black/10 bg-white px-4 text-[12px] text-black/70 shadow-sm hover:bg-black/5"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
onClick={() =>
currentResult && onAddToCanvas?.(currentResult)
}
variant="outline"
>
添加到画布
@ -821,10 +1044,13 @@ export function StyleProWorkspace({
<div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-gray-400">
<Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
{new Date(item.createdAt).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</div>
<div className="line-clamp-1 text-[12px] text-black/70">
{item.optionLabels[0] || "风格化结果"}

View File

@ -0,0 +1,92 @@
import "server-only";
import type { DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import {
getImageCreditCostFromConfig,
getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing";
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
const STYLE_PRO_SUPPORTED_PROVIDER_TYPES = new Set([
"gemini",
"grsai-gpt-image",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
"stepfun-image",
"midjourney",
"runway",
"krea",
]);
export type StyleProModelEntry = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
export type PublicStyleProModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl: string | null;
providerName: string;
providerType: string;
isDefault: boolean;
unitCost: number;
creditDiscountLabel: string | null;
};
export function isStyleProImageModel(entry: StyleProModelEntry) {
const { model, provider } = entry;
if (!model.supportsImageGen) return false;
if (!STYLE_PRO_SUPPORTED_PROVIDER_TYPES.has(provider.type)) return false;
return isImageGenerationModelDescriptor({
providerType: provider.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export async function getStyleProImageModelEntries(options?: {
includeHidden?: boolean;
}) {
const models = await getEnabledAiModelsWithProvider(
false,
options?.includeHidden === true
);
return models.filter(isStyleProImageModel);
}
export function findStyleProImageModelByRef(
entries: StyleProModelEntry[],
modelRef?: string | null
) {
const normalized = String(modelRef || "").trim();
if (!normalized) return null;
return (
entries.find(
({ model }) => model.id === normalized || model.modelId === normalized
) || null
);
}
export function toPublicStyleProModelOption(
entry: StyleProModelEntry
): PublicStyleProModelOption {
const imageGenConfig = (entry.model.imageGenConfig ||
null) as DiscountableImageGenConfigLike | null;
return {
id: entry.model.id,
modelId: entry.model.modelId,
displayName: entry.model.displayName,
avatarUrl: entry.model.avatarUrl || null,
providerName: entry.provider.name,
providerType: entry.provider.type,
isDefault: entry.model.isDefault,
unitCost: getImageCreditCostFromConfig(imageGenConfig),
creditDiscountLabel: getImageModelCreditDiscountLabel(imageGenConfig),
};
}