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; 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>(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; const safeText = (value: unknown, defaultValue: string) => { const normalized = String(value || "").replace(/\s+/g, " ").trim(); return normalized || defaultValue; }; const fabeSource = (source.fabe || {}) as Record; 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; const toText = (value: unknown, defaultValue: string) => { const normalized = String(value || "").replace(/\s+/g, " ").trim(); return normalized || defaultValue; }; const typography = (source.typography || {}) as Record; const layout = (source.layout || {}) as Record; 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, ); 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 }, ); } }