feat: strengthen ai detail planning review and progress placeholders
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-22 01:10:49 +08:00
parent 9f66a5ea28
commit 2e837b3769
4 changed files with 1098 additions and 125 deletions

View File

@ -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<string, unknown>;
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,

View File

@ -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<T = Record<string, unknown>>(text: string): T | null {
const matched = text.match(/\{[\s\S]*\}/);
if (!matched) return null;
try {
return JSON.parse(matched[0]) as T;
} catch {
return null;
}
}
function 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 },
);
}
}

View File

@ -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<string, unknown>;
} = {}
) => {
const nodeId = detailPlaceholderNodeIds[index];
if (!nodeId) return;
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
const currentMetadata = ((currentData.metadata as any) || {}) as Record<string, any>;
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<string, any>;
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<string, any>;
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", {

View File

@ -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' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
<span className="text-sm text-muted-foreground text-center px-3">
{detailProgressLabel || "生成中..."}
</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">