Your Name e8a3212b35
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: persist style pro history and model pricing
2026-05-16 21:28:00 +08:00

278 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { getSystemSetting, getUserById } from "@/lib/db/queries";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import {
STYLE_PRO_CONFIG_KEY,
type StyleProCategory,
type StyleProOption,
normalizeStyleProConfig,
} from "@/lib/style-pro/config";
export const maxDuration = 120;
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)),
});
type SelectedOption = {
category: StyleProCategory;
option: StyleProOption;
};
function isDisallowedImageUrl(value: string) {
const trimmed = value.trim().toLowerCase();
return trimmed.startsWith("data:") || trimmed.startsWith("javascript:");
}
function buildSelectionPayload(selectedOptions: SelectedOption[]) {
return selectedOptions.map(({ category, option }) => ({
categoryId: category.id,
categoryTitle: category.title,
jsonKey: option.jsonKey || category.id,
optionId: option.id,
optionLabel: option.label,
prompt: option.prompt,
}));
}
function composePrompt({
defaultPrompt,
selectedOptions,
}: {
defaultPrompt: string;
selectedOptions: SelectedOption[];
}) {
const selectionPayload = buildSelectionPayload(selectedOptions);
const groupedPrompt = selectionPayload.reduce<Record<string, string[]>>(
(acc, item) => {
const key = item.jsonKey || item.categoryId;
acc[key] = acc[key] || [];
acc[key].push(item.prompt);
return acc;
},
{}
);
return [
defaultPrompt,
"请严格依据以下风格化 PRO 选择执行最终图片生成。用户不可见配置 JSON:",
JSON.stringify(
{
mode: "style-pro-final",
selections: selectionPayload,
promptByKey: groupedPrompt,
},
null,
2
),
]
.filter(Boolean)
.join("\n\n");
}
function validateSelections(
categories: StyleProCategory[],
rawSelections: Record<string, string[]>
) {
const categoryById = new Map(
categories.map((category) => [category.id, category])
);
const selectedOptions: SelectedOption[] = [];
const normalizedSelections: Record<string, string[]> = {};
for (const [categoryId, optionIds] of Object.entries(rawSelections)) {
const category = categoryById.get(categoryId);
if (!category) {
return {
ok: false as const,
error: `无效分类:${categoryId}`,
};
}
const enabledOptions = new Map(
category.options
.filter((option) => option.enabled)
.map((option) => [option.id, option])
);
const uniqueOptionIds = Array.from(new Set(optionIds));
if (uniqueOptionIds.length > category.maxSelections) {
return {
ok: false as const,
error: `${category.title} 最多选择 ${category.maxSelections} 项`,
};
}
for (const optionId of uniqueOptionIds) {
const option = enabledOptions.get(optionId);
if (!option) {
return {
ok: false as const,
error: `无效选项:${category.title} / ${optionId}`,
};
}
selectedOptions.push({ category, option });
}
normalizedSelections[category.id] = uniqueOptionIds;
}
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)
);
});
if (missingCategory) {
return {
ok: false as const,
error: `请完成必选项:${missingCategory.title}`,
};
}
return {
ok: true as const,
selectedOptions,
normalizedSelections,
};
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = generateRequestSchema.parse(await request.json());
if (isDisallowedImageUrl(body.sourceImageUrl)) {
return NextResponse.json(
{ success: false, error: "请使用已上传的图片链接生成" },
{ status: 400 }
);
}
const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(
() => null
);
const config = normalizeStyleProConfig(setting?.value);
if (!config.enabled) {
return NextResponse.json(
{ success: false, error: "风格化 PRO 暂未启用" },
{ status: 403 }
);
}
const enabledCategories = config.categories
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0);
const selectionResult = validateSelections(
enabledCategories,
body.selections
);
if (!selectionResult.ok) {
return NextResponse.json(
{ success: false, error: selectionResult.error },
{ status: 400 }
);
}
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,
});
const result = await generateCanvasImage({
prompt,
negativePrompt: config.negativePrompt || undefined,
model: selectedModelRef,
aspectRatio: config.aspectRatio || "auto",
imageSize: config.imageSize || "2K",
imageCount: 1,
referenceImage: body.sourceImageUrl,
ownerKey: session.user.id,
debugLabel: "style-pro",
debugMeta: {
canvasId: body.canvasId || null,
requestedStyleProModelId: body.modelId || null,
selectedOptionCount: selectionResult.selectedOptions.length,
},
});
const image = result.images[0];
if (!image?.originalUrl) {
return NextResponse.json(
{ success: false, error: "生成结果为空" },
{ status: 502 }
);
}
return NextResponse.json({
success: true,
data: {
imageUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
model: result.model,
modelRef: result.modelRef,
selections: selectionResult.normalizedSelections,
optionLabels: selectionResult.selectedOptions.map(
({ category, option }) => `${category.title}:${option.label}`
),
},
});
} catch (error) {
console.error("[Style Pro Generate] Failed:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: "请求参数不完整" },
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "风格化 PRO 生成失败",
},
{ status: 500 }
);
}
}