diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index f013ba7d9..fd66d09d5 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -128,6 +128,13 @@ type DetailPlanResponse = { passed: boolean; issues: string[]; }; + preflight?: { + passed: boolean; + designDirectorScore: number; + marketingDirectorScore: number; + issues: string[]; + rewriteApplied: boolean; + }; screens: DetailPlanScreen[]; model?: { id: string; @@ -136,11 +143,65 @@ type DetailPlanResponse = { }; }; +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, +]; + +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 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.0", + 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.", @@ -148,12 +209,13 @@ function buildDetailSkillFlow(language: "zh" | "en") { "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.0", + version: "1.1", steps: [ "定位卡:明确购买人群、使用者、主场景、触发时刻、顾虑与期望结果。", "需求拆解:按核心需求优先级,把卖点转成买点表达。", @@ -161,14 +223,15 @@ function buildDetailSkillFlow(language: "zh" | "en") { "FABE文案:每屏都输出利益(B)、特征(F)、优势(A)、证据(E)。", "A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾。", "全局一致性:固定尺寸、统一字体层级、统一排版节奏与设计风格。", + "逐屏质检:每屏生成后检查标题层级/版式密度/文字可读性,不达标自动二次修图。", ], }; } function clampCount(value: unknown): number { const num = Number(value); - if (!Number.isFinite(num)) return 5; - return Math.min(13, Math.max(5, Math.round(num))); + if (!Number.isFinite(num)) return 14; + return Math.min(20, Math.max(5, Math.round(num))); } function sanitizeReferenceImages(images: unknown): string[] { @@ -283,19 +346,20 @@ function buildBriefText(brief: DetailBrief, language: "zh" | "en") { } function buildDefaultGlobalStyle(language: "zh" | "en") { + const typographySpec = buildTypographySpec(language); if (language === "en") { return { designLanguage: "Clean premium e-commerce detail storytelling", typography: { - fontFamily: "Unified modern sans-serif family across all frames", - title: "Main title: same weight/size hierarchy, bold and concise", - subtitle: "Subtitle: one level below title with stable spacing", - body: "Body text: readable medium-small size with consistent leading", + fontFamily: typographySpec.fontFamily, + title: typographySpec.title, + subtitle: typographySpec.subtitle, + body: typographySpec.body, }, layout: { grid: "Unified 12-column vertical layout with consistent alignment", - safeArea: "Top title area, middle product focus area, bottom proof area", - rhythm: "Use identical margin/padding rhythm across all frames", + safeArea: typographySpec.safeArea, + rhythm: typographySpec.rhythm, }, palette: "Keep one unified palette with stable contrast and accent color", }; @@ -303,15 +367,15 @@ function buildDefaultGlobalStyle(language: "zh" | "en") { return { designLanguage: "高转化电商详情叙事风格(统一、克制、专业)", typography: { - fontFamily: "全套统一现代无衬线字体家族,不混用", - title: "主标题使用统一字号/字重层级,简短有力", - subtitle: "副标题保持同一层级与间距,承接主标题", - body: "正文说明统一行高与字距,保证可读性", + fontFamily: typographySpec.fontFamily, + title: typographySpec.title, + subtitle: typographySpec.subtitle, + body: typographySpec.body, }, layout: { grid: "统一 12 栅格竖版布局,所有屏幕对齐关系一致", - safeArea: "上区标题、中区产品主体、下区证据与转化信息", - rhythm: "边距与留白节奏全套一致,避免每屏乱跳", + safeArea: typographySpec.safeArea, + rhythm: typographySpec.rhythm, }, palette: "全套保持同一主色/强调色/中性色体系,明暗对比稳定", }; @@ -341,8 +405,6 @@ function buildFallbackScreens(params: { } = params; const productLabel = brief.productName || (language === "en" ? "the product" : "该产品"); - const keyword = - brief.coreSellingPoint || (language === "en" ? "core value" : "核心价值"); const mainScenario = brief.usageScenario || (language === "en" ? "daily life" : "日常场景"); const coreNeed = brief.coreNeed || (language === "en" ? "user demand" : "核心需求"); const positioning = @@ -365,6 +427,10 @@ function buildFallbackScreens(params: { "证据背书与信任收口", ]; const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"]; + const defaultTitles = + language === "en" + ? ["Core Value", "Pain Resonance", "Solution Logic", "Detail Proof", "Trust Closure"] + : ["核心价值", "场景痛点", "方案拆解", "细节证明", "信任收口"]; return Array.from({ length: count }).map((_, index) => { const screenType = @@ -378,11 +444,11 @@ function buildFallbackScreens(params: { const title = isBundleScreen ? language === "en" - ? "Cross-sell Bundle Scene" - : "关联销售搭配场景" + ? "Bundle Cross-sell" + : "搭配推荐" : language === "en" - ? `${keyword} Frame ${index + 1}` - : `${keyword} 第${index + 1}屏`; + ? defaultTitles[index] || `Selling Point Extension ${index + 1}` + : defaultTitles[index] || "卖点延展"; const subtitle = isBundleScreen ? language === "en" @@ -405,8 +471,8 @@ function buildFallbackScreens(params: { ? "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.` - : `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致。`; + ? `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" @@ -552,6 +618,28 @@ function sanitizeScreen( const fabeSource = (source.fabe || {}) as Record; const visualPrompt = safeText(source.visualPrompt, fallback.visualPrompt); + 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() + ); + return cleaned || fallback.title; + }; + 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) => stripAgentPlanningPhrases(value) || fallback.copy; + const normalizeLayoutNote = (value: string) => + stripAgentPlanningPhrases(value) || fallback.layoutNote; const rawScreenType = String(source.screenType || "").trim().toUpperCase(); const screenType: DetailScreenType = rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C" @@ -561,10 +649,10 @@ function sanitizeScreen( index: fallback.index, screenType, objective: safeText(source.objective, fallback.objective), - title: safeText(source.title, fallback.title), - subtitle: safeText(source.subtitle, fallback.subtitle), - copy: safeText(source.copy, fallback.copy), - layoutNote: safeText(source.layoutNote, fallback.layoutNote), + title: normalizeTitle(safeText(source.title, fallback.title)), + subtitle: normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || 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: safeText(source.modelReason, fallback.modelReason), @@ -674,6 +762,11 @@ function evaluatePlanQuality(params: { issues.push("存在分镜FABE不完整。"); } + const hasScreenIndexInTitle = screens.some((screen) => /第\s*\d+\s*屏|screen\s*\d+/i.test(screen.title)); + if (hasScreenIndexInTitle) { + issues.push("主标题不应出现“第X屏/Screen X”类标记。"); + } + if (hasModelImage) { const modelScreenCount = screens.filter((screen) => screen.useModel).length; if (modelScreenCount === 0) { @@ -694,6 +787,159 @@ function evaluatePlanQuality(params: { }; } +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.", + "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屏”“本屏展示”“这一屏”等规划词。", + "必须维持 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); + 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(); @@ -714,7 +960,7 @@ export async function POST(request: Request) { const bundleImageUrls = sanitizeBundleImages(body.bundleImageUrls); const hasModelImage = /^https?:\/\//i.test(modelImageUrl); const hasBundleImages = bundleImageUrls.length > 0; - const count = hasBundleImages ? Math.min(13, baseCount + 1) : baseCount; + const 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) { @@ -834,12 +1080,14 @@ export async function POST(request: Request) { `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + `8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` + `9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` + + `10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` + + `11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` + (hasModelImage - ? `10) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` - : `10) 未提供模特图:所有分镜均设置 useModel=false。\n`) + + ? `12) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` + : `12) 未提供模特图:所有分镜均设置 useModel=false。\n`) + (hasBundleImages - ? `11) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` - : `11) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + + ? `13) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` + : `13) 未提供搭配产品图:所有分镜均设置 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, @@ -847,7 +1095,7 @@ export async function POST(request: Request) { } as any); const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || ""); - const globalStyle = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); + const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens).map((item) => hasModelImage ? item @@ -863,20 +1111,67 @@ export async function POST(request: Request) { : { ...item, bundleShowcase: false, - bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", - } + bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", + } ); - const qualityChecks = evaluatePlanQuality({ + const qualityChecksInitial = evaluatePlanQuality({ screens: normalizedScreens, hasModelImage, hasBundleImages, }); - const screens = qualityChecks.passed ? normalizedScreens : fallback.screens; + 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 = qualityChecks.passed + const summary = !shouldFallback ? summaryRaw || fallback.summary - : `${summaryRaw || fallback.summary}\n质量校验未通过,已自动回退到稳定分镜模板。`; + : `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`; const response: DetailPlanResponse = { summary, @@ -893,6 +1188,13 @@ export async function POST(request: Request) { 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, diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts new file mode 100644 index 000000000..d756e2e72 --- /dev/null +++ b/app/api/canvas/detail-review/route.ts @@ -0,0 +1,332 @@ +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 = 60; + +type DetailReviewRequest = { + outputLanguage?: string; + imageUrl?: string; + referenceImages?: string[]; + screen?: { + index?: number; + title?: string; + subtitle?: string; + copy?: string; + layoutNote?: string; + visualPrompt?: string; + screenType?: string; + }; + brief?: { + productName?: string; + targetAudience?: string; + usageScenario?: string; + coreNeed?: string; + coreSellingPoint?: string; + }; + globalStyle?: { + designLanguage?: string; + typography?: { + fontFamily?: string; + title?: string; + subtitle?: string; + body?: string; + }; + layout?: { + grid?: string; + safeArea?: string; + rhythm?: string; + }; + palette?: string; + }; +}; + +function supportsVisionReview(model: { + supportsVision?: boolean | null; + capabilities?: unknown; + defaultParams?: unknown; +}) { + return ( + Boolean(model.supportsVision) || + isAiModelMultimodal({ + capabilities: model.capabilities, + defaultParams: model.defaultParams, + }) + ); +} + +function normalizeLanguage(value: unknown): "zh" | "en" { + const raw = String(value || "").toLowerCase(); + if (raw.includes("en") || raw.includes("english")) return "en"; + return "zh"; +} + +function sanitizeImageUrls(value: unknown, limit = 8): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + .slice(0, limit); +} + +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 buildFallbackPrompt(params: { + screenPrompt: string; + layoutNote: string; + language: "zh" | "en"; +}) { + if (params.language === "en") { + return [ + params.screenPrompt, + "Second-pass optimization: improve typography hierarchy and layout readability.", + "Keep fixed canvas 790x1300.", + "Typography lock: title 64px/700, subtitle 34px/600, body 24px/500, keep the same hierarchy used in all other frames.", + "Text safe-area lock: y=64~360, avoid text overlap with product hero.", + params.layoutNote ? `Layout fix note: ${params.layoutNote}` : "", + "Do not put 'Screen X' text in the title.", + "Marketing conversion lock: pain point -> benefit -> trust proof path must be immediately understandable.", + "Preserve product identity and style consistency with previous frames.", + ] + .filter(Boolean) + .join("\n"); + } + return [ + params.screenPrompt, + "二次修图要求:重点优化标题层级、版式留白与可读性。", + "画布固定 790x1300。", + "字体锁定:主标题64px/700,副标题34px/600,正文24px/500,全套层级与其它屏一致。", + "文字安全区锁定:y=64~360,避免与产品主体重叠。", + params.layoutNote ? `排版修正建议:${params.layoutNote}` : "", + "主标题不得出现“第X屏”等编号词。", + "营销转化锁定:用户能快速读懂“痛点-利益-证据”路径,避免只好看不转化。", + "保持产品外观与整体风格连续一致。", + ] + .filter(Boolean) + .join("\n"); +} + +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 DetailReviewRequest; + const language = normalizeLanguage(body.outputLanguage); + const imageUrl = String(body.imageUrl || "").trim(); + if (!/^https?:\/\//i.test(imageUrl)) { + return NextResponse.json({ success: false, error: "缺少待质检图片" }, { status: 400 }); + } + + const screenPrompt = String(body.screen?.visualPrompt || "").trim(); + const layoutNote = String(body.screen?.layoutNote || "").trim(); + const referenceImages = sanitizeImageUrls(body.referenceImages); + const allModels = await getEnabledAiModelsWithProvider(false); + const agentModel = await getAgentAiModelWithProvider(); + const reviewCompatible = allModels.filter( + (item) => + !item.model.isSubModel && + item.model.isEnabled && + ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type), + ); + + let reviewerConfig = + agentModel && + reviewCompatible.some((item) => item.model.id === agentModel.model.id) + ? reviewCompatible.find((item) => item.model.id === agentModel.model.id) + : undefined; + if (!reviewerConfig || !supportsVisionReview(reviewerConfig.model)) { + reviewerConfig = reviewCompatible.find((item) => supportsVisionReview(item.model)); + } + + const fallbackPrompt = buildFallbackPrompt({ + screenPrompt, + layoutNote, + language, + }); + + if (!reviewerConfig) { + return NextResponse.json({ + success: true, + data: { + pass: true, + score: 80, + designDirectorScore: 80, + marketingDirectorScore: 80, + issues: [], + layoutFixes: [], + revisedPrompt: fallbackPrompt, + model: null, + }, + }); + } + + const model = await getLanguageModelById(reviewerConfig.model.id); + if (!model) { + return NextResponse.json({ + success: true, + data: { + pass: true, + score: 80, + designDirectorScore: 80, + marketingDirectorScore: 80, + issues: [], + layoutFixes: [], + revisedPrompt: fallbackPrompt, + model: null, + }, + }); + } + + const promptParts = [ + language === "en" + ? "Review this generated e-commerce detail frame and decide whether a second-pass regeneration is needed. Use dual-director standards." + : "请检查这张已生成的电商详情图,判断是否需要二次修图。按双总监标准审查。", + language === "en" + ? "Role A (Art Director): check visual hierarchy, typography consistency, spacing rhythm, composition balance, and premium design quality." + : "角色A(平面设计总监):检查视觉层级、字体统一性、留白节奏、构图平衡、设计高级感。", + language === "en" + ? "Role B (Marketing Director): check conversion clarity, pain-point to benefit translation, trust proof strength, and purchase-driving message clarity." + : "角色B(营销总监):检查转化清晰度、痛点到买点转译、证据可信度、购买驱动表达。", + language === "en" ? "Input constraints:" : "输入约束:", + `- Canvas fixed: 790x1300`, + `- Title: ${String(body.globalStyle?.typography?.title || "").trim()}`, + `- Subtitle: ${String(body.globalStyle?.typography?.subtitle || "").trim()}`, + `- Body: ${String(body.globalStyle?.typography?.body || "").trim()}`, + `- Safe area: ${String(body.globalStyle?.layout?.safeArea || "").trim()}`, + `- Rhythm: ${String(body.globalStyle?.layout?.rhythm || "").trim()}`, + `- Screen title: ${String(body.screen?.title || "").trim()}`, + `- Screen subtitle: ${String(body.screen?.subtitle || "").trim()}`, + `- Screen copy: ${String(body.screen?.copy || "").trim()}`, + `- Layout note: ${layoutNote}`, + screenPrompt ? `- Source prompt: ${screenPrompt}` : "", + language === "en" + ? "Return strict JSON: {\"pass\":boolean,\"score\":0-100,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"layoutFixes\":[\"...\"],\"typographyFixes\":[\"...\"],\"conversionFixes\":[\"...\"],\"revisedPrompt\":\"...\"}" + : "仅返回严格JSON:{\"pass\":boolean,\"score\":0-100,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"layoutFixes\":[\"...\"],\"typographyFixes\":[\"...\"],\"conversionFixes\":[\"...\"],\"revisedPrompt\":\"...\"}", + language === "en" + ? "If pass=false, revisedPrompt must be directly usable for image regeneration and include typography and safe-area fixes." + : "若 pass=false,revisedPrompt 必须可直接用于生图,且明确包含字体层级、安全区和营销转化修正。", + ] + .filter(Boolean) + .join("\n"); + + const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [ + { type: "text", text: promptParts }, + { type: "image", image: imageUrl }, + ...referenceImages.map((url) => ({ type: "image" as const, image: url })), + ]; + + const { text } = await generateText({ + model, + messages: [ + { + role: "user", + content, + }, + ], + system: + language === "en" + ? "You are both an Art Director and a Marketing Director for high-conversion e-commerce detail pages. Evaluate with strict professional standards and output JSON only." + : "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。", + temperature: 0.1, + maxTokens: 800, + } as any); + + const parsed = extractJsonBlock<{ + pass?: unknown; + score?: unknown; + designDirectorScore?: unknown; + marketingDirectorScore?: unknown; + issues?: unknown; + layoutFixes?: unknown; + typographyFixes?: unknown; + conversionFixes?: unknown; + revisedPrompt?: unknown; + }>(text || ""); + const designDirectorScoreRaw = Number(parsed?.designDirectorScore); + const marketingDirectorScoreRaw = Number(parsed?.marketingDirectorScore); + const designDirectorScore = Number.isFinite(designDirectorScoreRaw) + ? Math.max(0, Math.min(100, Math.round(designDirectorScoreRaw))) + : 80; + const marketingDirectorScore = Number.isFinite(marketingDirectorScoreRaw) + ? Math.max(0, Math.min(100, Math.round(marketingDirectorScoreRaw))) + : 80; + const scoreRaw = Number(parsed?.score); + const score = Number.isFinite(scoreRaw) + ? Math.max(0, Math.min(100, Math.round(scoreRaw))) + : Math.round((designDirectorScore + marketingDirectorScore) / 2); + const issues = Array.isArray(parsed?.issues) + ? parsed.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 6) + : []; + const layoutFixes = Array.isArray(parsed?.layoutFixes) + ? parsed.layoutFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 6) + : []; + const typographyFixes = Array.isArray(parsed?.typographyFixes) + ? parsed.typographyFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4) + : []; + const conversionFixes = Array.isArray(parsed?.conversionFixes) + ? parsed.conversionFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4) + : []; + const revisedPromptRaw = String(parsed?.revisedPrompt || "").trim(); + const revisedPrompt = + revisedPromptRaw.length > 30 + ? revisedPromptRaw + : [ + fallbackPrompt, + ...layoutFixes.map((item) => `- ${item}`), + ...typographyFixes.map((item) => `- ${item}`), + ...conversionFixes.map((item) => `- ${item}`), + ] + .filter(Boolean) + .join("\n"); + + const passByFlag = typeof parsed?.pass === "boolean" ? parsed.pass : undefined; + const pass = + passByFlag ?? + (issues.length === 0 && + score >= 86 && + designDirectorScore >= 85 && + marketingDirectorScore >= 85); + + return NextResponse.json({ + success: true, + data: { + pass, + score, + designDirectorScore, + marketingDirectorScore, + issues, + layoutFixes: [...layoutFixes, ...typographyFixes, ...conversionFixes].slice(0, 10), + revisedPrompt, + model: { + id: reviewerConfig.model.id, + displayName: reviewerConfig.model.displayName, + providerType: reviewerConfig.provider.type, + }, + }, + }); + } catch (error) { + console.error("[Canvas Detail Review] Error:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "详情质检失败" }, + { status: 500 }, + ); + } +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8bfc9c921..8f0877bf2 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -735,6 +735,13 @@ type DetailPlanResponse = { passed?: boolean; issues?: string[]; }; + preflight?: { + passed?: boolean; + designDirectorScore?: number; + marketingDirectorScore?: number; + issues?: string[]; + rewriteApplied?: boolean; + }; model?: { id: string; displayName: string; @@ -749,6 +756,14 @@ const DETAIL_REVIEW_REGEN_COUNT = 2; const DETAIL_REVIEW_IMAGE_COUNT = 3; const DETAIL_REVIEW_IMAGE_WIDTH = 420; const DETAIL_REVIEW_IMAGE_HEIGHT = 420; +const DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS = 1; +const DETAIL_TYPOGRAPHY_LOCK = { + title: "主标题 64px / 700 / 行高1.15,建议单行最多2行,不得出现“第X屏”", + subtitle: "副标题 34px / 600 / 行高1.25,位于主标题下方16px", + body: "正文 24px / 500 / 行高1.45,段间距10px,每块最多4行", + safeArea: "文字安全区固定 y=64~360,产品主体避免遮挡标题区", + rhythm: "左右边距56px,模块间距24px,全套页面留白节奏一致", +} as const; const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [ { key: "product-1", label: "产品图1", role: "product" }, { key: "product-2", label: "产品图2", role: "product" }, @@ -810,11 +825,11 @@ function deriveSessionTitleFromMessages(messages: ChatMessage[]): string { function extractDetailFrameCount(text: string): number { const normalized = text.replace(/\s+/g, ""); - const matched = normalized.match(/(\d{1,2})张/); - if (!matched) return 5; + const matched = normalized.match(/(\d{1,2})(?:张|屏|页)/); + if (!matched) return 14; const value = Number(matched[1]); - if (!Number.isFinite(value)) return 5; - return Math.min(12, Math.max(5, Math.round(value))); + if (!Number.isFinite(value)) return 14; + return Math.min(20, Math.max(5, Math.round(value))); } type DetailReviewPlan = { @@ -829,6 +844,19 @@ type DetailReviewGroup = { images: Array<{ originalUrl: string; thumbnailUrl: string }>; }; +type DetailScreenReviewResult = { + pass: boolean; + score?: number; + issues?: string[]; + layoutFixes?: string[]; + revisedPrompt?: string; + model?: { + id?: string; + displayName?: string; + providerType?: string; + } | null; +}; + function splitDetailKeywords(value: string, limit = 3): string[] { const raw = String(value || "") .split(/[\n,,、;;|]/) @@ -1100,7 +1128,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF assets: true, archives: true, }); - const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(false); + const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(true); const [detailProgress, setDetailProgress] = useState<{ status: "idle" | "running" | "done" | "error"; stage: string; @@ -2217,6 +2245,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } setSelectedModelId("auto"); setIsDetailGenerationMode(true); + setIsDetailWorkbenchCollapsed(true); return; } @@ -3396,7 +3425,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF "3. 黄金五屏:核心卖点 -> 差异强化 -> 证据收口", "4. FABE文案:每屏输出利益/特征/优势/证据", "5. A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾", - "6. 一致性约束:固定790x1300 + 统一字体层级 + 统一排版风格", + "6. 字体规范:主标题64/700,副标题34/600,正文24/500,全套一致", + "7. 分镜预审:先走平面设计总监+营销总监双审,不通过先重写再放行", + "8. 逐屏质检:每屏先生成再由Agent复检,必要时自动二次修图", "", "正在规划分镜提示词...", ].join("\n"); @@ -3432,6 +3463,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF modelImageHint, bundleImageHint, `输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`, + `字体规范:${DETAIL_TYPOGRAPHY_LOCK.title};${DETAIL_TYPOGRAPHY_LOCK.subtitle};${DETAIL_TYPOGRAPHY_LOCK.body}`, ] .filter(Boolean) .join("\n"); @@ -3439,6 +3471,105 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF let workingArchive: DetailArchive | null = null; let completedDetailImageCount = 0; const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = []; + let detailPlaceholderNodeIds: string[] = []; + const detailPlaceholderSpacing = 24; + const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH; + const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT; + const detailPlaceholderStartX = 120; + const detailPlaceholderStartY = 120; + const expectedPlaceholderCount = + bundleAttachments.length > 0 ? Math.min(20, detailCount + 1) : detailCount; + const patchDetailPlaceholder = ( + index: number, + options: { + label?: string; + status?: "generating" | "completed" | "failed"; + src?: string; + prompt?: string; + title?: string; + metadata?: Record; + } = {} + ) => { + const nodeId = detailPlaceholderNodeIds[index]; + if (!nodeId) return; + const storeState = useCanvasStore.getState(); + const currentNode = storeState.getNode(nodeId); + const currentData = ((currentNode?.data as any) || {}) as Record; + const currentMetadata = ((currentData.metadata as any) || {}) as Record; + const currentStatus = + currentData.status === "completed" || currentData.status === "failed" + ? currentData.status + : "generating"; + const nextStatus = options.status || currentStatus; + updateNode(nodeId, { + size: { + width: detailPlaceholderItemWidth, + height: detailPlaceholderItemHeight, + }, + data: { + ...currentData, + src: String(options.src ?? currentData.src ?? ""), + status: nextStatus, + prompt: options.prompt ?? currentData.prompt, + title: options.title ?? currentData.title ?? `详情图-${index + 1}`, + metadata: { + ...currentMetadata, + detailSectionIndex: index + 1, + detailTargetWidth: DETAIL_TARGET_WIDTH, + detailTargetHeight: DETAIL_TARGET_HEIGHT, + detailGeneratingPlaceholder: nextStatus !== "completed", + detailProgressLabel: + options.label ?? + currentMetadata.detailProgressLabel ?? + (nextStatus === "failed" ? "生成失败" : "平面设计中..."), + ...(options.metadata || {}), + }, + }, + }); + }; + const ensureDetailPlaceholders = (count: number, defaultLabel: string) => { + for (let index = detailPlaceholderNodeIds.length; index < count; index += 1) { + const column = index % 3; + const row = Math.floor(index / 3); + const x = + detailPlaceholderStartX + + column * (detailPlaceholderItemWidth + detailPlaceholderSpacing); + const y = + detailPlaceholderStartY + + row * (detailPlaceholderItemHeight + detailPlaceholderSpacing); + const nodeId = addNode({ + type: "image", + position: { x, y }, + size: { width: detailPlaceholderItemWidth, height: detailPlaceholderItemHeight }, + data: { + src: "", + status: "generating", + prompt: detailPrompt, + title: `详情图-${index + 1}`, + metadata: { + detailSectionIndex: index + 1, + detailTargetWidth: DETAIL_TARGET_WIDTH, + detailTargetHeight: DETAIL_TARGET_HEIGHT, + detailGeneratingPlaceholder: true, + detailProgressLabel: defaultLabel, + }, + }, + connections: [], + }); + detailPlaceholderNodeIds.push(nodeId); + } + }; + const markExtraPlaceholders = (startIndex: number, label: string) => { + for (let index = startIndex; index < detailPlaceholderNodeIds.length; index += 1) { + patchDetailPlaceholder(index, { + status: "failed", + label, + metadata: { + detailSkipped: true, + }, + }); + } + }; setIsDetailWorkbenchCollapsed(true); setIsDetailGenerating(true); @@ -3446,8 +3577,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF status: "running", stage: "Agent 正在解析需求并规划详情结构...", current: 0, - total: Math.max(1, detailCount + 1 + DETAIL_REVIEW_COUNT), + total: Math.max(1, detailCount * 2 + 1 + DETAIL_REVIEW_COUNT), }); + ensureDetailPlaceholders(expectedPlaceholderCount, "需求解析与分镜规划中..."); try { workingArchive = await saveDetailArchiveSnapshot({ summary: `进行中:已完成 0/${detailCount} 张详情图`, @@ -3537,16 +3669,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF language: outputLanguage, count: DETAIL_REVIEW_COUNT, }); - const totalSteps = prompts.length + 1 + reviewPlans.length; + const totalSteps = prompts.length * 2 + 1 + reviewPlans.length; const qualityIssues = Array.isArray(planData.qualityChecks?.issues) ? planData.qualityChecks?.issues.filter(Boolean) : []; + const preflightIssues = Array.isArray(planData.preflight?.issues) + ? planData.preflight?.issues.filter(Boolean) + : []; + const preflightDesignScore = Number(planData.preflight?.designDirectorScore || 0); + const preflightMarketingScore = Number(planData.preflight?.marketingDirectorScore || 0); + const preflightPassed = planData.preflight?.passed !== false; setDetailProgress({ status: "running", stage: planData.qualityChecks?.passed === false - ? `结构质检提示:${qualityIssues[0] || "已自动切换为稳定方案"}` - : "结构质检通过,开始逐屏生成", + ? `分镜预审未通过:${qualityIssues[0] || "已自动切换为稳定方案"}` + : preflightPassed + ? `双总监预审通过(设计${preflightDesignScore || "--"} / 营销${preflightMarketingScore || "--"}),开始逐屏生成` + : `双总监预审提示:${preflightIssues[0] || "已自动重写分镜后放行"}`, current: 1, total: totalSteps, }); @@ -3558,6 +3698,44 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF siteSettings?.canvasAutoModeDefaultModelId || siteSettings?.canvasDefaultModelId || undefined; + ensureDetailPlaceholders(prompts.length, "需求解析与分镜规划中..."); + if (detailPlaceholderNodeIds.length > prompts.length) { + markExtraPlaceholders(prompts.length, "本轮未使用"); + } + for (let index = 0; index < prompts.length; index += 1) { + const screen = screenSpecs[index]; + patchDetailPlaceholder(index, { + label: "分镜规划完成", + status: "generating", + prompt: String(screen?.visualPrompt || prompts[index] || "").trim(), + title: `详情图-${index + 1}`, + metadata: { + detailTitle: screen?.title || "", + detailSubtitle: screen?.subtitle || "", + detailScreenType: screen?.screenType || "", + detailLayoutNote: screen?.layoutNote || "", + detailPlanSummary: planData.summary || "", + detailGlobalStyle: planData.globalStyle || null, + detailPreflight: planData.preflight || null, + detailGeneratingPlaceholder: true, + }, + }); + } + const preflightLabel = planData.preflight?.rewriteApplied + ? "双总监预审重写完成" + : "双总监预审通过"; + for (let index = 0; index < prompts.length; index += 1) { + patchDetailPlaceholder(index, { + label: preflightLabel, + status: "generating", + }); + } + for (let index = 0; index < prompts.length; index += 1) { + patchDetailPlaceholder(index, { + label: `排队待生成 ${index + 1}/${prompts.length}`, + status: "generating", + }); + } for (let index = 0; index < prompts.length; index += 1) { const screen = screenSpecs[index]; const useModelForScene = Boolean(screen?.useModel); @@ -3565,10 +3743,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const bundleReason = String((screen as any)?.bundleReason || "").trim(); const promptText = String(screen?.visualPrompt || "").trim() || prompts[index]!; + const generationStepCurrent = 2 + index * 2; + const reviewStepCurrent = generationStepCurrent + 1; + patchDetailPlaceholder(index, { + label: `主图首稿生成中 ${index + 1}/${prompts.length}`, + status: "generating", + prompt: promptText, + }); setDetailProgress({ status: "running", stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`, - current: index + 2, + current: generationStepCurrent, total: totalSteps, }); const globalStyle = planData.globalStyle; @@ -3586,6 +3771,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF globalStyle?.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "", globalStyle?.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "", globalStyle?.palette ? `配色:${globalStyle.palette}` : "", + `固定字体锁定:${DETAIL_TYPOGRAPHY_LOCK.title}`, + `固定副标题锁定:${DETAIL_TYPOGRAPHY_LOCK.subtitle}`, + `固定正文锁定:${DETAIL_TYPOGRAPHY_LOCK.body}`, + `固定排版安全区:${DETAIL_TYPOGRAPHY_LOCK.safeArea}`, + `固定留白节奏:${DETAIL_TYPOGRAPHY_LOCK.rhythm}`, ] .filter(Boolean) .join(";"); @@ -3597,7 +3787,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF parts: [ { type: "text", - text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 张...`, + text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`, }, ], } @@ -3628,39 +3818,157 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "", screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "", `输出为竖版电商详情图,尺寸固定 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素。`, - "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。", + "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。", ] .filter(Boolean) .join("\n\n"); const styleAnchorUrl = index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null; - const generationReferenceImages = [ + const generationReferenceImagesBase = [ ...productAttachments.map((item) => item.url), ...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []), ...(useModelForScene ? [modelAttachment.url] : []), ...(styleAnchorUrl ? [styleAnchorUrl] : []), ].filter(Boolean); - const generationResponse = await fetch("/api/canvas/generate/image", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - prompt: generationPrompt, - model: modelForGeneration, - aspectRatio: "9:16", - imageCount: 1, - negativePrompt: imageParams.negativePrompt, - referenceImage: productAttachments[0]?.url, - referenceImages: generationReferenceImages, - }), - }); - const generationResult = await generationResponse.json(); + const generateDetailFrame = async ( + promptOverride: string, + extraReferenceImages: string[] = [] + ): Promise<{ originalUrl: string; thumbnailUrl: string }> => { + const generationReferenceImages = [ + ...generationReferenceImagesBase, + ...extraReferenceImages, + ].filter(Boolean); + const response = await fetch("/api/canvas/generate/image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: promptOverride, + model: modelForGeneration, + aspectRatio: "9:16", + imageCount: 1, + negativePrompt: imageParams.negativePrompt, + referenceImage: productAttachments[0]?.url, + referenceImages: generationReferenceImages, + }), + }); + const result = await response.json(); + if (!response.ok || !result?.success || !Array.isArray(result?.data?.images) || result.data.images.length === 0) { + throw new Error(result?.error || `第 ${index + 1} 张生成失败`); + } + const generatedImage = result.data.images[0]; + return { + originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").trim(), + thumbnailUrl: String(generatedImage?.thumbnailUrl || generatedImage?.originalUrl || "").trim(), + }; + }; - if (!generationResponse.ok || !generationResult.success || !generationResult.data?.images?.length) { - throw new Error(generationResult.error || `第 ${index + 1} 张生成失败`); + const firstPassImage = await generateDetailFrame(generationPrompt); + let finalGenerated = firstPassImage; + let finalGenerationPrompt = generationPrompt; + let screenReviewResult: DetailScreenReviewResult | null = null; + patchDetailPlaceholder(index, { + label: `双总监审稿中 ${index + 1}/${prompts.length}`, + status: "generating", + }); + + setDetailProgress({ + status: "running", + stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, + current: reviewStepCurrent, + total: totalSteps, + }); + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + parts: [ + { + type: "text", + text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`, + }, + ], + } + : message, + ), + ); + + try { + const reviewResponse = await fetch("/api/canvas/detail-review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + outputLanguage, + imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl, + referenceImages: generationReferenceImagesBase.slice(0, 6), + brief, + globalStyle: planData.globalStyle, + screen: { + index: index + 1, + title: screen?.title || "", + subtitle: screen?.subtitle || "", + copy: screen?.copy || "", + layoutNote: screen?.layoutNote || "", + visualPrompt: generationPrompt, + screenType: screen?.screenType || "", + }, + }), + }); + const reviewResult = await reviewResponse.json().catch(() => ({})); + if (reviewResponse.ok && reviewResult?.success) { + screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult; + } + } catch (reviewError) { + console.error("[CanvasChat] Detail screen review failed:", reviewError); } - const generated = generationResult.data.images[0]!; - generatedImages.push(generated); + const shouldRegenerate = + screenReviewResult?.pass === false && + typeof screenReviewResult?.revisedPrompt === "string" && + screenReviewResult.revisedPrompt.trim().length > 20; + + if (shouldRegenerate) { + patchDetailPlaceholder(index, { + label: `二次优化生成中 ${index + 1}/${prompts.length}`, + status: "generating", + }); + const issueHint = Array.isArray(screenReviewResult?.issues) + ? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";") + : ""; + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + parts: [ + { + type: "text", + text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`, + }, + ], + } + : message, + ), + ); + + for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) { + try { + const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim(); + const regenerated = await generateDetailFrame(revisedPrompt, [ + firstPassImage.originalUrl || firstPassImage.thumbnailUrl, + ]); + finalGenerated = regenerated; + finalGenerationPrompt = revisedPrompt; + break; + } catch (regenError) { + if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) { + console.error("[CanvasChat] Detail second-pass generation failed:", regenError); + } + } + } + } + + generatedImages.push(finalGenerated); completedDetailImageCount = generatedImages.length; if (workingArchive?.id) { @@ -3682,45 +3990,54 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } const dimensions = await getImageDimensions( - generated.originalUrl || generated.thumbnailUrl + finalGenerated.originalUrl || finalGenerated.thumbnailUrl ); - const spacing = 24; - const itemWidth = DETAIL_TARGET_WIDTH; - const itemHeight = DETAIL_TARGET_HEIGHT; - const column = index % 3; - const row = Math.floor(index / 3); - const x = 120 + column * (itemWidth + spacing); - const y = 120 + row * (itemHeight + spacing); - addNode({ - type: "image", - position: { x, y }, - size: { width: itemWidth, height: itemHeight }, - data: { - src: generated.originalUrl || generated.thumbnailUrl, - status: "completed", - prompt: promptText, - title: `详情图-${index + 1}`, - metadata: { - originalWidth: dimensions.originalWidth, - originalHeight: dimensions.originalHeight, - generationPrompt, - detailSectionIndex: index + 1, - detailPlanSummary: planData.summary || "", - detailTargetWidth: DETAIL_TARGET_WIDTH, - detailTargetHeight: DETAIL_TARGET_HEIGHT, - detailTitle: screen?.title || "", - detailSubtitle: screen?.subtitle || "", - detailScreenType: screen?.screenType || "", - detailLayoutNote: screen?.layoutNote || "", - detailUseModel: useModelForScene, - detailModelReason: screen?.modelReason || "", - detailBundleShowcase: bundleShowcase, - detailBundleReason: bundleReason, - detailGlobalStyle: planData.globalStyle || null, + const targetNodeId = detailPlaceholderNodeIds[index]; + if (targetNodeId) { + const storeState = useCanvasStore.getState(); + const currentNode = storeState.getNode(targetNodeId); + const currentData = ((currentNode?.data as any) || {}) as Record; + updateNode(targetNodeId, { + size: { + width: DETAIL_TARGET_WIDTH, + height: DETAIL_TARGET_HEIGHT, }, - }, - connections: [], - }); + data: { + ...currentData, + src: finalGenerated.originalUrl || finalGenerated.thumbnailUrl, + status: "completed", + prompt: promptText, + title: `详情图-${index + 1}`, + metadata: { + ...(currentData.metadata || {}), + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + generationPrompt: finalGenerationPrompt, + detailSectionIndex: index + 1, + detailPlanSummary: planData.summary || "", + detailTargetWidth: DETAIL_TARGET_WIDTH, + detailTargetHeight: DETAIL_TARGET_HEIGHT, + detailTitle: screen?.title || "", + detailSubtitle: screen?.subtitle || "", + detailScreenType: screen?.screenType || "", + detailLayoutNote: screen?.layoutNote || "", + detailUseModel: useModelForScene, + detailModelReason: screen?.modelReason || "", + detailBundleShowcase: bundleShowcase, + detailBundleReason: bundleReason, + detailGlobalStyle: planData.globalStyle || null, + detailScreenReview: screenReviewResult || null, + detailGeneratingPlaceholder: false, + detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`, + }, + }, + }); + } + } + if (generatedImages.length !== prompts.length) { + throw new Error( + `详情主图未完整生成:计划 ${prompts.length} 屏,实际完成 ${generatedImages.length} 屏` + ); } const reviewGroups: DetailReviewGroup[] = []; @@ -3734,7 +4051,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) { const reviewPlan = reviewPlans[reviewIndex]!; - const reviewStepCurrent = prompts.length + 2 + reviewIndex; + const reviewStepCurrent = prompts.length * 2 + 2 + reviewIndex; setDetailProgress({ status: "running", stage: `正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}`, @@ -3761,6 +4078,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF reviewPlan.imagePrompt, `评价文本:${reviewPlan.reviewText}`, "输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。", + "人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。", `同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图要保持同一叙事主题但角度不同。`, "图片内容要看得见产品主体和真实使用痕迹,适合放在详情页评价区。", ] @@ -3770,7 +4088,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const reviewReferenceImages = [ ...productAttachments.map((item) => item.url), ...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []), - modelAttachment.url, ].filter(Boolean); const reviewResponse = await fetch("/api/canvas/generate/image", { @@ -3875,16 +4192,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF status: "done", stage: reviewFailures.length > 0 - ? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条` - : `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`, + ? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条` + : `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`, current: totalSteps, total: totalSteps, }); const finalSummary = planData.summary || (reviewFailures.length > 0 - ? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条` - : `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`); + ? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条` + : `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`); if (workingArchive?.id) { const finalArchive: DetailArchive = { ...workingArchive, @@ -3912,15 +4229,37 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if (reviewFailures.length > 0) { toast.warning( - `详情图完成 ${generatedImages.length} 张,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条` + `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条` ); } else { toast.success( - `AI详情生成完成:详情图 ${generatedImages.length} 张,评价晒图 ${reviewGroups.length} 条` + `AI详情生成完成:详情主图 ${generatedImages.length}/${prompts.length} 屏,评价晒图 ${reviewGroups.length} 条` ); } } catch (error) { const errMsg = error instanceof Error ? error.message : "AI详情生成失败"; + if (detailPlaceholderNodeIds.length > 0) { + for (let index = completedDetailImageCount; index < detailPlaceholderNodeIds.length; index += 1) { + const nodeId = detailPlaceholderNodeIds[index]; + if (!nodeId) continue; + const storeState = useCanvasStore.getState(); + const currentNode = storeState.getNode(nodeId); + const currentData = ((currentNode?.data as any) || {}) as Record; + if (currentData.status === "completed") continue; + updateNode(nodeId, { + data: { + ...currentData, + src: String(currentData.src || ""), + status: "failed", + metadata: { + ...(currentData.metadata || {}), + detailGeneratingPlaceholder: false, + detailFailedReason: errMsg, + }, + }, + }); + } + } if (workingArchive?.id) { const failedSummary = `异常中断:已完成 ${completedDetailImageCount} 张详情图,错误:${errMsg}`; const failedArchive: DetailArchive = { @@ -4000,12 +4339,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("请先补齐5张产品图,再追加评价晒图"); return; } - const modelAttachment = - DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null; - if (!modelAttachment?.url) { - toast.error("请先补齐模特图,再追加评价晒图"); - return; - } const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES .map((index) => detailSlots[index]) .filter((item): item is PickedObjectAttachment => Boolean(item?.url)); @@ -4099,6 +4432,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF reviewPlan.imagePrompt, `评价文本:${reviewPlan.reviewText}`, "输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。", + "人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。", `同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图保持同一叙事主题但角度不同。`, "图片内容需要清晰展示产品主体与使用场景,可直接用于详情页评价区。", ] @@ -4107,7 +4441,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const reviewReferenceImages = [ ...productAttachments.map((item) => item.url), ...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []), - modelAttachment.url, ].filter(Boolean); const reviewResponse = await fetch("/api/canvas/generate/image", { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 63613dabf..0cfe31ac3 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -229,6 +229,10 @@ interface ImageNodeProps { export function ImageNode({ node, isSelected }: ImageNodeProps) { const data = node.data as ImageNodeData; + const detailProgressLabel = + typeof data.metadata?.detailProgressLabel === "string" + ? data.metadata.detailProgressLabel.trim() + : ""; const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); const [isHovered, setIsHovered] = useState(false); const [isProcessing, setIsProcessing] = useState(false); @@ -1541,7 +1545,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { ) : data.status === 'generating' ? (
- 生成中... + + {detailProgressLabel || "生成中..."} +
) : data.status === 'failed' ? (