Your Name 81c1e955cb
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: persist canvas detail archives and extend detail metadata
2026-02-19 13:14:05 +08:00

885 lines
34 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 { generateText } from "ai";
import { auth } from "@/app/(auth)/auth";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
} from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export const runtime = "nodejs";
export const maxDuration = 60;
type DetailPlanRequest = {
prompt?: string;
count?: number;
outputLanguage?: string;
referenceImages?: string[];
referenceImageNotes?: string[];
modelImageUrl?: string;
bundleImageUrls?: string[];
brief?: {
productSpu?: string;
demandType?: string;
productName?: string;
targetAudience?: string;
mainUser?: string;
usageScenario?: string;
triggerMoment?: string;
coreNeed?: string;
riskConcern?: string;
expectedResult?: string;
coreSellingPoint?: string;
positioningStatement?: string;
userVoice?: string;
competitorReference?: string;
priceBand?: string;
brandTone?: string;
bannedWords?: string;
};
};
type DetailBrief = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
usageScenario: string;
triggerMoment: string;
coreNeed: string;
riskConcern: string;
expectedResult: string;
coreSellingPoint: string;
positioningStatement: string;
userVoice: string;
competitorReference: string;
priceBand: string;
brandTone: string;
bannedWords: string;
};
type DetailScreenType = "A" | "B" | "C";
type DetailPlanScreen = {
index: number;
screenType: DetailScreenType;
objective: string;
title: string;
subtitle: string;
copy: string;
layoutNote: string;
useModel: boolean;
modelReason: string;
bundleShowcase: boolean;
bundleReason: string;
visualPrompt: string;
fabe: {
b: string;
f: string;
a: string;
e: string;
};
};
type DetailPlanResponse = {
summary: string;
prompts: string[];
screenSize: {
width: number;
height: number;
};
globalStyle: {
designLanguage: string;
typography: {
fontFamily: string;
title: string;
subtitle: string;
body: string;
};
layout: {
grid: string;
safeArea: string;
rhythm: string;
};
palette: string;
};
skillFlow: {
name: string;
version: string;
steps: string[];
};
qualityChecks: {
passed: boolean;
issues: string[];
};
screens: DetailPlanScreen[];
model?: {
id: string;
displayName: string;
providerType: string;
};
};
function buildDetailSkillFlow(language: "zh" | "en") {
if (language === "en") {
return {
name: "AI Detail Generation Skill",
version: "1.0",
steps: [
"Positioning Card: identify buyer, user, scenario, trigger, risk, expected outcome.",
"Need Decomposition: prioritize core demand and convert feature to buying reason.",
"Golden Five-Screen Structure: core value, differentiation, trust proof.",
"FABE Copying: each frame includes Benefit, Feature, Advantage, Evidence.",
"A/B/C Visual Rhythm: emotional scene + rational proof + conversion closure.",
"Global Consistency: fixed size, unified typography hierarchy, layout rhythm and style.",
],
};
}
return {
name: "AI详情生成技能",
version: "1.0",
steps: [
"定位卡:明确购买人群、使用者、主场景、触发时刻、顾虑与期望结果。",
"需求拆解:按核心需求优先级,把卖点转成买点表达。",
"黄金五屏:前2屏讲核心卖点,3-4屏做差异强化,第5屏证据收口。",
"FABE文案:每屏都输出利益(B)、特征(F)、优势(A)、证据(E)。",
"A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾。",
"全局一致性:固定尺寸、统一字体层级、统一排版节奏与设计风格。",
],
};
}
function clampCount(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) return 5;
return Math.min(13, Math.max(5, Math.round(num)));
}
function sanitizeReferenceImages(images: unknown): string[] {
if (!Array.isArray(images)) return [];
return images
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 12);
}
function sanitizeReferenceImageNotes(notes: unknown): string[] {
if (!Array.isArray(notes)) return [];
return notes
.map((item) => String(item || "").replace(/\s+/g, " ").trim())
.filter(Boolean)
.slice(0, 12);
}
function sanitizeBundleImages(images: unknown): string[] {
if (!Array.isArray(images)) return [];
return images
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 3);
}
function normalizeOutputLanguage(value: unknown): "zh" | "en" {
const raw = String(value || "").toLowerCase();
if (raw.includes("en") || raw.includes("english") || raw.includes("英文")) {
return "en";
}
return "zh";
}
function normalizeDetailBrief(value: unknown): DetailBrief {
const raw = (value || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
return {
productSpu: normalize("productSpu"),
demandType: normalize("demandType"),
productName: normalize("productName"),
targetAudience: normalize("targetAudience"),
mainUser: normalize("mainUser"),
usageScenario: normalize("usageScenario"),
triggerMoment: normalize("triggerMoment"),
coreNeed: normalize("coreNeed"),
riskConcern: normalize("riskConcern"),
expectedResult: normalize("expectedResult"),
coreSellingPoint: normalize("coreSellingPoint"),
positioningStatement: normalize("positioningStatement"),
userVoice: normalize("userVoice"),
competitorReference: normalize("competitorReference"),
priceBand: normalize("priceBand"),
brandTone: normalize("brandTone"),
bannedWords: normalize("bannedWords"),
};
}
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
const matched = text.match(/\{[\s\S]*\}/);
if (!matched) return null;
try {
return JSON.parse(matched[0]) as T;
} catch {
return null;
}
}
function buildBriefText(brief: DetailBrief, language: "zh" | "en") {
const lines =
language === "en"
? [
brief.productSpu ? `Product SPU: ${brief.productSpu}` : "",
brief.demandType ? `Demand type: ${brief.demandType}` : "",
brief.productName ? `Product: ${brief.productName}` : "",
`Primary buyer audience: ${brief.targetAudience}`,
brief.mainUser ? `Primary user: ${brief.mainUser}` : "",
`Main usage scenario: ${brief.usageScenario}`,
brief.triggerMoment ? `Trigger moment: ${brief.triggerMoment}` : "",
`Core demand: ${brief.coreNeed}`,
brief.riskConcern ? `Risk concern: ${brief.riskConcern}` : "",
brief.expectedResult ? `Expected result: ${brief.expectedResult}` : "",
brief.coreSellingPoint ? `Core mental keyword: ${brief.coreSellingPoint}` : "",
brief.positioningStatement ? `Positioning statement: ${brief.positioningStatement}` : "",
brief.userVoice ? `User voice samples: ${brief.userVoice}` : "",
brief.competitorReference ? `Competitor reference: ${brief.competitorReference}` : "",
brief.priceBand ? `Price band: ${brief.priceBand}` : "",
brief.brandTone ? `Brand tone: ${brief.brandTone}` : "",
brief.bannedWords ? `Banned words: ${brief.bannedWords}` : "",
]
: [
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
brief.demandType ? `需求类型:${brief.demandType}` : "",
brief.productName ? `产品:${brief.productName}` : "",
`主购买人群:${brief.targetAudience}`,
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
`主场景:${brief.usageScenario}`,
brief.triggerMoment ? `触发时刻:${brief.triggerMoment}` : "",
`核心需求:${brief.coreNeed}`,
brief.riskConcern ? `用户顾虑:${brief.riskConcern}` : "",
brief.expectedResult ? `期望结果:${brief.expectedResult}` : "",
brief.coreSellingPoint ? `核心心智词:${brief.coreSellingPoint}` : "",
brief.positioningStatement ? `一句话定位:${brief.positioningStatement}` : "",
brief.userVoice ? `用户原话样本:${brief.userVoice}` : "",
brief.competitorReference ? `竞品对标:${brief.competitorReference}` : "",
brief.priceBand ? `价格带:${brief.priceBand}` : "",
brief.brandTone ? `品牌语气:${brief.brandTone}` : "",
brief.bannedWords ? `禁用词:${brief.bannedWords}` : "",
];
return lines.filter(Boolean).join("\n");
}
function buildDefaultGlobalStyle(language: "zh" | "en") {
if (language === "en") {
return {
designLanguage: "Clean premium e-commerce detail storytelling",
typography: {
fontFamily: "Unified modern sans-serif family across all frames",
title: "Main title: same weight/size hierarchy, bold and concise",
subtitle: "Subtitle: one level below title with stable spacing",
body: "Body text: readable medium-small size with consistent leading",
},
layout: {
grid: "Unified 12-column vertical layout with consistent alignment",
safeArea: "Top title area, middle product focus area, bottom proof area",
rhythm: "Use identical margin/padding rhythm across all frames",
},
palette: "Keep one unified palette with stable contrast and accent color",
};
}
return {
designLanguage: "高转化电商详情叙事风格(统一、克制、专业)",
typography: {
fontFamily: "全套统一现代无衬线字体家族,不混用",
title: "主标题使用统一字号/字重层级,简短有力",
subtitle: "副标题保持同一层级与间距,承接主标题",
body: "正文说明统一行高与字距,保证可读性",
},
layout: {
grid: "统一 12 栅格竖版布局,所有屏幕对齐关系一致",
safeArea: "上区标题、中区产品主体、下区证据与转化信息",
rhythm: "边距与留白节奏全套一致,避免每屏乱跳",
},
palette: "全套保持同一主色/强调色/中性色体系,明暗对比稳定",
};
}
function buildFallbackScreens(params: {
prompt: string;
count: number;
language: "zh" | "en";
brief: DetailBrief;
globalStyle: DetailPlanResponse["globalStyle"];
referenceImageNotes: string[];
hasModelImage: boolean;
hasBundleImages: boolean;
bundleImageCount: number;
}): DetailPlanScreen[] {
const {
prompt,
count,
language,
brief,
globalStyle,
referenceImageNotes,
hasModelImage,
hasBundleImages,
bundleImageCount,
} = params;
const productLabel =
brief.productName || (language === "en" ? "the product" : "该产品");
const keyword =
brief.coreSellingPoint || (language === "en" ? "core value" : "核心价值");
const mainScenario = brief.usageScenario || (language === "en" ? "daily life" : "日常场景");
const coreNeed = brief.coreNeed || (language === "en" ? "user demand" : "核心需求");
const positioning =
brief.positioningStatement || (language === "en" ? "No explicit positioning statement" : "未提供一句话定位");
const defaultObjectives =
language === "en"
? [
"Hero scene with one core value",
"Pain-point resonance and trigger moment",
"Solution structure and key mechanism",
"Differentiation reinforcement",
"Evidence and trust closing",
]
: [
"首屏主视觉聚焦单一核心卖点",
"痛点共鸣与触发场景放大",
"解决方案与关键机制说明",
"差异化优势重复强化",
"证据背书与信任收口",
];
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
return Array.from({ length: count }).map((_, index) => {
const screenType =
screenTypeSequence[index] || (index % 3 === 0 ? "B" : index % 3 === 1 ? "A" : "C");
const objective =
defaultObjectives[index] ||
(language === "en"
? `Extended detail frame ${index + 1}`
: `延展分镜 ${index + 1}`);
const isBundleScreen = hasBundleImages && index === count - 1;
const title =
isBundleScreen
? language === "en"
? "Cross-sell Bundle Scene"
: "关联销售搭配场景"
: language === "en"
? `${keyword} Frame ${index + 1}`
: `${keyword} 第${index + 1}屏`;
const subtitle =
isBundleScreen
? language === "en"
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
: language === "en"
? `For ${brief.targetAudience}, solve "${coreNeed}" in ${mainScenario}.`
: `面向${brief.targetAudience},在${mainScenario}场景解决“${coreNeed}”。`;
const copy =
isBundleScreen
? language === "en"
? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.`
: `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。`
: language === "en"
? `${productLabel} turns key pain points into visible outcomes with a clear decision path.`
: `${productLabel}将关键痛点转为可感知结果,形成清晰购买决策路径。`;
const layoutNote =
isBundleScreen
? language === "en"
? "Main product remains primary; companion products are secondary with clear hierarchy and no visual clutter."
: "主产品为视觉主角,搭配产品作为辅助,层级清晰避免喧宾夺主。"
: language === "en"
? `Use fixed layout rhythm. Keep title/subtitle/body hierarchy identical to all other frames.`
: `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致。`;
const useModel = !isBundleScreen && hasModelImage && (index === 1 || index === 3 || index >= 5);
const modelReason = useModel
? language === "en"
? "This frame needs human scenario demonstration, so use the fixed model image."
: "本屏需要人物场景演示,使用固定模特图。"
: language === "en"
? "This frame focuses on product-only communication without human figure."
: "本屏聚焦产品信息展示,不使用人物。";
const bundleShowcase = isBundleScreen;
const bundleReason = bundleShowcase
? language === "en"
? "Optional bundle references were provided; this frame must show scenario-based product pairing for cross-sell."
: "用户提供了搭配产品图,本屏必须展示场景化产品搭配以增强关联销售。"
: language === "en"
? "This frame is not the bundle showcase frame."
: "本屏非搭配展示页。";
const fabe =
language === "en"
? {
b: `Get ${coreNeed} resolved with less effort and risk.`,
f: `Show concrete product structure and material facts.`,
a: `Outperform generic options in core scenario performance.`,
e: `Use test data, certification, and before-after comparisons.`,
}
: {
b: `用户可低成本获得“${coreNeed}”的结果。`,
f: "给出可验证的结构、材质或工艺事实。",
a: "强调在主场景下优于普通方案的关键优势。",
e: "使用检测、认证、对比图等证据收尾。",
};
const visualPrompt =
language === "en"
? [
`E-commerce detail frame ${index + 1}, objective: ${objective}.`,
`Fixed vertical canvas 790x1300 pixels.`,
`Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; audience: ${brief.targetAudience}; core demand: ${coreNeed}.`,
`Positioning statement: ${positioning}.`,
`Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
`Layout: ${globalStyle.layout.grid}; ${globalStyle.layout.safeArea}; ${globalStyle.layout.rhythm}.`,
`Palette: ${globalStyle.palette}.`,
referenceImageNotes.length > 0
? `Reference image set roles: ${referenceImageNotes.join(" | ")}. Keep product identity and details fully consistent.`
: "",
bundleShowcase
? `This must be a bundle showcase frame: show the hero product with companion products from optional bundle refs in one coherent scenario.`
: "",
useModel
? "Integrate the same human model from the uploaded model image."
: "No human figure in this frame.",
`Commercial product photography, typography-safe area, no extra watermark.`,
`Task context: ${prompt}.`,
].join(" ")
: [
`电商详情页第${index + 1}屏,目标:${objective}。`,
"画布固定竖版 790x1300 像素。",
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};人群:${brief.targetAudience};核心需求:${coreNeed}。`,
`一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
`版式规范:${globalStyle.layout.grid};${globalStyle.layout.safeArea};${globalStyle.layout.rhythm}。`,
`配色规范:${globalStyle.palette}。`,
referenceImageNotes.length > 0
? `参考图角色:${referenceImageNotes.join(";")};必须严格保持产品一致性与细节一致性。`
: "",
bundleShowcase
? "本屏必须是搭配展示页:同屏展示主产品与搭配产品,体现组合使用场景与加购价值。"
: "",
useModel ? "本屏融入固定模特图中的同一人物。" : "本屏不要出现人物。",
"商业摄影质感,留出清晰文案排版区,不要水印。",
`任务上下文:${prompt}。`,
].join("");
return {
index: index + 1,
screenType,
objective,
title,
subtitle,
copy,
layoutNote,
useModel,
modelReason,
bundleShowcase,
bundleReason,
visualPrompt,
fabe,
};
});
}
function buildFallbackPlan(params: {
prompt: string;
count: number;
language: "zh" | "en";
brief: DetailBrief;
referenceImageNotes: string[];
hasModelImage: boolean;
hasBundleImages: boolean;
bundleImageCount: number;
}): DetailPlanResponse {
const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = buildDetailSkillFlow(language);
const screens = buildFallbackScreens({
...params,
globalStyle,
});
const qualityChecks = evaluatePlanQuality({
screens,
hasModelImage: params.hasModelImage,
hasBundleImages: params.hasBundleImages,
});
return {
summary:
language === "en"
? `Generated ${count} storyboard frames with fixed size 790x1300.`
: `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`,
prompts: screens.map((item) => item.visualPrompt),
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow,
qualityChecks,
screens,
};
}
function sanitizeScreen(
raw: unknown,
fallback: DetailPlanScreen
): DetailPlanScreen {
const source = (raw || {}) as Record<string, unknown>;
const safeText = (value: unknown, defaultValue: string) => {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
return normalized || defaultValue;
};
const fabeSource = (source.fabe || {}) as Record<string, unknown>;
const visualPrompt = safeText(source.visualPrompt, fallback.visualPrompt);
const rawScreenType = String(source.screenType || "").trim().toUpperCase();
const screenType: DetailScreenType =
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
? (rawScreenType as DetailScreenType)
: fallback.screenType;
return {
index: fallback.index,
screenType,
objective: safeText(source.objective, fallback.objective),
title: safeText(source.title, fallback.title),
subtitle: safeText(source.subtitle, fallback.subtitle),
copy: safeText(source.copy, fallback.copy),
layoutNote: safeText(source.layoutNote, fallback.layoutNote),
useModel:
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
modelReason: safeText(source.modelReason, fallback.modelReason),
bundleShowcase:
typeof source.bundleShowcase === "boolean"
? source.bundleShowcase
: fallback.bundleShowcase,
bundleReason: safeText(source.bundleReason, fallback.bundleReason),
visualPrompt: visualPrompt.includes("790x1300")
? visualPrompt
: `${visualPrompt} 画布固定 790x1300 像素。`,
fabe: {
b: safeText(fabeSource.b, fallback.fabe.b),
f: safeText(fabeSource.f, fallback.fabe.f),
a: safeText(fabeSource.a, fallback.fabe.a),
e: safeText(fabeSource.e, fallback.fabe.e),
},
};
}
function sanitizePlannedScreens(
raw: unknown,
fallbackScreens: DetailPlanScreen[],
): DetailPlanScreen[] {
if (!Array.isArray(raw)) {
return fallbackScreens;
}
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback));
}
function sanitizeGlobalStyle(
raw: unknown,
fallback: DetailPlanResponse["globalStyle"],
): DetailPlanResponse["globalStyle"] {
const source = (raw || {}) as Record<string, unknown>;
const toText = (value: unknown, defaultValue: string) => {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
return normalized || defaultValue;
};
const typography = (source.typography || {}) as Record<string, unknown>;
const layout = (source.layout || {}) as Record<string, unknown>;
return {
designLanguage: toText(source.designLanguage, fallback.designLanguage),
typography: {
fontFamily: toText(typography.fontFamily, fallback.typography.fontFamily),
title: toText(typography.title, fallback.typography.title),
subtitle: toText(typography.subtitle, fallback.typography.subtitle),
body: toText(typography.body, fallback.typography.body),
},
layout: {
grid: toText(layout.grid, fallback.layout.grid),
safeArea: toText(layout.safeArea, fallback.layout.safeArea),
rhythm: toText(layout.rhythm, fallback.layout.rhythm),
},
palette: toText(source.palette, fallback.palette),
};
}
function evaluatePlanQuality(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
hasBundleImages: boolean;
}): { passed: boolean; issues: string[] } {
const { screens, hasModelImage, hasBundleImages } = params;
const issues: string[] = [];
if (screens.length === 0) {
issues.push("未生成任何分镜。");
}
const firstFive = screens.slice(0, 5);
if (firstFive.length >= 1 && firstFive[0]?.screenType !== "B") {
issues.push("第1屏应为B类场景共鸣页。");
}
if (firstFive.length >= 2 && firstFive[1]?.screenType !== "A") {
issues.push("第2屏建议为A类技术公信页。");
}
if (firstFive.length >= 4 && firstFive[3]?.screenType !== "C") {
issues.push("第4屏建议为C类细节佐证页。");
}
const typeCounter = firstFive.reduce(
(acc, screen) => {
acc[screen.screenType] += 1;
return acc;
},
{ A: 0, B: 0, C: 0 } as Record<DetailScreenType, number>,
);
if (typeCounter.B < 2) {
issues.push("前5屏应至少包含2屏B类场景页。");
}
if (typeCounter.A < 1) {
issues.push("前5屏应至少包含1屏A类公信页。");
}
if (typeCounter.C < 1) {
issues.push("前5屏应至少包含1屏C类细节页。");
}
const hasMissingFABE = screens.some(
(screen) =>
!screen.fabe.b.trim() ||
!screen.fabe.f.trim() ||
!screen.fabe.a.trim() ||
!screen.fabe.e.trim(),
);
if (hasMissingFABE) {
issues.push("存在分镜FABE不完整。");
}
if (hasModelImage) {
const modelScreenCount = screens.filter((screen) => screen.useModel).length;
if (modelScreenCount === 0) {
issues.push("已提供模特图但没有人物场景分镜。");
}
}
if (hasBundleImages) {
const bundleScreenCount = screens.filter((screen) => screen.bundleShowcase).length;
if (bundleScreenCount === 0) {
issues.push("已提供搭配产品图但没有搭配展示分镜。");
}
}
return {
passed: issues.length === 0,
issues,
};
}
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 = (await request.json()) as DetailPlanRequest;
const prompt = String(body.prompt || "").trim();
if (!prompt) {
return NextResponse.json({ success: false, error: "请输入详情需求" }, { status: 400 });
}
const baseCount = clampCount(body.count);
const referenceImages = sanitizeReferenceImages(body.referenceImages);
const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes);
const modelImageUrl = String(body.modelImageUrl || "").trim();
const bundleImageUrls = sanitizeBundleImages(body.bundleImageUrls);
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
const hasBundleImages = bundleImageUrls.length > 0;
const count = hasBundleImages ? Math.min(13, baseCount + 1) : baseCount;
const language = normalizeOutputLanguage(body.outputLanguage);
const brief = normalizeDetailBrief(body.brief);
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
return NextResponse.json(
{
success: false,
error: "请补充必填项:主购买人群、需求场景、核心需求",
},
{ status: 400 },
);
}
const fallback = buildFallbackPlan({
prompt,
count,
language,
brief,
referenceImageNotes,
hasModelImage,
hasBundleImages,
bundleImageCount: bundleImageUrls.length,
});
const allModels = await getEnabledAiModelsWithProvider(false);
const agentModel = await getAgentAiModelWithProvider();
const reasoningCompatible = allModels.filter(
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
);
const preferredAgentConfig =
agentModel &&
reasoningCompatible.some((item) => item.model.id === agentModel.model.id)
? reasoningCompatible.find((item) => item.model.id === agentModel.model.id)
: undefined;
let plannerConfig = preferredAgentConfig;
if (referenceImages.length > 0) {
if (!plannerConfig?.model.supportsVision) {
plannerConfig = reasoningCompatible.find((item) => item.model.supportsVision);
}
}
if (!plannerConfig) {
plannerConfig = reasoningCompatible[0];
}
if (!plannerConfig) {
return NextResponse.json({ success: true, data: fallback });
}
const model = await getLanguageModelById(plannerConfig.model.id);
if (!model) {
return NextResponse.json({ success: true, data: fallback });
}
const userInput = [
`用户需求:${prompt}`,
"以下是详情生成输入信息:",
buildBriefText(brief, language),
`目标分镜数量:${count}`,
`输出语言:${language === "en" ? "English" : "中文"}`,
"任务类型:电商详情页“AI详情生成”",
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
referenceImageNotes.length > 0
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
: "",
hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。",
hasBundleImages
? `已提供 ${bundleImageUrls.length} 张搭配产品图,必须增加1屏场景化搭配展示用于关联销售。`
: "未提供搭配产品图。",
referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。",
]
.filter(Boolean)
.join("\n");
const imageInputs =
plannerConfig.model.supportsVision && referenceImages.length > 0
? referenceImages.map((url) => ({ type: "image" as const, image: url }))
: [];
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: userInput,
},
...imageInputs,
],
},
],
system:
`你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` +
`硬性要求:\n` +
`1) 输出严格 JSON,不要其他文本。\n` +
`2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` +
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
(hasModelImage
? `10) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
: `10) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
(hasBundleImages
? `11) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `11) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
`输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
temperature: 0.25,
maxTokens: 1800,
} as any);
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || "");
const globalStyle = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens).map((item) =>
hasModelImage
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const normalizedScreens = rawScreens.map((item) =>
hasBundleImages
? item
: {
...item,
bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const qualityChecks = evaluatePlanQuality({
screens: normalizedScreens,
hasModelImage,
hasBundleImages,
});
const screens = qualityChecks.passed ? normalizedScreens : fallback.screens;
const prompts = screens.map((item) => item.visualPrompt);
const summaryRaw = String(parsed?.summary || "").trim();
const summary = qualityChecks.passed
? summaryRaw || fallback.summary
: `${summaryRaw || fallback.summary}\n质量校验未通过,已自动回退到稳定分镜模板。`;
const response: DetailPlanResponse = {
summary,
prompts,
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow: fallback.skillFlow,
qualityChecks: qualityChecks.passed
? qualityChecks
: {
passed: false,
issues: [...qualityChecks.issues],
},
screens,
model: {
id: plannerConfig.model.id,
displayName: plannerConfig.model.displayName,
providerType: plannerConfig.provider.type,
},
};
return NextResponse.json({ success: true, data: response });
} catch (error) {
console.error("[Canvas Detail Plan] Error:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "详情规划失败" },
{ status: 500 },
);
}
}