885 lines
34 KiB
TypeScript
885 lines
34 KiB
TypeScript
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 },
|
||
);
|
||
}
|
||
}
|