import { NextResponse } from "next/server"; import { generateText } from "ai"; import { auth } from "@/app/(auth)/auth"; import { getAgentAiModelWithProvider, getEnabledAiModelsWithProvider, getSiteSettings, } from "@/lib/db/queries"; import { getAiModelAgentCodingModelId, isAiModelMultimodal, } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime"; import { buildDetailKimiGenerateOptions, type DetailKimiGenerateOptions, } from "@/lib/ai/detail-agent-kimi-profile"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; import { createLogger } from "@/lib/observability/logger"; import { filterReachableRemoteUrls } from "@/lib/net/url-access"; import { buildDetailUiProtocol, type DetailUiProtocol, } from "@/lib/detail-ui-protocol"; import { buildDetailUiSurfaceSnapshot } from "@/lib/detail-ui-surface-adapter"; import { normalizeDetailUiDslV2Document, type DetailUiDslV2Document, } from "@/lib/detail-ui-dsl-v2"; import { buildDetailUiDesignSkillFlowStep, buildDetailUiDesignSkillPrompt, buildDetailUiDesignSkillSpec, summarizeDetailUiDesignSkill, type DetailUiDesignSkillSpec, type DetailUiDesignSkillSummary, } from "@/lib/detail-ui-design-skill"; import { buildDetailBrandStyleBoard } from "@/lib/detail-ui-style-board"; import { validateDetailUiProtocol } from "@/lib/detail-ui-validator"; import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol"; import type { UiScreenDocument } from "@/lib/ui-screen/schema"; export const runtime = "nodejs"; export const maxDuration = 180; const DETAIL_PLAN_INLINE_MAX_IMAGES = 4; const DETAIL_PLAN_INLINE_TIMEOUT_MS = 4000; const DETAIL_PLAN_INLINE_RETRIES = 1; const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000; const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000; const logger = createLogger("canvas-detail-plan"); 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 isReferenceImageDownloadError(error: unknown): boolean { const target = error as { name?: unknown; message?: unknown }; const name = String(target?.name || ""); const message = String(target?.message || ""); return ( /AI_DownloadError/i.test(name) || /Failed to download/i.test(message) || /404\s*Not\s*Found/i.test(message) || /403\s*Forbidden/i.test(message) || /image.*download/i.test(message) ); } function isTransientNetworkError(error: unknown): boolean { const target = error as { message?: unknown; cause?: unknown; }; const message = String(target?.message || ""); const cause = target?.cause as { message?: unknown; code?: unknown } | undefined; const causeMessage = String(cause?.message || ""); const causeCode = String(cause?.code || ""); return /fetch failed|enotfound|econnreset|econnrefused|socket|timeout/i.test( `${message} ${causeMessage} ${causeCode}` ); } function supportsVisionPlanning(model: { supportsVision?: boolean | null; capabilities?: unknown; defaultParams?: unknown; }) { return ( Boolean(model.supportsVision) || isAiModelMultimodal({ capabilities: model.capabilities, defaultParams: model.defaultParams, }) ); } function isVolcanoResponsesPlanner(config: { provider?: { type?: string | null; baseUrl?: string | null } | null; }) { if (!config?.provider || config.provider.type !== "custom") return false; const baseUrl = String(config.provider.baseUrl || "").toLowerCase(); return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl); } async function generateTextWithTimeout(params: any, timeoutMs: number) { const controller = new AbortController(); let timer: ReturnType | null = null; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error(`OperationTimeoutError: generateText timed out after ${timeoutMs}ms`)); }, timeoutMs); }); try { return await Promise.race([ generateText({ ...params, abortSignal: controller.signal, } as any), timeoutPromise, ]); } finally { if (timer) clearTimeout(timer); } } type DetailPlanRequest = { prompt?: string; count?: number; generationMode?: string; 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 DetailGenerationMode = "classic" | "agent_layout"; 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 = { generationMode?: DetailGenerationMode; 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[]; }; designSkill?: DetailUiDesignSkillSummary; qualityChecks: { passed: boolean; issues: string[]; }; uiValidation?: { passed: boolean; issues: string[]; }; preflight?: { passed: boolean; designDirectorScore: number; marketingDirectorScore: number; issues: string[]; rewriteApplied: boolean; }; screens: DetailPlanScreen[]; uiProtocol?: DetailUiProtocol; uiSurface?: UiSurfaceSnapshot; uiDslV2Screens?: DetailUiDslV2Document[]; brandStyleBoard?: UiScreenDocument; model?: { id: string; displayName: string; providerType: string; }; }; type DetailPlanPreflight = { passed: boolean; designDirectorScore: number; marketingDirectorScore: number; issues: string[]; rewriteApplied: boolean; }; type DetailCoreFocusContext = { anchor: string; primaryNeed: string; namingToken: string; firstScreenTitle: string; firstScreenSubtitle: string; focusTerms: string[]; }; 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 = [ "核心价值", "场景痛点", "方案拆解", "机制拆解", "核心机制拆解", "核心收益可证", "差异强化", "细节证明", "信任收口", "证据收口", "卖点延展", "解决方案", "价值延展", "证据背书", ]; const RENDER_META_PATTERNS: RegExp[] = [ /第\s*\d+\s*屏/gi, /\bscreen\s*\d+\b/gi, /\bframe\s*\d+\b/gi, /核心机制拆解/gi, /机制拆解/gi, /差异强化/gi, /证据收口/gi, /核心收益可证/gi, /方案拆解/gi, /信任收口/gi, ]; 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 stripRenderMetaTerms(value: string): string { let result = String(value || ""); for (const pattern of RENDER_META_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 splitToCoreTerms(value: string, language: "zh" | "en"): string[] { const raw = String(value || "").trim(); if (!raw) return []; if (language === "en") { return Array.from( new Set( raw .toLowerCase() .split(/[^a-z0-9]+/i) .map((term) => term.trim()) .filter((term) => term.length >= 3) .slice(0, 6) ) ); } const compact = raw.replace(/\s+/g, ""); const clauses = compact .split(/[,。;、::,.!?!?/|()()\-—]+/g) .map((item) => item.trim()) .filter(Boolean); const seed = clauses[0] || compact; const terms = new Set(); if (seed.length >= 2) terms.add(seed.slice(0, Math.min(6, seed.length))); if (seed.length >= 4) terms.add(seed.slice(0, 4)); terms.add(seed); return Array.from(terms).filter((term) => term.length >= 2).slice(0, 6); } function compactZhTitle(value: string): string { const base = String(value || "") .replace(/[“”"'`]/g, "") .replace(/[,。;、::,.!?!?/|()()\-—\s]+/g, "") .trim(); if (!base) return "核心不妥协"; if (base.length > 10) return base.slice(0, 10); if (base.length < 4) return `${base}升级`; return base; } function compactEnTitle(value: string): string { const words = String(value || "") .replace(/[“”"'`]/g, "") .replace(/\s+/g, " ") .trim() .split(/[^a-z0-9]+/i) .map((word) => word.trim()) .filter(Boolean); if (words.length === 0) return "Core Upgrade"; return words.slice(0, 4).join(" "); } function inferZhProductSuffix(productName: string): string { const name = String(productName || "").replace(/\s+/g, ""); if (!name) return ""; const rules: Array<{ pattern: RegExp; suffix: string }> = [ { pattern: /自拍杆|稳定杆|补光杆/, suffix: "杆" }, { pattern: /尿不湿|纸尿裤|拉拉裤|尿裤/, suffix: "裤" }, { pattern: /面膜/, suffix: "膜" }, { pattern: /精华|精华液/, suffix: "精华" }, { pattern: /洗发水|洗发露/, suffix: "露" }, { pattern: /沐浴露/, suffix: "露" }, { pattern: /牙膏/, suffix: "膏" }, { pattern: /椅子|座椅|办公椅|人体工学椅/, suffix: "椅" }, { pattern: /枕头|枕/, suffix: "枕" }, { pattern: /书桌|桌子|电脑桌/, suffix: "桌" }, { pattern: /收纳柜|收纳架|收纳箱/, suffix: "架" }, ]; const matched = rules.find((rule) => rule.pattern.test(name)); if (matched) return matched.suffix; const tailMatch = name.match(/[杆裤膜露膏椅枕桌架杯包箱机器锅刷]/g); if (tailMatch && tailMatch.length > 0) { return tailMatch[tailMatch.length - 1] || ""; } return ""; } function inferZhNamingStem(anchor: string, primaryNeed: string): string { const source = `${anchor} ${primaryNeed}`.replace(/\s+/g, ""); const rules: Array<{ pattern: RegExp; stem: string }> = [ { pattern: /抗过敏|防过敏|敏感|低敏|温和/, stem: "安敏" }, { pattern: /甩|一甩|快速展开/, stem: "甩甩" }, { pattern: /防抖|稳定|稳拍/, stem: "稳拍" }, { pattern: /轻|便携|小巧/, stem: "轻享" }, { pattern: /护腰|贴腰|腰撑/, stem: "护腰" }, { pattern: /透气|呼吸感/, stem: "透气" }, { pattern: /省空间|收纳/, stem: "省位" }, { pattern: /抗菌|抑菌|洁净/, stem: "净护" }, ]; const matched = rules.find((rule) => rule.pattern.test(source)); if (matched) return matched.stem; const compact = compactZhTitle(anchor); if (!compact) return "安心"; if (compact.length === 1) return `${compact}${compact}`; return compact.slice(0, Math.min(4, compact.length)); } const ZH_NAME_BANNED_WORDS = [ "核心", "价值", "方案", "系统", "智能", "科技", "场景", "升级", "高端", "专业", ]; function toColloquialZhStem(stem: string): string { let next = String(stem || "").replace(/\s+/g, ""); if (!next) return "好用"; const map: Array<{ pattern: RegExp; value: string }> = [ { pattern: /快速|秒开|一秒|展开|甩/, value: "甩甩" }, { pattern: /防抖|稳定|稳拍|不晃/, value: "稳稳" }, { pattern: /抗过敏|安敏|低敏|敏感/, value: "安敏" }, { pattern: /透气|不闷|呼吸感/, value: "透透" }, { pattern: /防漏/, value: "防漏" }, { pattern: /护腰|贴腰|腰撑/, value: "护腰" }, { pattern: /省空间|收纳|整洁/, value: "省位" }, { pattern: /轻便|小巧|便携/, value: "轻轻" }, ]; const hit = map.find((item) => item.pattern.test(next)); if (hit) { next = hit.value; } ZH_NAME_BANNED_WORDS.forEach((token) => { next = next.replace(new RegExp(token, "g"), ""); }); next = next.replace(/[,。;、::,.!?!?/|()()\-—]/g, ""); if (!next) return "好用"; if (next.length === 1) return `${next}${next}`; if (next.length > 4) return next.slice(0, 4); return next; } function buildZhCoreNamingWord(brief: DetailBrief, anchor: string, primaryNeed: string): string { const suffix = inferZhProductSuffix(brief.productName); let stem = toColloquialZhStem(inferZhNamingStem(anchor, primaryNeed)); if (!suffix) return compactZhTitle(anchor); if (stem.length > 4) { stem = stem.slice(0, 4); } if (stem.length === 1) { stem = `${stem}${stem}`; } let title = stem.endsWith(suffix) ? stem : `${stem}${suffix}`; if (title.length < 3) { title = `${compactZhTitle(anchor).slice(0, 2)}${suffix}`; } if (title.length > 10) { title = title.slice(0, 10); } return title; } function inferZhNeedTag(params: { anchor: string; primaryNeed: string; expectedText: string; scenario: string; }): string { const source = `${params.anchor} ${params.primaryNeed} ${params.expectedText} ${params.scenario}`; const rules: Array<{ pattern: RegExp; phrase: string }> = [ { pattern: /自拍|拍摄|防抖|不抖|稳拍/, phrase: "自拍不手抖" }, { pattern: /过敏|低敏|敏感|红痒/, phrase: "敏肌不红痒" }, { pattern: /漏|防漏|整夜|夜用/, phrase: "整夜不漏尿" }, { pattern: /腰|护腰|久坐|腰酸/, phrase: "久坐不腰酸" }, { pattern: /收纳|省空间|不占地/, phrase: "收纳不占地" }, { pattern: /透气|不闷|呼吸感/, phrase: "久用不闷热" }, { pattern: /清洁|去污|去渍|好洗/, phrase: "去污不费劲" }, { pattern: /便携|轻便|小巧|出门/, phrase: "出门更轻松" }, ]; const hit = rules.find((item) => item.pattern.test(source)); if (hit) return hit.phrase; const compactNeed = compactZhTitle(params.expectedText || params.primaryNeed || params.anchor); if (!compactNeed) return "用着更省心"; if (compactNeed.length <= 4) return `${compactNeed}更省心`; return `${compactNeed.slice(0, 6)}更省心`; } function buildZhNameNeedTitle(params: { namingToken: string; needTag: string }): string { const left = String(params.namingToken || "").trim() || "好用款"; let right = String(params.needTag || "").trim() || "用着更省心"; right = right.replace(/[。!!??]+$/g, ""); let title = `${left},${right}`; if (title.length > 20) { title = `${left},${right.slice(0, Math.max(4, 19 - left.length))}`; } return title; } function buildCoreFocusContext(brief: DetailBrief, language: "zh" | "en"): DetailCoreFocusContext { const anchorRaw = brief.coreSellingPoint || brief.positioningStatement || brief.coreNeed || (language === "en" ? "core value" : "核心卖点"); const scenario = brief.usageScenario || (language === "en" ? "daily use" : "日常场景"); const primaryNeed = brief.coreNeed || (language === "en" ? "key demand" : "核心需求"); const riskText = brief.riskConcern || (language === "en" ? "extra effort" : "反复折腾"); const expectedText = brief.expectedResult || primaryNeed; const focusTerms = Array.from( new Set([ ...splitToCoreTerms(anchorRaw, language), ...splitToCoreTerms(primaryNeed, language), ...splitToCoreTerms(expectedText, language), ]) ).slice(0, 8); if (language === "en") { const title = compactEnTitle(anchorRaw); const subtitle = `In ${scenario}, achieve ${expectedText} without ${riskText}.`; return { anchor: anchorRaw, primaryNeed, namingToken: title, firstScreenTitle: title, firstScreenSubtitle: subtitle, focusTerms, }; } const namingToken = buildZhCoreNamingWord(brief, anchorRaw, primaryNeed); const needTag = inferZhNeedTag({ anchor: anchorRaw, primaryNeed, expectedText, scenario, }); const title = buildZhNameNeedTitle({ namingToken, needTag }); const subtitle = `在${scenario}场景,不用${riskText}也能${expectedText}。`; return { anchor: anchorRaw, primaryNeed, namingToken, firstScreenTitle: title, firstScreenSubtitle: subtitle, focusTerms: Array.from( new Set([ ...focusTerms, ...splitToCoreTerms(title, language), ...splitToCoreTerms(namingToken, language), ...splitToCoreTerms(needTag, language), ]) ), }; } function containsCoreTerms(text: string, terms: string[]): boolean { const source = String(text || "").toLowerCase(); if (!source || terms.length === 0) return false; return terms.some((term) => source.includes(String(term || "").toLowerCase())); } function hasFirstScreenPosterSignal(screen?: DetailPlanScreen): boolean { if (!screen) return false; const merged = `${screen.objective} ${screen.layoutNote} ${screen.visualPrompt}`.toLowerCase(); return /海报|封面|主视觉|kv|hero|poster|cover/.test(merged); } function enforceCoreFocusOnScreens(params: { screens: DetailPlanScreen[]; coreFocus: DetailCoreFocusContext; language: "zh" | "en"; }): DetailPlanScreen[] { const { screens, coreFocus, language } = params; const namingWord = coreFocus.namingToken || coreFocus.firstScreenTitle; return screens.map((screen, index) => { const mergedText = `${screen.title} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`; const focused = containsCoreTerms(mergedText, coreFocus.focusTerms); const promptCoreLine = language === "en" ? `Core selling-point anchor: ${coreFocus.anchor}. This frame must serve this same anchor and avoid topic drift.` : `核心卖点锚点:${coreFocus.anchor}。本屏必须服务同一核心卖点,不得偏题。`; const firstScreenPosterLine = language === "en" ? "Hero-cover rule: single dominant product visual, cinematic light, strong focal contrast, clean headline-safe top area, no collage." : "首屏必须是封面海报感:单一主视觉主体、电影级光影、强焦点对比、顶部清晰标题安全区,禁止拼贴说明图。"; let title = screen.title; let subtitle = screen.subtitle; let objective = screen.objective; let copy = screen.copy; let layoutNote = screen.layoutNote; let benefit = screen.fabe.b; if (index === 0) { title = language === "zh" ? coreFocus.firstScreenTitle : hasPlanningTitleMeta(title) || !containsCoreTerms(title, coreFocus.focusTerms) ? coreFocus.firstScreenTitle : title; subtitle = containsCoreTerms(`${subtitle} ${copy}`, coreFocus.focusTerms) ? subtitle : coreFocus.firstScreenSubtitle; objective = language === "en" ? `Hero poster cover for the One selling-point in one glance, instantly answer why it is worth buying.` : "封面海报化呈现The One核心卖点,一眼回答“为什么值得买”。"; layoutNote = language === "en" ? "Poster composition: product hero occupies 55-70% visual weight; top text safe area is clean; avoid dense multi-module layout." : "海报封面构图:产品主体占画面55-70%视觉权重,顶部标题区留白清晰,避免密集小模块拼贴。"; } if (!containsCoreTerms(objective, coreFocus.focusTerms)) { objective = language === "en" ? `${objective}. Keep reinforcing "${coreFocus.anchor}".` : `${objective},持续强化“${coreFocus.anchor}”。`; } if (!focused) { copy = language === "en" ? `${copy} Keep all messaging aligned to "${coreFocus.anchor}" and "${coreFocus.primaryNeed}".` : `${copy} 全屏文案围绕“${coreFocus.anchor}”,统一服务“${coreFocus.primaryNeed}”。`; benefit = language === "en" ? `Deliver "${coreFocus.primaryNeed}" under "${coreFocus.anchor}" with lower effort and clearer payoff.` : `在“${coreFocus.anchor}”下交付“${coreFocus.primaryNeed}”结果,降低决策与使用成本。`; } if ( language === "zh" && index > 0 && index <= 3 && namingWord && !String(`${subtitle} ${copy}`).includes(namingWord) ) { copy = `${copy} 持续强化“${namingWord}”这一记忆点,形成独有搜索词。`; } return { ...screen, title, subtitle, objective, copy, layoutNote, fabe: { ...screen.fabe, b: benefit, }, visualPrompt: (() => { let nextPrompt = screen.visualPrompt.includes(coreFocus.anchor) ? screen.visualPrompt : `${screen.visualPrompt} ${promptCoreLine}`; if (index === 0 && !/海报|封面|主视觉|kv|hero|poster|cover/i.test(nextPrompt)) { nextPrompt = `${nextPrompt} ${firstScreenPosterLine}`; } return nextPrompt; })(), }; }); } 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,建议1-2行,首屏控制在6-20字(命名词+需求句),不得出现“第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 applyDesignSkillToFlow( flow: DetailPlanResponse["skillFlow"], designSkill: DetailUiDesignSkillSpec ): DetailPlanResponse["skillFlow"] { return { ...flow, version: "1.2", steps: [...flow.steps, buildDetailUiDesignSkillFlowStep(designSkill)], }; } 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 normalizeDetailGenerationMode(value: unknown): DetailGenerationMode { return value === "agent_layout" ? "agent_layout" : "classic"; } 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; coreFocus: DetailCoreFocusContext; globalStyle: DetailPlanResponse["globalStyle"]; referenceImageNotes: string[]; hasModelImage: boolean; hasBundleImages: boolean; bundleImageCount: number; }): DetailPlanScreen[] { const { prompt, count, language, brief, coreFocus, 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 poster cover with one core value", "Pain-point resonance and trigger moment", "Comfort benefit clarity in real use", "Differentiated value in key details", "Trust proof and confidence closing", ] : [ "首屏海报主视觉聚焦单一核心卖点", "痛点共鸣与触发场景放大", "真实使用中的舒适收益说明", "关键细节体现差异优势", "证据背书与购买信心建立", ]; const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"]; const defaultTitles = language === "en" ? [ coreFocus.firstScreenTitle, "Sit Longer, Feel Better", "Comfort That Holds Up", "Stability You Can Trust", "Built to Last Daily", ] : [ coreFocus.firstScreenTitle, "久坐更轻松", "贴合更舒适", "稳固更省心", "耐用不将就", ]; return Array.from({ length: count }).map((_, index) => { const screenType = screenTypeSequence[index] || (index % 3 === 0 ? "B" : index % 3 === 1 ? "A" : "C"); let 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}张搭配产品参考图,强化关联销售与加购动机。` : index === 0 ? coreFocus.firstScreenSubtitle : language === "en" ? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.` : `围绕“${coreFocus.anchor}”,在${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 under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.` : `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`; let 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: "使用检测、认证、对比图等证据收尾。", }; if (index === 0 && !isBundleScreen) { objective = language === "en" ? "Poster-style hero cover for the One selling-point, making the buying reason obvious in one glance." : "海报级首屏封面,聚焦The One核心卖点,一眼说明购买理由。"; layoutNote = language === "en" ? "Hero poster layout: dominant single product focal point, cinematic lighting and contrast, clear top headline safe area, no collage cards." : "首屏海报布局:单一主体强焦点,电影感光影与对比,顶部标题区清晰,禁止拼贴卡片式说明。"; } const visualPrompt = language === "en" ? [ `E-commerce detail frame, 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.", `Core selling-point anchor: ${coreFocus.anchor}; this frame must stay aligned to this anchor.`, index === 0 ? "Hero-cover rule: this must look like a poster key visual with one dominant subject, strong focus contrast, and no collage cards." : "", `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(" ") : [ `电商详情页分镜,目标:${objective}。`, "画布固定竖版 790x1300 像素。", "全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。", `主场景:${mainScenario};核心需求:${coreNeed}。`, "人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。", `核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`, index === 0 ? "首屏封面规则:必须是海报主视觉(非说明图拼贴),单一主体大图、强聚焦、高级商业海报质感。" : "", `一句话定位:${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; generationMode: DetailGenerationMode; language: "zh" | "en"; brief: DetailBrief; coreFocus: DetailCoreFocusContext; designSkill: DetailUiDesignSkillSpec; referenceImageNotes: string[]; hasModelImage: boolean; hasBundleImages: boolean; bundleImageCount: number; }): DetailPlanResponse { const { count, language } = params; const globalStyle = buildDefaultGlobalStyle(language); const skillFlow = applyDesignSkillToFlow(buildDetailSkillFlow(language), params.designSkill); const designSkillSummary = summarizeDetailUiDesignSkill(params.designSkill); const fallbackScreens = buildFallbackScreens({ ...params, globalStyle, }); const screens = enforceCoreFocusOnScreens({ screens: fallbackScreens, coreFocus: params.coreFocus, language, }); const qualityChecks = evaluatePlanQuality({ screens, hasModelImage: params.hasModelImage, hasBundleImages: params.hasBundleImages, coreFocus: params.coreFocus, }); const uiProtocol = buildDetailUiProtocol({ generationMode: params.generationMode, screenSize: { width: 790, height: 1300, }, globalStyle, screens, }); const uiValidation = validateDetailUiProtocol(uiProtocol); const mergedIssues = [ ...qualityChecks.issues, ...uiValidation.issues.map((item) => item.message), ]; return { generationMode: params.generationMode, 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, designSkill: designSkillSummary, qualityChecks: { passed: qualityChecks.passed && uiValidation.passed, issues: mergedIssues, }, uiValidation: { passed: uiValidation.passed, issues: uiValidation.issues.map((item) => item.message), }, screens, uiProtocol, uiSurface: params.generationMode === "agent_layout" ? buildDetailUiSurfaceSnapshot({ generationMode: params.generationMode, screenSize: { width: 790, height: 1300, }, summary: language === "en" ? `Generated ${count} storyboard frames with fixed size 790x1300.` : `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`, globalStyle, screens, uiProtocol, uiValidation: { passed: uiValidation.passed, issues: uiValidation.issues.map((item) => item.message), }, }) : undefined, brandStyleBoard: buildDetailBrandStyleBoard({ language, prompt: params.prompt, brief: params.brief, globalStyle, designSkill: designSkillSummary, }), }; } 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( stripAgentPlanningPhrases( safeText(source.visualPrompt, fallback.visualPrompt) .replace(/第\s*\d+\s*屏/gi, "") .replace(/\bscreen\s*\d+\b/gi, "") .replace(/\bframe\s*\d+\b/gi, "") .replace(/\s+/g, " ") .trim() ), 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 sanitizeScreensForRender(params: { screens: DetailPlanScreen[]; coreFocus: DetailCoreFocusContext; language: "zh" | "en"; }): DetailPlanScreen[] { const { screens, coreFocus, language } = params; return screens.map((screen, index) => { const cleanedTitle = stripRenderMetaTerms(screen.title); const title = cleanedTitle && !hasPlanningTitleMeta(cleanedTitle) ? cleanedTitle : index === 0 ? coreFocus.firstScreenTitle : language === "zh" ? "舒适升级" : "Comfort Upgrade"; const subtitleRaw = stripRenderMetaTerms(screen.subtitle); const subtitle = subtitleRaw || (index === 0 ? coreFocus.firstScreenSubtitle : screen.subtitle); return { ...screen, title, subtitle, objective: stripRenderMetaTerms(screen.objective), copy: stripRenderMetaTerms(screen.copy), layoutNote: stripRenderMetaTerms(screen.layoutNote), modelReason: stripRenderMetaTerms(screen.modelReason), bundleReason: stripRenderMetaTerms(screen.bundleReason), visualPrompt: stripRenderMetaTerms(screen.visualPrompt), fabe: { b: stripRenderMetaTerms(screen.fabe.b), f: stripRenderMetaTerms(screen.fabe.f), a: stripRenderMetaTerms(screen.fabe.a), e: stripRenderMetaTerms(screen.fabe.e), }, }; }); } 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; coreFocus?: DetailCoreFocusContext; }): { passed: boolean; issues: string[] } { const { screens, hasModelImage, hasBundleImages, coreFocus } = 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 (!hasFirstScreenPosterSignal(firstFive[0])) { issues.push("首屏缺少“封面海报感”约束(主视觉/海报/封面/KV),需强化首屏主视觉表现。"); } 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, index) => { const len = visibleTitleLength(screen.title); const minLen = index === 0 ? 6 : 4; const maxLen = index === 0 ? 20 : 10; return len < minLen || len > maxLen; }); if (titleLengthOutOfRange) { issues.push("存在分镜主标题长度不符合规范(首屏6-20字,其余4-10字)。"); } const firstTitle = String(screens[0]?.title || "").trim(); if (firstTitle && !/[,,::·|]/.test(firstTitle)) { issues.push("首屏主标题需采用“命名词+需求句”结构(示例:甩甩杆,自拍不手抖)。"); } 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 (coreFocus && coreFocus.focusTerms.length > 0 && screens.length > 0) { const firstScreen = screens[0]; const firstScreenText = `${firstScreen?.title || ""} ${firstScreen?.subtitle || ""} ${firstScreen?.copy || ""}`; if (!containsCoreTerms(firstScreenText, coreFocus.focusTerms)) { issues.push("首屏未聚焦核心卖点锚点,主标题与副标题需直接承接核心卖点。"); } const offTopicCount = screens.filter((screen) => { const merged = `${screen.objective} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`; return !containsCoreTerms(merged, coreFocus.focusTerms); }).length; const allowOffTopic = Math.max(1, Math.floor(screens.length * 0.15)); if (offTopicCount > allowOffTopic) { issues.push("多屏文案偏离核心卖点,整套详情需围绕同一核心卖点展开。"); } const namingWord = String(coreFocus.namingToken || firstScreen?.title || "").trim(); if (namingWord) { const repeatCount = screens .slice(1) .filter((screen) => String(`${screen.subtitle} ${screen.copy}`).includes(namingWord)).length; if (repeatCount < Math.min(2, Math.max(1, screens.length - 1))) { issues.push("首屏命名词未在后续页面重复强化,建议至少2屏复现以沉淀新搜索词。"); } } } 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; isLocalMoonshot?: boolean; kimiOptions?: DetailKimiGenerateOptions; timeoutMs?: number; language: "zh" | "en"; brief: DetailBrief; coreFocus: DetailCoreFocusContext; designSkillPrompt: string; 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.", `All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`, 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.", "Apply this UI design skill as a hard visual preflight contract:", params.designSkillPrompt, "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.coreFocus.anchor}”,不得跑题。`, params.hasModelImage ? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。" : "未提供模特图:所有分镜不得依赖模特人物。", params.hasBundleImages ? "已提供搭配图:必须且仅保留1屏搭配展示页。" : "未提供搭配图:所有分镜 bundleShowcase=false。", "必须把以下 UI 设计技能作为硬性视觉预审契约:", params.designSkillPrompt, "若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。", "只返回严格JSON:", "{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}", "当前方案JSON:", payload, "输入简报:", briefText, ]; const { text } = await generateTextWithTimeout({ 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: params.isLocalMoonshot ? 1 : 0.1, maxTokens: 2000, ...(params.kimiOptions || {}), }, params.timeoutMs || DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS); 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) { logger.warn("preflight_failed", 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 referenceImagesRaw = sanitizeReferenceImages(body.referenceImages); const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes); const modelImageUrlRaw = String(body.modelImageUrl || "").trim(); const bundleImageUrlsRaw = sanitizeBundleImages(body.bundleImageUrls); const [referenceImageCheck, bundleImageCheck, modelImageCheck] = await Promise.all([ filterReachableRemoteUrls(referenceImagesRaw, { timeoutMs: 3000 }), filterReachableRemoteUrls(bundleImageUrlsRaw, { timeoutMs: 3000 }), modelImageUrlRaw ? filterReachableRemoteUrls([modelImageUrlRaw], { timeoutMs: 3000 }) : Promise.resolve({ kept: [] as string[], dropped: [] as string[] }), ]); const referenceImages = referenceImageCheck.kept.slice(0, 12); const bundleImageUrls = bundleImageCheck.kept.slice(0, 3); const modelImageUrl = modelImageCheck.kept[0] || ""; if (referenceImageCheck.dropped.length > 0) { logger.warn("reference_images_dropped", { count: referenceImageCheck.dropped.length, }); } if (bundleImageCheck.dropped.length > 0) { logger.warn("bundle_images_dropped", { count: bundleImageCheck.dropped.length, }); } if (modelImageUrlRaw && !modelImageUrl) { logger.warn("model_image_dropped"); } const effectiveReferenceImageNotes = referenceImages.length === referenceImagesRaw.length ? referenceImageNotes : []; const hasModelImage = /^https?:\/\//i.test(modelImageUrl); const hasBundleImages = bundleImageUrls.length > 0; const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount; const generationMode = normalizeDetailGenerationMode(body.generationMode); 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 coreFocus = buildCoreFocusContext(brief, language); const designSkill = buildDetailUiDesignSkillSpec({ language, generationMode, screenCount: count, hasReferenceImages: referenceImages.length > 0, hasModelImage, hasBundleImages, productName: brief.productName, brandTone: brief.brandTone, }); const designSkillPrompt = buildDetailUiDesignSkillPrompt(designSkill); const fallback = buildFallbackPlan({ prompt, count, generationMode, language, brief, coreFocus, designSkill, referenceImageNotes: effectiveReferenceImageNotes, hasModelImage, hasBundleImages, bundleImageCount: bundleImageUrls.length, }); const [allModels, agentModel, currentSiteSettings] = await Promise.all([ getEnabledAiModelsWithProvider(false), getAgentAiModelWithProvider(), getSiteSettings(), ]); const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({ siteSettingModelId: currentSiteSettings?.canvasDetailAgentModelId, }); const reasoningCompatible = allModels.filter( (item) => !item.model.isSubModel && item.model.isEnabled && ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type), ); const configuredDetailAgentConfig = currentSiteSettings?.canvasDetailAgentModelId && reasoningCompatible.some( (item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId, ) ? reasoningCompatible.find( (item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId, ) : undefined; const fallbackAgentConfig = agentModel && reasoningCompatible.some((item) => item.model.id === agentModel.model.id) ? reasoningCompatible.find((item) => item.model.id === agentModel.model.id) : undefined; const preferredAgentConfig = configuredDetailAgentConfig || fallbackAgentConfig; let plannerConfig = preferredAgentConfig; if (!localDetailAgentResolved && referenceImages.length > 0) { if (!plannerConfig || !supportsVisionPlanning(plannerConfig.model)) { plannerConfig = reasoningCompatible.find((item) => supportsVisionPlanning(item.model)); } } if (!localDetailAgentResolved && !plannerConfig) { plannerConfig = reasoningCompatible[0]; } if (!localDetailAgentResolved && !plannerConfig) { return NextResponse.json({ success: true, data: fallback }); } const selectedModelRef = localDetailAgentResolved ? localDetailAgentResolved.modelRef : plannerConfig!.model; const selectedProviderRef = localDetailAgentResolved ? localDetailAgentResolved.providerRef : { id: plannerConfig!.provider.id, name: plannerConfig!.provider.name, type: plannerConfig!.provider.type, baseUrl: plannerConfig!.provider.baseUrl, }; const plannerKimiOptions = buildDetailKimiGenerateOptions({ purpose: "plan", generationMode, modelId: selectedModelRef.modelId, providerBaseUrl: selectedProviderRef.baseUrl, isLocalMoonshot: Boolean(localDetailAgentResolved), }); const preflightKimiOptions = buildDetailKimiGenerateOptions({ purpose: "preflight", generationMode, modelId: selectedModelRef.modelId, providerBaseUrl: selectedProviderRef.baseUrl, isLocalMoonshot: Boolean(localDetailAgentResolved), }); logger.info("planner_model_selected", { id: selectedModelRef.id, displayName: selectedModelRef.displayName, modelId: selectedModelRef.modelId, codingModelId: localDetailAgentResolved ? null : getAiModelAgentCodingModelId(plannerConfig!.model), providerType: selectedProviderRef.type, supportsVision: selectedModelRef.supportsVision, detailAgentSettingModelId: currentSiteSettings?.canvasDetailAgentModelId || null, selectionSource: localDetailAgentResolved ? "detail-agent-local-config" : configuredDetailAgentConfig?.model.id === plannerConfig?.model.id ? "detail-agent-setting" : fallbackAgentConfig?.model.id === plannerConfig?.model.id ? "agent-default" : "vision-fallback", multimodalFlag: isAiModelMultimodal({ capabilities: selectedModelRef.capabilities, defaultParams: selectedModelRef.defaultParams, }), hasReferenceImages: referenceImages.length > 0, kimiDetailProfile: plannerKimiOptions.headers ? { thinking: plannerKimiOptions.headers["x-detail-agent-kimi-thinking"], maxTokens: plannerKimiOptions.maxTokens, } : null, }); const codingModelId = localDetailAgentResolved ? null : getAiModelAgentCodingModelId(plannerConfig!.model); const model = localDetailAgentResolved ? localDetailAgentResolved.model : await getLanguageModelById( plannerConfig!.model.id, codingModelId ); if (!model) { return NextResponse.json({ success: true, data: fallback }); } const userInput = [ `用户需求:${prompt}`, "以下是详情生成输入信息:", buildBriefText(brief, language), `目标分镜数量:${count}`, `输出语言:${language === "en" ? "English" : "中文"}`, `生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`, "任务类型:电商详情页“AI详情生成”", `详情 UI 设计技能:\n${designSkillPrompt}`, "必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。", `核心卖点锚点(整套必须围绕):${coreFocus.anchor}`, `首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`, "首屏命名规则:标题必须是“名字+需求一句话”(如“甩甩杆,自拍不手抖”),不能只给一个名字。", "命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。", effectiveReferenceImageNotes.length > 0 ? `参考图说明(按上传顺序):${effectiveReferenceImageNotes.join(" | ")}` : "", hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。", hasBundleImages ? `已提供 ${bundleImageUrls.length} 张搭配产品图,必须增加1屏场景化搭配展示用于关联销售。` : "未提供搭配产品图。", referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。", ] .filter(Boolean) .join("\n"); let imageInputs: Array<{ type: "image"; image: string }> = []; if ( (localDetailAgentResolved || supportsVisionPlanning(plannerConfig!.model)) && referenceImages.length > 0 ) { try { if (!localDetailAgentResolved && isVolcanoResponsesPlanner(plannerConfig!)) { imageInputs = referenceImages .slice(0, 6) .map((url) => ({ type: "image" as const, image: url })); } else { const inlined = await inlineVisionImages(referenceImages, { maxImages: DETAIL_PLAN_INLINE_MAX_IMAGES, maxSide: 900, timeoutMs: DETAIL_PLAN_INLINE_TIMEOUT_MS, retries: DETAIL_PLAN_INLINE_RETRIES, loggerPrefix: "[Canvas Detail Plan]", }); imageInputs = inlined.inlineDataUrls.map((dataUrl) => ({ type: "image" as const, image: dataUrl, })); } } catch (error) { if (isReferenceImageDownloadError(error) || isTransientNetworkError(error)) { logger.warn("vision_input_preprocessing_failed", error, { referenceImageCount: referenceImages.length, }); imageInputs = []; } else { throw error; } } } const buildPlannerRequest = (visionInputs: Array<{ type: "image"; image: string }>) => ({ model, messages: [ { role: "user" as const, content: [ { type: "text" as const, text: userInput, }, ...visionInputs, ], }, ], system: `你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` + `硬性要求:\n` + `1) 输出严格 JSON,不要其他文本。\n` + `2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` + `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + (generationMode === "agent_layout" ? `4.1) 当前为AGENT排版模式:visualPrompt 只描述画面层,禁止要求模型生成标题/副标题/正文等可见文字。\n` : "") + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + `5.1) 首屏主标题必须是“命名词+需求句”结构(6-20字,例如“甩甩杆,自拍不手抖”),并配一条结果副标题,直接回答“为什么值得买”。\n` + `5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` + `5.3) 中文首屏标题优先使用“核心卖点命名词+品类后缀”结构(如:甩甩杆、安敏裤)。\n` + `5.4) 命名词必须大白话、好记、可口头传播,避免“智能/科技/方案/系统”等抽象词。\n` + `5.5) 后续至少2屏在副标题或正文复现该命名词,强化用户检索记忆。\n` + `5.6) 首屏必须是封面海报构图:单一主体主视觉、强对比焦点、电影感光影、顶部标题安全区;禁止拼贴说明页。\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”等编号标识;首屏6-20字(命名词+需求句),其余屏建议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`) + (generationMode === "agent_layout" ? `15) 当前为 AGENT 详情模式:除 screens 外,还必须返回 dslV2Screens。每个 dslV2Screens[i] 都是可直接渲染的 detail-ui-dsl/v2 文档,必须包含 version、screenIndex、layoutVariant、region、nodes。nodes 仅允许这些 kind:panel、stack、row、badge、heading、body、feature-card、spec-table、compare、proof、cta。\n` + `15.1) dslV2Screens 必须执行“详情 UI 设计技能”:region 覆盖面积控制在画布45%以内;避开商品主体/Logo/人脸/动作;不得卡片套卡片;每屏最多1个主面板+1个辅助网格/证据条;所有 id 语义化且文字短句化。\n` : "") + `输出格式:\n` + `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}],"dslV2Screens":[{"version":"detail-ui-dsl/v2","screenIndex":1,"layoutVariant":"hero-left-panel","region":{"x":0.06,"y":0.05,"width":0.88,"height":0.46},"nodes":[{"kind":"panel","id":"hero-panel","variant":"glass","tone":"light","direction":"column","gap":18,"children":[{"kind":"badge","id":"screen-badge","icon":"sparkle","text":"核心升级","tone":"primary"},{"kind":"heading","id":"hero-title","text":"护腰椅,久坐不腰酸","level":1,"emphasis":"hero"},{"kind":"body","id":"hero-subtitle","text":"在家里加班,也能护腰/舒适","emphasis":"strong"}]}]}]}`, temperature: 0.25, ...(localDetailAgentResolved ? { temperature: 1 } : {}), maxTokens: 1800, ...plannerKimiOptions, }); let text = ""; try { const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => { return await generateTextWithTimeout( buildPlannerRequest(visionInputs), plannerKimiOptions.headers ? 120_000 : DETAIL_PLAN_MAIN_TIMEOUT_MS ); }; try { const result = await runPlanner(imageInputs); text = String(result?.text || ""); } catch (firstError) { const firstMessage = String((firstError as Error)?.message || ""); const shouldRetryWithoutImages = imageInputs.length > 0 && (isReferenceImageDownloadError(firstError) || isTransientNetworkError(firstError) || /operationtimeouterror|timed out|timeout/i.test(firstMessage)); if (!shouldRetryWithoutImages) { throw firstError; } logger.warn("planner_retry_without_reference_images", firstError, { imageInputCount: imageInputs.length, }); const retryResult = await runPlanner([]); text = String(retryResult?.text || ""); } } catch (error) { const message = String((error as Error)?.message || ""); if ( isAbortError(error) || /invalid scheme/i.test(message) || isReferenceImageDownloadError(error) || isTransientNetworkError(error) || /operationtimeouterror|timed out|timeout/i.test(message) ) { logger.error("primary_planning_fallback_applied", error); const fallbackReason = /invalid scheme/i.test(message) ? "规划模型图片输入协议不兼容,已回退到稳定分镜模板。" : isReferenceImageDownloadError(error) ? "参考图链接不可访问(404/下载失败),已自动忽略参考图并回退稳定分镜模板。" : isTransientNetworkError(error) ? "参考资源网络波动(DNS/连接异常),已自动回退到稳定分镜模板。" : "规划模型调用超时,已自动回退到稳定分镜模板。"; return NextResponse.json({ success: true, data: { ...fallback, summary: `${fallback.summary}\n${fallbackReason}`, qualityChecks: { passed: false, issues: [fallbackReason], }, model: { id: selectedModelRef.id, displayName: selectedModelRef.displayName, providerType: selectedProviderRef.type, }, preflight: { passed: false, designDirectorScore: 0, marketingDirectorScore: 0, issues: [fallbackReason], rewriteApplied: false, }, }, }); } throw error; } const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown; dslV2Screens?: 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 coreFocusedScreens = enforceCoreFocusOnScreens({ screens: normalizedScreens, coreFocus, language, }); const qualityChecksInitial = evaluatePlanQuality({ screens: coreFocusedScreens, hasModelImage, hasBundleImages, coreFocus, }); const preflight = await runDualDirectorPlanPreflight({ model, isLocalMoonshot: Boolean(localDetailAgentResolved), kimiOptions: preflightKimiOptions, timeoutMs: preflightKimiOptions.headers ? 30_000 : DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS, language, brief, coreFocus, designSkillPrompt, globalStyle: globalStyleInitial, screens: coreFocusedScreens, hasModelImage, hasBundleImages, }); const preflightScreensRaw = preflight.candidateScreens.map((item) => hasBundleImages ? item : { ...item, bundleShowcase: false, bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", } ); const preflightScreens = enforceCoreFocusOnScreens({ screens: preflightScreensRaw, coreFocus, language, }); const qualityChecksAfterPreflight = evaluatePlanQuality({ screens: preflightScreens, hasModelImage, hasBundleImages, coreFocus, }); const preflightPassed = (preflight.passed || preflight.rewriteApplied) && preflight.designDirectorScore >= 85 && preflight.marketingDirectorScore >= 85; const shouldUsePreflightPlan = qualityChecksAfterPreflight.passed && preflightPassed; const shouldFallback = !shouldUsePreflightPlan; const screensRaw = shouldFallback ? fallback.screens : preflightScreens; const screens = sanitizeScreensForRender({ screens: screensRaw, coreFocus, language, }); const globalStyle = shouldFallback ? fallback.globalStyle : preflight.candidateGlobalStyle; const qualityIssuesMerged = [ ...new Set([ ...qualityChecksInitial.issues, ...qualityChecksAfterPreflight.issues, ...preflight.issues.map((issue) => `双总监预审:${issue}`), ]), ]; const uiProtocol = buildDetailUiProtocol({ generationMode, screenSize: { width: 790, height: 1300, }, globalStyle, screens, }); const uiValidation = validateDetailUiProtocol(uiProtocol); const qualityChecks = { passed: !shouldFallback, issues: shouldFallback ? qualityIssuesMerged.length > 0 ? qualityIssuesMerged : ["双总监预审未通过,已回退到稳定分镜模板。"] : qualityIssuesMerged, }; const mergedIssues = [ ...qualityChecks.issues, ...uiValidation.issues.map((item) => item.message), ]; 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 uiDslV2Screens = !shouldFallback && !preflight.rewriteApplied && Array.isArray(parsed?.dslV2Screens) ? parsed.dslV2Screens .map((item) => normalizeDetailUiDslV2Document(item)) .filter(Boolean) .map((item) => ({ ...item!, surfaceId: `detail-dsl-${generationMode}-${item!.screenIndex}`, generationMode, screenSize: { width: 790, height: 1300, }, designStyle: item!.designStyle || globalStyle, })) : undefined; const designSkillSummary = summarizeDetailUiDesignSkill(designSkill); const response: DetailPlanResponse = { summary, prompts, generationMode, screenSize: { width: 790, height: 1300, }, globalStyle, skillFlow: fallback.skillFlow, designSkill: designSkillSummary, qualityChecks: qualityChecks.passed ? { passed: qualityChecks.passed && uiValidation.passed, issues: mergedIssues, } : { passed: false, issues: [...mergedIssues], }, uiValidation: { passed: uiValidation.passed, issues: uiValidation.issues.map((item) => item.message), }, preflight: { passed: !shouldFallback, designDirectorScore: preflight.designDirectorScore, marketingDirectorScore: preflight.marketingDirectorScore, issues: preflight.issues, rewriteApplied: preflight.rewriteApplied, }, screens, uiProtocol, uiSurface: generationMode === "agent_layout" ? buildDetailUiSurfaceSnapshot({ generationMode, screenSize: { width: 790, height: 1300, }, summary, globalStyle, screens, uiProtocol, uiValidation: { passed: uiValidation.passed, issues: uiValidation.issues.map((item) => item.message), }, }) : undefined, uiDslV2Screens, brandStyleBoard: buildDetailBrandStyleBoard({ language, prompt, brief, globalStyle, designSkill: designSkillSummary, }), model: { id: selectedModelRef.id, displayName: selectedModelRef.displayName, providerType: selectedProviderRef.type, }, }; return NextResponse.json({ success: true, data: response }); } catch (error) { logger.error("detail_plan_failed", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "详情规划失败" }, { status: 500 }, ); } }