feat: strengthen ai detail planning review and progress placeholders
This commit is contained in:
parent
9f66a5ea28
commit
2e837b3769
@ -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,
|
||||
|
||||
332
app/api/canvas/detail-review/route.ts
Normal file
332
app/api/canvas/detail-review/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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", {
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user