import { NextResponse } from "next/server"; import { generateText } from "ai"; import { auth } from "@/app/(auth)/auth"; import { getAgentAiModelWithProvider, getEnabledAiModelsWithProvider, } from "@/lib/db/queries"; import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; export const runtime = "nodejs"; export const maxDuration = 180; function isAbortError(error: unknown): boolean { const target = error as { name?: unknown; message?: unknown; code?: unknown; ABORT_ERR?: unknown; }; const name = String(target?.name || ""); const message = String(target?.message || ""); const code = Number(target?.code); const abortCode = Number(target?.ABORT_ERR); return ( name === "AbortError" || code === 20 || abortCode === 20 || /aborted|abort/i.test(message) ); } function supportsVisionPlanning(model: { supportsVision?: boolean | null; capabilities?: unknown; defaultParams?: unknown; }) { return ( Boolean(model.supportsVision) || isAiModelMultimodal({ capabilities: model.capabilities, defaultParams: model.defaultParams, }) ); } 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[]; }; preflight?: { passed: boolean; designDirectorScore: number; marketingDirectorScore: number; issues: string[]; rewriteApplied: boolean; }; screens: DetailPlanScreen[]; model?: { id: string; displayName: string; providerType: string; }; }; type DetailPlanPreflight = { passed: boolean; designDirectorScore: number; marketingDirectorScore: number; issues: string[]; rewriteApplied: boolean; }; const AGENT_PLANNING_PHRASE_PATTERNS = [ /第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /这一屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /第\s*[0-9]+\s*屏(?:展示|讲|说明|介绍|主要|重点)?/gi, /\bscreen\s*[0-9]+\s*(shows?|focuses on|introduces?)?/gi, /第一屏展示啥/gi, ]; const TITLE_META_TERMS = [ "核心价值", "场景痛点", "方案拆解", "细节证明", "信任收口", "卖点延展", "解决方案", "价值延展", "证据背书", ]; function hasPlanningTitleMeta(value: string): boolean { const text = String(value || "").trim(); if (!text) return false; if (/第\s*\d+\s*屏|screen\s*\d+/i.test(text)) return true; return TITLE_META_TERMS.some((term) => text.includes(term)); } function stripAgentPlanningPhrases(value: string): string { let result = value; for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) { result = result.replace(pattern, " "); } return result.replace(/\s+/g, " ").trim(); } function desensitizeAudienceCopy(value: string, language: "zh" | "en"): string { let result = String(value || ""); if (!result) return result; if (language === "en") { result = result .replace(/\b\d{1,2}\s*(?:-|~|to)\s*\d{1,2}\s*years?\s*old\b/gi, "target users") .replace( /\b(?:for|toward|targeting)\s+[^,.;\n]{0,48}\b(?:women|men|female|male|audience|users?)\b/gi, "for target scenario users" ) .replace(/\b(?:primary audience|target audience)\s*:\s*[^,.;\n]+/gi, "target scenario users"); } else { result = result .replace(/\d{1,2}\s*(?:-|~|~|到|至)\s*\d{1,2}\s*岁(?:[男女]性)?/g, "目标用户") .replace( /(?:面向|针对)[^,。;\n]{0,40}(?:人群|用户|女性|男性|宝妈|白领|学生|新手)/g, "围绕目标场景用户" ) .replace( /(?:主购买人群|目标人群|适用人群)\s*[::]\s*[^,。;\n]+/g, "目标场景用户" ); } return result.replace(/\s+/g, " ").replace(/,{2,}/g, ",").trim(); } const GENERIC_FLUFF_WORDS_ZH = [ "高级感", "轻奢", "超好用", "巨能装", "适合所有场景", "适合所有人", ]; function visibleTitleLength(value: string): number { return value .replace(/[^\p{L}\p{N}]+/gu, "") .trim().length; } function buildTypographySpec(language: "zh" | "en") { if (language === "en") { return { fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei", title: "Main title 64px, weight 700, line-height 1.15, max 12 Chinese chars equivalent, single line preferred, at most 2 lines", subtitle: "Subtitle 34px, weight 600, line-height 1.25, placed 16px below title, max 30 Chinese chars equivalent", body: "Body text 24px, weight 500, line-height 1.45, 10px paragraph gap, max 4 lines per block", safeArea: "Top text safe-area starts at 64px and ends within 360px; keep product hero away from text zone", rhythm: "Horizontal margin 56px, inter-module gap 24px, all screens keep identical spacing rhythm", }; } return { fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei", title: "主标题 64px、字重700、行高1.15,建议单行,最多2行,控制在12字以内,不得出现“第X屏”", subtitle: "副标题 34px、字重600、行高1.25,位于主标题下方16px,控制在30字以内", body: "正文 24px、字重500、行高1.45、段间距10px,每块最多4行,避免密集堆字", safeArea: "顶部文字安全区 y=64~360,产品主体避免压住标题区", rhythm: "左右边距56px,模块间距24px,全套页面保持相同留白节奏", }; } function buildDetailSkillFlow(language: "zh" | "en") { if (language === "en") { return { name: "AI Detail Generation Skill", version: "1.1", 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.", "Per-frame Quality Review: check title hierarchy, typography, and layout clarity, then regenerate once if needed.", ], }; } return { name: "AI详情生成技能", version: "1.1", 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 14; return Math.min(20, 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") { const typographySpec = buildTypographySpec(language); if (language === "en") { return { designLanguage: "Clean premium e-commerce detail storytelling", typography: { fontFamily: typographySpec.fontFamily, title: typographySpec.title, subtitle: typographySpec.subtitle, body: typographySpec.body, }, layout: { grid: "Unified 12-column vertical layout with consistent alignment", safeArea: typographySpec.safeArea, rhythm: typographySpec.rhythm, }, palette: "Keep one unified palette with stable contrast and accent color", }; } return { designLanguage: "高转化电商详情叙事风格(统一、克制、专业)", typography: { fontFamily: typographySpec.fontFamily, title: typographySpec.title, subtitle: typographySpec.subtitle, body: typographySpec.body, }, layout: { grid: "统一 12 栅格竖版布局,所有屏幕对齐关系一致", safeArea: typographySpec.safeArea, rhythm: typographySpec.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 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"]; const defaultTitles = language === "en" ? ["Long-Sit Comfort", "Waist Support", "One-Step Adjust", "Stable Structure", "Verified Trust"] : ["久坐不累", "贴腰承托", "一键调节", "稳固支撑", "安心可证"]; 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" ? "Bundle Cross-sell" : "搭配推荐" : language === "en" ? defaultTitles[index] || `Comfort Upgrade ${index + 1}` : defaultTitles[index] || "舒适升级"; const subtitle = isBundleScreen ? language === "en" ? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.` : `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。` : language === "en" ? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.` : `在${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, and preserve text safe-area.` : `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致,并严格保留文字安全区。`; 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}; core demand: ${coreNeed}.`, "Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.", `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};核心需求:${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, language: "zh" | "en" ): 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 = desensitizeAudienceCopy( safeText(source.visualPrompt, fallback.visualPrompt), language ); const normalizeTitle = (value: string) => { const cleaned = stripAgentPlanningPhrases( value .replace(/第\s*\d+\s*屏/gi, "") .replace(/\bscreen\s*\d+\b/gi, "") .replace(/[((]?\s*\d+\s*[))]?$/g, "") .replace(/\s+/g, " ") .trim() ); if (!cleaned) return fallback.title; if (hasPlanningTitleMeta(cleaned)) return fallback.title; return cleaned; }; const normalizeSubtitle = (value: string) => stripAgentPlanningPhrases( value .replace(/第\s*\d+\s*屏/gi, "") .replace(/\bscreen\s*\d+\b/gi, "") .replace(/\s+/g, " ") .trim() ); const normalizeCopy = (value: string) => desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.copy, language); const normalizeLayoutNote = (value: string) => desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.layoutNote, language); 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: desensitizeAudienceCopy(safeText(source.objective, fallback.objective), language), title: desensitizeAudienceCopy(normalizeTitle(safeText(source.title, fallback.title)), language), subtitle: desensitizeAudienceCopy( normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle, language ) || fallback.subtitle, copy: normalizeCopy(safeText(source.copy, fallback.copy)), layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)), useModel: typeof source.useModel === "boolean" ? source.useModel : fallback.useModel, modelReason: desensitizeAudienceCopy(safeText(source.modelReason, fallback.modelReason), language), bundleShowcase: typeof source.bundleShowcase === "boolean" ? source.bundleShowcase : fallback.bundleShowcase, bundleReason: desensitizeAudienceCopy(safeText(source.bundleReason, fallback.bundleReason), language), 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[], language: "zh" | "en" ): DetailPlanScreen[] { if (!Array.isArray(raw)) { return fallbackScreens; } return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback, language)); } 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不完整。"); } const hasMetaTitle = screens.some((screen) => hasPlanningTitleMeta(screen.title)); if (hasMetaTitle) { issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。"); } const titleLengthOutOfRange = screens.some((screen) => { const len = visibleTitleLength(screen.title); return len < 4 || len > 10; }); if (titleLengthOutOfRange) { issues.push("存在分镜主标题不在4-10字区间。"); } const subtitleTooLong = screens.some((screen) => { const normalized = screen.subtitle.replace(/\s+/g, ""); return normalized.length > 38 || /[。!?.!?].+[。!?.!?]/.test(screen.subtitle); }); if (subtitleTooLong) { issues.push("存在分镜副标题过长或多句,建议保持一条结果句。"); } const hasGenericFluff = screens.some((screen) => { const fullText = `${screen.title} ${screen.subtitle} ${screen.copy}`; return GENERIC_FLUFF_WORDS_ZH.some((word) => fullText.includes(word)); }); if (hasGenericFluff) { issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。"); } 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, }; } async function runDualDirectorPlanPreflight(params: { model: any; language: "zh" | "en"; brief: DetailBrief; globalStyle: DetailPlanResponse["globalStyle"]; screens: DetailPlanScreen[]; hasModelImage: boolean; hasBundleImages: boolean; }) { const defaultResult = { passed: true, designDirectorScore: 88, marketingDirectorScore: 88, issues: [] as string[], rewriteApplied: false, candidateGlobalStyle: params.globalStyle, candidateScreens: params.screens, }; try { const payload = JSON.stringify( { globalStyle: params.globalStyle, screens: params.screens.map((screen) => ({ index: screen.index, screenType: screen.screenType, objective: screen.objective, title: screen.title, subtitle: screen.subtitle, copy: screen.copy, layoutNote: screen.layoutNote, useModel: screen.useModel, bundleShowcase: screen.bundleShowcase, fabe: screen.fabe, visualPrompt: screen.visualPrompt, })), }, null, 2, ); const briefText = buildBriefText(params.brief, params.language); const instruction = params.language === "en" ? [ "Preflight this storyboard before image generation using dual-director standards.", "Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).", "Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).", "Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.", "Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.", "Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.", "Must keep canvas 790x1300, unified typography hierarchy, and consistent style.", params.hasModelImage ? "Model image is provided: only use where needed, keep identity consistent." : "No model image: all frames must avoid human model dependence.", params.hasBundleImages ? "Bundle refs provided: keep exactly one bundle showcase frame." : "No bundle refs: bundle showcase must be false in all frames.", "If not pass, rewrite globalStyle and all screens directly to a production-ready version.", "Return strict JSON only:", "{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}", "Current plan JSON:", payload, "Input brief:", briefText, ] : [ "在生图前,按双总监标准对当前分镜做预审。", "角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。", "角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。", "禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。", "禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。", "禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。", "必须维持 790x1300 固定规格、全套统一字体层级与统一风格。", params.hasModelImage ? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。" : "未提供模特图:所有分镜不得依赖模特人物。", params.hasBundleImages ? "已提供搭配图:必须且仅保留1屏搭配展示页。" : "未提供搭配图:所有分镜 bundleShowcase=false。", "若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。", "只返回严格JSON:", "{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}", "当前方案JSON:", payload, "输入简报:", briefText, ]; const { text } = await generateText({ model: params.model, messages: [ { role: "user", content: instruction.join("\n"), }, ], system: params.language === "en" ? "You are both an art director and a marketing director for high-conversion e-commerce detail pages. Output JSON only." : "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。", temperature: 0.1, maxTokens: 2000, } as any); const parsed = extractJsonBlock<{ pass?: unknown; designDirectorScore?: unknown; marketingDirectorScore?: unknown; issues?: unknown; revisedGlobalStyle?: unknown; revisedScreens?: unknown; }>(text || ""); const designRaw = Number(parsed?.designDirectorScore); const marketingRaw = Number(parsed?.marketingDirectorScore); const designDirectorScore = Number.isFinite(designRaw) ? Math.max(0, Math.min(100, Math.round(designRaw))) : defaultResult.designDirectorScore; const marketingDirectorScore = Number.isFinite(marketingRaw) ? Math.max(0, Math.min(100, Math.round(marketingRaw))) : defaultResult.marketingDirectorScore; const issues = Array.isArray(parsed?.issues) ? parsed.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) : []; const rawPass = typeof parsed?.pass === "boolean" ? parsed.pass : designDirectorScore >= 85 && marketingDirectorScore >= 85 && issues.length === 0; let candidateGlobalStyle = params.globalStyle; let candidateScreens = params.screens; let rewriteApplied = false; if (parsed?.revisedGlobalStyle && typeof parsed.revisedGlobalStyle === "object") { candidateGlobalStyle = sanitizeGlobalStyle(parsed.revisedGlobalStyle, params.globalStyle); rewriteApplied = true; } if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) { candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens, params.language); rewriteApplied = true; } return { passed: rawPass, designDirectorScore, marketingDirectorScore, issues, rewriteApplied, candidateGlobalStyle, candidateScreens, }; } catch (error) { console.error("[Canvas Detail Plan] preflight error:", error); return defaultResult; } } 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(20, 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 || !supportsVisionPlanning(plannerConfig.model)) { plannerConfig = reasoningCompatible.find((item) => supportsVisionPlanning(item.model)); } } if (!plannerConfig) { plannerConfig = reasoningCompatible[0]; } if (!plannerConfig) { return NextResponse.json({ success: true, data: fallback }); } console.log("[Canvas Detail Plan] Planner model selected:", { id: plannerConfig.model.id, displayName: plannerConfig.model.displayName, modelId: plannerConfig.model.modelId, providerType: plannerConfig.provider.type, supportsVision: plannerConfig.model.supportsVision, multimodalFlag: isAiModelMultimodal({ capabilities: plannerConfig.model.capabilities, defaultParams: plannerConfig.model.defaultParams, }), hasReferenceImages: referenceImages.length > 0, }); 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 = supportsVisionPlanning(plannerConfig.model) && referenceImages.length > 0 ? referenceImages.map((url) => ({ type: "image" as const, image: url })) : []; let text = ""; try { const result = 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` + `7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` + `7.2) 核心需求最多保留3-5条(1条必备 + 1-2条期待 + 1条魅力),避免页面过载。\n` + `7.3) 每个核心卖点需覆盖“体验/社交/实用”三维中的至少2维,并落地到 copy 或 layoutNote。\n` + `8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` + `9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` + `9.1) 前3屏默认节奏:屏1=场景痛点共鸣(B),屏2=买点总括,屏3=证据验证(对比/实测/口碑/认证)。\n` + `10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` + `10.1) 主标题禁止使用规划节奏词:核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展。\n` + `11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` + `11.1) 副标题必须为单句结果句,禁止写成长段说明。\n` + `12) 对外文案禁止直写具体年龄/性别标签(如“25-35岁女性”),改为场景化表达(如“久坐办公场景用户”)。\n` + (hasModelImage ? `13) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` : `13) 未提供模特图:所有分镜均设置 useModel=false。\n`) + (hasBundleImages ? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` : `14) 未提供搭配产品图:所有分镜均设置 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); text = String(result?.text || ""); } catch (error) { if (isAbortError(error)) { console.error("[Canvas Detail Plan] Primary planning aborted, fallback applied:", error); return NextResponse.json({ success: true, data: { ...fallback, summary: `${fallback.summary}\n规划模型超时,已回退到稳定分镜模板。`, qualityChecks: { passed: false, issues: ["规划模型调用超时,已自动回退到稳定分镜模板。"], }, model: { id: plannerConfig.model.id, displayName: plannerConfig.model.displayName, providerType: plannerConfig.provider.type, }, preflight: { passed: false, designDirectorScore: 0, marketingDirectorScore: 0, issues: ["规划阶段超时,已启用稳定模板回退。"], rewriteApplied: false, }, }, }); } throw error; } const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || ""); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) => hasModelImage ? item : { ...item, useModel: false, modelReason: "未提供模特图,当前分镜不使用人物。", } ); const normalizedScreens = rawScreens.map((item) => hasBundleImages ? item : { ...item, bundleShowcase: false, bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", } ); const qualityChecksInitial = evaluatePlanQuality({ screens: normalizedScreens, hasModelImage, hasBundleImages, }); const preflight = await runDualDirectorPlanPreflight({ model, language, brief, globalStyle: globalStyleInitial, screens: normalizedScreens, hasModelImage, hasBundleImages, }); const preflightScreens = preflight.candidateScreens.map((item) => hasBundleImages ? item : { ...item, bundleShowcase: false, bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", } ); const qualityChecksAfterPreflight = evaluatePlanQuality({ screens: preflightScreens, hasModelImage, hasBundleImages, }); const preflightPassed = (preflight.passed || preflight.rewriteApplied) && preflight.designDirectorScore >= 85 && preflight.marketingDirectorScore >= 85; const shouldUsePreflightPlan = qualityChecksAfterPreflight.passed && preflightPassed; const shouldFallback = !shouldUsePreflightPlan; const screens = shouldFallback ? fallback.screens : preflightScreens; const globalStyle = shouldFallback ? fallback.globalStyle : preflight.candidateGlobalStyle; const qualityIssuesMerged = [ ...new Set([ ...qualityChecksInitial.issues, ...qualityChecksAfterPreflight.issues, ...preflight.issues.map((issue) => `双总监预审:${issue}`), ]), ]; const qualityChecks = { passed: !shouldFallback, issues: shouldFallback ? qualityIssuesMerged.length > 0 ? qualityIssuesMerged : ["双总监预审未通过,已回退到稳定分镜模板。"] : qualityIssuesMerged, }; const prompts = screens.map((item) => item.visualPrompt); const summaryRaw = String(parsed?.summary || "").trim(); const summary = desensitizeAudienceCopy( !shouldFallback ? summaryRaw || fallback.summary : `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`, language ); const response: DetailPlanResponse = { summary, prompts, screenSize: { width: 790, height: 1300, }, globalStyle, skillFlow: fallback.skillFlow, qualityChecks: qualityChecks.passed ? qualityChecks : { passed: false, issues: [...qualityChecks.issues], }, preflight: { passed: !shouldFallback, designDirectorScore: preflight.designDirectorScore, marketingDirectorScore: preflight.marketingDirectorScore, issues: preflight.issues, rewriteApplied: preflight.rewriteApplied, }, 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 }, ); } }