1900 lines
74 KiB
TypeScript
1900 lines
74 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { generateText } from "ai";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
getAgentAiModelWithProvider,
|
||
getEnabledAiModelsWithProvider,
|
||
} from "@/lib/db/queries";
|
||
import {
|
||
getAiModelAgentCodingModelId,
|
||
isAiModelMultimodal,
|
||
} from "@/lib/db/ai-model-flags";
|
||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 180;
|
||
|
||
function isAbortError(error: unknown): boolean {
|
||
const target = error as {
|
||
name?: unknown;
|
||
message?: unknown;
|
||
code?: unknown;
|
||
ABORT_ERR?: unknown;
|
||
};
|
||
const name = String(target?.name || "");
|
||
const message = String(target?.message || "");
|
||
const code = Number(target?.code);
|
||
const abortCode = Number(target?.ABORT_ERR);
|
||
return (
|
||
name === "AbortError" ||
|
||
code === 20 ||
|
||
abortCode === 20 ||
|
||
/aborted|abort/i.test(message)
|
||
);
|
||
}
|
||
|
||
function supportsVisionPlanning(model: {
|
||
supportsVision?: boolean | null;
|
||
capabilities?: unknown;
|
||
defaultParams?: unknown;
|
||
}) {
|
||
return (
|
||
Boolean(model.supportsVision) ||
|
||
isAiModelMultimodal({
|
||
capabilities: model.capabilities,
|
||
defaultParams: model.defaultParams,
|
||
})
|
||
);
|
||
}
|
||
|
||
function isVolcanoResponsesPlanner(config: {
|
||
provider?: { type?: string | null; baseUrl?: string | null } | null;
|
||
}) {
|
||
if (!config?.provider || config.provider.type !== "custom") return false;
|
||
const baseUrl = String(config.provider.baseUrl || "").toLowerCase();
|
||
return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl);
|
||
}
|
||
|
||
type DetailPlanRequest = {
|
||
prompt?: string;
|
||
count?: number;
|
||
outputLanguage?: string;
|
||
referenceImages?: string[];
|
||
referenceImageNotes?: string[];
|
||
modelImageUrl?: string;
|
||
bundleImageUrls?: string[];
|
||
brief?: {
|
||
productSpu?: string;
|
||
demandType?: string;
|
||
productName?: string;
|
||
targetAudience?: string;
|
||
mainUser?: string;
|
||
usageScenario?: string;
|
||
triggerMoment?: string;
|
||
coreNeed?: string;
|
||
riskConcern?: string;
|
||
expectedResult?: string;
|
||
coreSellingPoint?: string;
|
||
positioningStatement?: string;
|
||
userVoice?: string;
|
||
competitorReference?: string;
|
||
priceBand?: string;
|
||
brandTone?: string;
|
||
bannedWords?: string;
|
||
};
|
||
};
|
||
|
||
type DetailBrief = {
|
||
productSpu: string;
|
||
demandType: string;
|
||
productName: string;
|
||
targetAudience: string;
|
||
mainUser: string;
|
||
usageScenario: string;
|
||
triggerMoment: string;
|
||
coreNeed: string;
|
||
riskConcern: string;
|
||
expectedResult: string;
|
||
coreSellingPoint: string;
|
||
positioningStatement: string;
|
||
userVoice: string;
|
||
competitorReference: string;
|
||
priceBand: string;
|
||
brandTone: string;
|
||
bannedWords: string;
|
||
};
|
||
|
||
type DetailScreenType = "A" | "B" | "C";
|
||
|
||
type DetailPlanScreen = {
|
||
index: number;
|
||
screenType: DetailScreenType;
|
||
objective: string;
|
||
title: string;
|
||
subtitle: string;
|
||
copy: string;
|
||
layoutNote: string;
|
||
useModel: boolean;
|
||
modelReason: string;
|
||
bundleShowcase: boolean;
|
||
bundleReason: string;
|
||
visualPrompt: string;
|
||
fabe: {
|
||
b: string;
|
||
f: string;
|
||
a: string;
|
||
e: string;
|
||
};
|
||
};
|
||
|
||
type DetailPlanResponse = {
|
||
summary: string;
|
||
prompts: string[];
|
||
screenSize: {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
globalStyle: {
|
||
designLanguage: string;
|
||
typography: {
|
||
fontFamily: string;
|
||
title: string;
|
||
subtitle: string;
|
||
body: string;
|
||
};
|
||
layout: {
|
||
grid: string;
|
||
safeArea: string;
|
||
rhythm: string;
|
||
};
|
||
palette: string;
|
||
};
|
||
skillFlow: {
|
||
name: string;
|
||
version: string;
|
||
steps: string[];
|
||
};
|
||
qualityChecks: {
|
||
passed: boolean;
|
||
issues: string[];
|
||
};
|
||
preflight?: {
|
||
passed: boolean;
|
||
designDirectorScore: number;
|
||
marketingDirectorScore: number;
|
||
issues: string[];
|
||
rewriteApplied: boolean;
|
||
};
|
||
screens: DetailPlanScreen[];
|
||
model?: {
|
||
id: string;
|
||
displayName: string;
|
||
providerType: string;
|
||
};
|
||
};
|
||
|
||
type DetailPlanPreflight = {
|
||
passed: boolean;
|
||
designDirectorScore: number;
|
||
marketingDirectorScore: number;
|
||
issues: string[];
|
||
rewriteApplied: boolean;
|
||
};
|
||
|
||
type DetailCoreFocusContext = {
|
||
anchor: string;
|
||
primaryNeed: string;
|
||
namingToken: string;
|
||
firstScreenTitle: string;
|
||
firstScreenSubtitle: string;
|
||
focusTerms: string[];
|
||
};
|
||
|
||
const AGENT_PLANNING_PHRASE_PATTERNS = [
|
||
/第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
|
||
/本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
|
||
/这一屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
|
||
/第\s*[0-9]+\s*屏(?:展示|讲|说明|介绍|主要|重点)?/gi,
|
||
/\bscreen\s*[0-9]+\s*(shows?|focuses on|introduces?)?/gi,
|
||
/第一屏展示啥/gi,
|
||
];
|
||
|
||
const TITLE_META_TERMS = [
|
||
"核心价值",
|
||
"场景痛点",
|
||
"方案拆解",
|
||
"细节证明",
|
||
"信任收口",
|
||
"卖点延展",
|
||
"解决方案",
|
||
"价值延展",
|
||
"证据背书",
|
||
];
|
||
|
||
function hasPlanningTitleMeta(value: string): boolean {
|
||
const text = String(value || "").trim();
|
||
if (!text) return false;
|
||
if (/第\s*\d+\s*屏|screen\s*\d+/i.test(text)) return true;
|
||
return TITLE_META_TERMS.some((term) => text.includes(term));
|
||
}
|
||
|
||
function stripAgentPlanningPhrases(value: string): string {
|
||
let result = value;
|
||
for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) {
|
||
result = result.replace(pattern, " ");
|
||
}
|
||
return result.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function desensitizeAudienceCopy(value: string, language: "zh" | "en"): string {
|
||
let result = String(value || "");
|
||
if (!result) return result;
|
||
|
||
if (language === "en") {
|
||
result = result
|
||
.replace(/\b\d{1,2}\s*(?:-|~|to)\s*\d{1,2}\s*years?\s*old\b/gi, "target users")
|
||
.replace(
|
||
/\b(?:for|toward|targeting)\s+[^,.;\n]{0,48}\b(?:women|men|female|male|audience|users?)\b/gi,
|
||
"for target scenario users"
|
||
)
|
||
.replace(/\b(?:primary audience|target audience)\s*:\s*[^,.;\n]+/gi, "target scenario users");
|
||
} else {
|
||
result = result
|
||
.replace(/\d{1,2}\s*(?:-|~|~|到|至)\s*\d{1,2}\s*岁(?:[男女]性)?/g, "目标用户")
|
||
.replace(
|
||
/(?:面向|针对)[^,。;\n]{0,40}(?:人群|用户|女性|男性|宝妈|白领|学生|新手)/g,
|
||
"围绕目标场景用户"
|
||
)
|
||
.replace(
|
||
/(?:主购买人群|目标人群|适用人群)\s*[::]\s*[^,。;\n]+/g,
|
||
"目标场景用户"
|
||
);
|
||
}
|
||
|
||
return result.replace(/\s+/g, " ").replace(/,{2,}/g, ",").trim();
|
||
}
|
||
|
||
const GENERIC_FLUFF_WORDS_ZH = [
|
||
"高级感",
|
||
"轻奢",
|
||
"超好用",
|
||
"巨能装",
|
||
"适合所有场景",
|
||
"适合所有人",
|
||
];
|
||
|
||
function visibleTitleLength(value: string): number {
|
||
return value
|
||
.replace(/[^\p{L}\p{N}]+/gu, "")
|
||
.trim().length;
|
||
}
|
||
|
||
function splitToCoreTerms(value: string, language: "zh" | "en"): string[] {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return [];
|
||
if (language === "en") {
|
||
return Array.from(
|
||
new Set(
|
||
raw
|
||
.toLowerCase()
|
||
.split(/[^a-z0-9]+/i)
|
||
.map((term) => term.trim())
|
||
.filter((term) => term.length >= 3)
|
||
.slice(0, 6)
|
||
)
|
||
);
|
||
}
|
||
const compact = raw.replace(/\s+/g, "");
|
||
const clauses = compact
|
||
.split(/[,。;、::,.!?!?/|()()\-—]+/g)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
const seed = clauses[0] || compact;
|
||
const terms = new Set<string>();
|
||
if (seed.length >= 2) terms.add(seed.slice(0, Math.min(6, seed.length)));
|
||
if (seed.length >= 4) terms.add(seed.slice(0, 4));
|
||
terms.add(seed);
|
||
return Array.from(terms).filter((term) => term.length >= 2).slice(0, 6);
|
||
}
|
||
|
||
function compactZhTitle(value: string): string {
|
||
const base = String(value || "")
|
||
.replace(/[“”"'`]/g, "")
|
||
.replace(/[,。;、::,.!?!?/|()()\-—\s]+/g, "")
|
||
.trim();
|
||
if (!base) return "核心不妥协";
|
||
if (base.length > 10) return base.slice(0, 10);
|
||
if (base.length < 4) return `${base}升级`;
|
||
return base;
|
||
}
|
||
|
||
function compactEnTitle(value: string): string {
|
||
const words = String(value || "")
|
||
.replace(/[“”"'`]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.split(/[^a-z0-9]+/i)
|
||
.map((word) => word.trim())
|
||
.filter(Boolean);
|
||
if (words.length === 0) return "Core Upgrade";
|
||
return words.slice(0, 4).join(" ");
|
||
}
|
||
|
||
function inferZhProductSuffix(productName: string): string {
|
||
const name = String(productName || "").replace(/\s+/g, "");
|
||
if (!name) return "";
|
||
const rules: Array<{ pattern: RegExp; suffix: string }> = [
|
||
{ pattern: /自拍杆|稳定杆|补光杆/, suffix: "杆" },
|
||
{ pattern: /尿不湿|纸尿裤|拉拉裤|尿裤/, suffix: "裤" },
|
||
{ pattern: /面膜/, suffix: "膜" },
|
||
{ pattern: /精华|精华液/, suffix: "精华" },
|
||
{ pattern: /洗发水|洗发露/, suffix: "露" },
|
||
{ pattern: /沐浴露/, suffix: "露" },
|
||
{ pattern: /牙膏/, suffix: "膏" },
|
||
{ pattern: /椅子|座椅|办公椅|人体工学椅/, suffix: "椅" },
|
||
{ pattern: /枕头|枕/, suffix: "枕" },
|
||
{ pattern: /书桌|桌子|电脑桌/, suffix: "桌" },
|
||
{ pattern: /收纳柜|收纳架|收纳箱/, suffix: "架" },
|
||
];
|
||
const matched = rules.find((rule) => rule.pattern.test(name));
|
||
if (matched) return matched.suffix;
|
||
|
||
const tailMatch = name.match(/[杆裤膜露膏椅枕桌架杯包箱机器锅刷]/g);
|
||
if (tailMatch && tailMatch.length > 0) {
|
||
return tailMatch[tailMatch.length - 1] || "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function inferZhNamingStem(anchor: string, primaryNeed: string): string {
|
||
const source = `${anchor} ${primaryNeed}`.replace(/\s+/g, "");
|
||
const rules: Array<{ pattern: RegExp; stem: string }> = [
|
||
{ pattern: /抗过敏|防过敏|敏感|低敏|温和/, stem: "安敏" },
|
||
{ pattern: /甩|一甩|快速展开/, stem: "甩甩" },
|
||
{ pattern: /防抖|稳定|稳拍/, stem: "稳拍" },
|
||
{ pattern: /轻|便携|小巧/, stem: "轻享" },
|
||
{ pattern: /护腰|贴腰|腰撑/, stem: "护腰" },
|
||
{ pattern: /透气|呼吸感/, stem: "透气" },
|
||
{ pattern: /省空间|收纳/, stem: "省位" },
|
||
{ pattern: /抗菌|抑菌|洁净/, stem: "净护" },
|
||
];
|
||
const matched = rules.find((rule) => rule.pattern.test(source));
|
||
if (matched) return matched.stem;
|
||
|
||
const compact = compactZhTitle(anchor);
|
||
if (!compact) return "安心";
|
||
if (compact.length === 1) return `${compact}${compact}`;
|
||
return compact.slice(0, Math.min(4, compact.length));
|
||
}
|
||
|
||
const ZH_NAME_BANNED_WORDS = [
|
||
"核心",
|
||
"价值",
|
||
"方案",
|
||
"系统",
|
||
"智能",
|
||
"科技",
|
||
"场景",
|
||
"升级",
|
||
"高端",
|
||
"专业",
|
||
];
|
||
|
||
function toColloquialZhStem(stem: string): string {
|
||
let next = String(stem || "").replace(/\s+/g, "");
|
||
if (!next) return "好用";
|
||
const map: Array<{ pattern: RegExp; value: string }> = [
|
||
{ pattern: /快速|秒开|一秒|展开|甩/, value: "甩甩" },
|
||
{ pattern: /防抖|稳定|稳拍|不晃/, value: "稳稳" },
|
||
{ pattern: /抗过敏|安敏|低敏|敏感/, value: "安敏" },
|
||
{ pattern: /透气|不闷|呼吸感/, value: "透透" },
|
||
{ pattern: /防漏/, value: "防漏" },
|
||
{ pattern: /护腰|贴腰|腰撑/, value: "护腰" },
|
||
{ pattern: /省空间|收纳|整洁/, value: "省位" },
|
||
{ pattern: /轻便|小巧|便携/, value: "轻轻" },
|
||
];
|
||
const hit = map.find((item) => item.pattern.test(next));
|
||
if (hit) {
|
||
next = hit.value;
|
||
}
|
||
ZH_NAME_BANNED_WORDS.forEach((token) => {
|
||
next = next.replace(new RegExp(token, "g"), "");
|
||
});
|
||
next = next.replace(/[,。;、::,.!?!?/|()()\-—]/g, "");
|
||
if (!next) return "好用";
|
||
if (next.length === 1) return `${next}${next}`;
|
||
if (next.length > 4) return next.slice(0, 4);
|
||
return next;
|
||
}
|
||
|
||
function buildZhCoreNamingWord(brief: DetailBrief, anchor: string, primaryNeed: string): string {
|
||
const suffix = inferZhProductSuffix(brief.productName);
|
||
let stem = toColloquialZhStem(inferZhNamingStem(anchor, primaryNeed));
|
||
if (!suffix) return compactZhTitle(anchor);
|
||
|
||
if (stem.length > 4) {
|
||
stem = stem.slice(0, 4);
|
||
}
|
||
if (stem.length === 1) {
|
||
stem = `${stem}${stem}`;
|
||
}
|
||
|
||
let title = stem.endsWith(suffix) ? stem : `${stem}${suffix}`;
|
||
if (title.length < 3) {
|
||
title = `${compactZhTitle(anchor).slice(0, 2)}${suffix}`;
|
||
}
|
||
if (title.length > 10) {
|
||
title = title.slice(0, 10);
|
||
}
|
||
return title;
|
||
}
|
||
|
||
function inferZhNeedTag(params: {
|
||
anchor: string;
|
||
primaryNeed: string;
|
||
expectedText: string;
|
||
scenario: string;
|
||
}): string {
|
||
const source = `${params.anchor} ${params.primaryNeed} ${params.expectedText} ${params.scenario}`;
|
||
const rules: Array<{ pattern: RegExp; phrase: string }> = [
|
||
{ pattern: /自拍|拍摄|防抖|不抖|稳拍/, phrase: "自拍不手抖" },
|
||
{ pattern: /过敏|低敏|敏感|红痒/, phrase: "敏肌不红痒" },
|
||
{ pattern: /漏|防漏|整夜|夜用/, phrase: "整夜不漏尿" },
|
||
{ pattern: /腰|护腰|久坐|腰酸/, phrase: "久坐不腰酸" },
|
||
{ pattern: /收纳|省空间|不占地/, phrase: "收纳不占地" },
|
||
{ pattern: /透气|不闷|呼吸感/, phrase: "久用不闷热" },
|
||
{ pattern: /清洁|去污|去渍|好洗/, phrase: "去污不费劲" },
|
||
{ pattern: /便携|轻便|小巧|出门/, phrase: "出门更轻松" },
|
||
];
|
||
const hit = rules.find((item) => item.pattern.test(source));
|
||
if (hit) return hit.phrase;
|
||
|
||
const compactNeed = compactZhTitle(params.expectedText || params.primaryNeed || params.anchor);
|
||
if (!compactNeed) return "用着更省心";
|
||
if (compactNeed.length <= 4) return `${compactNeed}更省心`;
|
||
return `${compactNeed.slice(0, 6)}更省心`;
|
||
}
|
||
|
||
function buildZhNameNeedTitle(params: { namingToken: string; needTag: string }): string {
|
||
const left = String(params.namingToken || "").trim() || "好用款";
|
||
let right = String(params.needTag || "").trim() || "用着更省心";
|
||
right = right.replace(/[。!!??]+$/g, "");
|
||
let title = `${left},${right}`;
|
||
if (title.length > 20) {
|
||
title = `${left},${right.slice(0, Math.max(4, 19 - left.length))}`;
|
||
}
|
||
return title;
|
||
}
|
||
|
||
function buildCoreFocusContext(brief: DetailBrief, language: "zh" | "en"): DetailCoreFocusContext {
|
||
const anchorRaw =
|
||
brief.coreSellingPoint ||
|
||
brief.positioningStatement ||
|
||
brief.coreNeed ||
|
||
(language === "en" ? "core value" : "核心卖点");
|
||
const scenario = brief.usageScenario || (language === "en" ? "daily use" : "日常场景");
|
||
const primaryNeed = brief.coreNeed || (language === "en" ? "key demand" : "核心需求");
|
||
const riskText = brief.riskConcern || (language === "en" ? "extra effort" : "反复折腾");
|
||
const expectedText = brief.expectedResult || primaryNeed;
|
||
|
||
const focusTerms = Array.from(
|
||
new Set([
|
||
...splitToCoreTerms(anchorRaw, language),
|
||
...splitToCoreTerms(primaryNeed, language),
|
||
...splitToCoreTerms(expectedText, language),
|
||
])
|
||
).slice(0, 8);
|
||
|
||
if (language === "en") {
|
||
const title = compactEnTitle(anchorRaw);
|
||
const subtitle = `In ${scenario}, achieve ${expectedText} without ${riskText}.`;
|
||
return {
|
||
anchor: anchorRaw,
|
||
primaryNeed,
|
||
namingToken: title,
|
||
firstScreenTitle: title,
|
||
firstScreenSubtitle: subtitle,
|
||
focusTerms,
|
||
};
|
||
}
|
||
|
||
const namingToken = buildZhCoreNamingWord(brief, anchorRaw, primaryNeed);
|
||
const needTag = inferZhNeedTag({
|
||
anchor: anchorRaw,
|
||
primaryNeed,
|
||
expectedText,
|
||
scenario,
|
||
});
|
||
const title = buildZhNameNeedTitle({ namingToken, needTag });
|
||
const subtitle = `在${scenario}场景,不用${riskText}也能${expectedText}。`;
|
||
return {
|
||
anchor: anchorRaw,
|
||
primaryNeed,
|
||
namingToken,
|
||
firstScreenTitle: title,
|
||
firstScreenSubtitle: subtitle,
|
||
focusTerms: Array.from(
|
||
new Set([
|
||
...focusTerms,
|
||
...splitToCoreTerms(title, language),
|
||
...splitToCoreTerms(namingToken, language),
|
||
...splitToCoreTerms(needTag, language),
|
||
])
|
||
),
|
||
};
|
||
}
|
||
|
||
function containsCoreTerms(text: string, terms: string[]): boolean {
|
||
const source = String(text || "").toLowerCase();
|
||
if (!source || terms.length === 0) return false;
|
||
return terms.some((term) => source.includes(String(term || "").toLowerCase()));
|
||
}
|
||
|
||
function hasFirstScreenPosterSignal(screen?: DetailPlanScreen): boolean {
|
||
if (!screen) return false;
|
||
const merged = `${screen.objective} ${screen.layoutNote} ${screen.visualPrompt}`.toLowerCase();
|
||
return /海报|封面|主视觉|kv|hero|poster|cover/.test(merged);
|
||
}
|
||
|
||
function enforceCoreFocusOnScreens(params: {
|
||
screens: DetailPlanScreen[];
|
||
coreFocus: DetailCoreFocusContext;
|
||
language: "zh" | "en";
|
||
}): DetailPlanScreen[] {
|
||
const { screens, coreFocus, language } = params;
|
||
const namingWord = coreFocus.namingToken || coreFocus.firstScreenTitle;
|
||
return screens.map((screen, index) => {
|
||
const mergedText = `${screen.title} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`;
|
||
const focused = containsCoreTerms(mergedText, coreFocus.focusTerms);
|
||
const promptCoreLine =
|
||
language === "en"
|
||
? `Core selling-point anchor: ${coreFocus.anchor}. This frame must serve this same anchor and avoid topic drift.`
|
||
: `核心卖点锚点:${coreFocus.anchor}。本屏必须服务同一核心卖点,不得偏题。`;
|
||
const firstScreenPosterLine =
|
||
language === "en"
|
||
? "Screen 1 must be a hero poster cover: single dominant product visual, cinematic light, strong focal contrast, clean headline-safe top area, no collage."
|
||
: "首屏必须是封面海报感:单一主视觉主体、电影级光影、强焦点对比、顶部清晰标题安全区,禁止拼贴说明图。";
|
||
|
||
let title = screen.title;
|
||
let subtitle = screen.subtitle;
|
||
let objective = screen.objective;
|
||
let copy = screen.copy;
|
||
let layoutNote = screen.layoutNote;
|
||
let benefit = screen.fabe.b;
|
||
|
||
if (index === 0) {
|
||
title =
|
||
language === "zh"
|
||
? coreFocus.firstScreenTitle
|
||
: hasPlanningTitleMeta(title) || !containsCoreTerms(title, coreFocus.focusTerms)
|
||
? coreFocus.firstScreenTitle
|
||
: title;
|
||
subtitle = containsCoreTerms(`${subtitle} ${copy}`, coreFocus.focusTerms)
|
||
? subtitle
|
||
: coreFocus.firstScreenSubtitle;
|
||
objective =
|
||
language === "en"
|
||
? `Hero poster cover for the One selling-point in one glance, instantly answer why it is worth buying.`
|
||
: "封面海报化呈现The One核心卖点,一眼回答“为什么值得买”。";
|
||
layoutNote =
|
||
language === "en"
|
||
? "Poster composition: product hero occupies 55-70% visual weight; top text safe area is clean; avoid dense multi-module layout."
|
||
: "海报封面构图:产品主体占画面55-70%视觉权重,顶部标题区留白清晰,避免密集小模块拼贴。";
|
||
}
|
||
|
||
if (!containsCoreTerms(objective, coreFocus.focusTerms)) {
|
||
objective =
|
||
language === "en"
|
||
? `${objective}. Keep reinforcing "${coreFocus.anchor}".`
|
||
: `${objective},持续强化“${coreFocus.anchor}”。`;
|
||
}
|
||
|
||
if (!focused) {
|
||
copy =
|
||
language === "en"
|
||
? `${copy} Keep all messaging aligned to "${coreFocus.anchor}" and "${coreFocus.primaryNeed}".`
|
||
: `${copy} 全屏文案围绕“${coreFocus.anchor}”,统一服务“${coreFocus.primaryNeed}”。`;
|
||
benefit =
|
||
language === "en"
|
||
? `Deliver "${coreFocus.primaryNeed}" under "${coreFocus.anchor}" with lower effort and clearer payoff.`
|
||
: `在“${coreFocus.anchor}”下交付“${coreFocus.primaryNeed}”结果,降低决策与使用成本。`;
|
||
}
|
||
|
||
if (
|
||
language === "zh" &&
|
||
index > 0 &&
|
||
index <= 3 &&
|
||
namingWord &&
|
||
!String(`${subtitle} ${copy}`).includes(namingWord)
|
||
) {
|
||
copy = `${copy} 持续强化“${namingWord}”这一记忆点,形成独有搜索词。`;
|
||
}
|
||
|
||
return {
|
||
...screen,
|
||
title,
|
||
subtitle,
|
||
objective,
|
||
copy,
|
||
layoutNote,
|
||
fabe: {
|
||
...screen.fabe,
|
||
b: benefit,
|
||
},
|
||
visualPrompt: (() => {
|
||
let nextPrompt = screen.visualPrompt.includes(coreFocus.anchor)
|
||
? screen.visualPrompt
|
||
: `${screen.visualPrompt} ${promptCoreLine}`;
|
||
if (index === 0 && !/海报|封面|主视觉|kv|hero|poster|cover/i.test(nextPrompt)) {
|
||
nextPrompt = `${nextPrompt} ${firstScreenPosterLine}`;
|
||
}
|
||
return nextPrompt;
|
||
})(),
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildTypographySpec(language: "zh" | "en") {
|
||
if (language === "en") {
|
||
return {
|
||
fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei",
|
||
title:
|
||
"Main title 64px, weight 700, line-height 1.15, max 12 Chinese chars equivalent, single line preferred, at most 2 lines",
|
||
subtitle:
|
||
"Subtitle 34px, weight 600, line-height 1.25, placed 16px below title, max 30 Chinese chars equivalent",
|
||
body:
|
||
"Body text 24px, weight 500, line-height 1.45, 10px paragraph gap, max 4 lines per block",
|
||
safeArea:
|
||
"Top text safe-area starts at 64px and ends within 360px; keep product hero away from text zone",
|
||
rhythm:
|
||
"Horizontal margin 56px, inter-module gap 24px, all screens keep identical spacing rhythm",
|
||
};
|
||
}
|
||
return {
|
||
fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei",
|
||
title:
|
||
"主标题 64px、字重700、行高1.15,建议1-2行,首屏控制在6-20字(命名词+需求句),不得出现“第X屏”",
|
||
subtitle:
|
||
"副标题 34px、字重600、行高1.25,位于主标题下方16px,控制在30字以内",
|
||
body:
|
||
"正文 24px、字重500、行高1.45、段间距10px,每块最多4行,避免密集堆字",
|
||
safeArea: "顶部文字安全区 y=64~360,产品主体避免压住标题区",
|
||
rhythm: "左右边距56px,模块间距24px,全套页面保持相同留白节奏",
|
||
};
|
||
}
|
||
|
||
function buildDetailSkillFlow(language: "zh" | "en") {
|
||
if (language === "en") {
|
||
return {
|
||
name: "AI Detail Generation Skill",
|
||
version: "1.1",
|
||
steps: [
|
||
"Positioning Card: identify buyer, user, scenario, trigger, risk, expected outcome.",
|
||
"Need Decomposition: prioritize core demand and convert feature to buying reason.",
|
||
"Golden Five-Screen Structure: core value, differentiation, trust proof.",
|
||
"FABE Copying: each frame includes Benefit, Feature, Advantage, Evidence.",
|
||
"A/B/C Visual Rhythm: emotional scene + rational proof + conversion closure.",
|
||
"Global Consistency: fixed size, unified typography hierarchy, layout rhythm and style.",
|
||
"Per-frame Quality Review: check title hierarchy, typography, and layout clarity, then regenerate once if needed.",
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
name: "AI详情生成技能",
|
||
version: "1.1",
|
||
steps: [
|
||
"定位卡:明确购买人群、使用者、主场景、触发时刻、顾虑与期望结果。",
|
||
"需求拆解:按核心需求优先级,把卖点转成买点表达。",
|
||
"黄金五屏:前2屏讲核心卖点,3-4屏做差异强化,第5屏证据收口。",
|
||
"FABE文案:每屏都输出利益(B)、特征(F)、优势(A)、证据(E)。",
|
||
"A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾。",
|
||
"全局一致性:固定尺寸、统一字体层级、统一排版节奏与设计风格。",
|
||
"逐屏质检:每屏生成后检查标题层级/版式密度/文字可读性,不达标自动二次修图。",
|
||
],
|
||
};
|
||
}
|
||
|
||
function clampCount(value: unknown): number {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return 14;
|
||
return Math.min(20, Math.max(5, Math.round(num)));
|
||
}
|
||
|
||
function sanitizeReferenceImages(images: unknown): string[] {
|
||
if (!Array.isArray(images)) return [];
|
||
return images
|
||
.map((item) => String(item || "").trim())
|
||
.filter((url) => /^https?:\/\//i.test(url))
|
||
.slice(0, 12);
|
||
}
|
||
|
||
function sanitizeReferenceImageNotes(notes: unknown): string[] {
|
||
if (!Array.isArray(notes)) return [];
|
||
return notes
|
||
.map((item) => String(item || "").replace(/\s+/g, " ").trim())
|
||
.filter(Boolean)
|
||
.slice(0, 12);
|
||
}
|
||
|
||
function sanitizeBundleImages(images: unknown): string[] {
|
||
if (!Array.isArray(images)) return [];
|
||
return images
|
||
.map((item) => String(item || "").trim())
|
||
.filter((url) => /^https?:\/\//i.test(url))
|
||
.slice(0, 3);
|
||
}
|
||
|
||
function normalizeOutputLanguage(value: unknown): "zh" | "en" {
|
||
const raw = String(value || "").toLowerCase();
|
||
if (raw.includes("en") || raw.includes("english") || raw.includes("英文")) {
|
||
return "en";
|
||
}
|
||
return "zh";
|
||
}
|
||
|
||
function normalizeDetailBrief(value: unknown): DetailBrief {
|
||
const raw = (value || {}) as Record<string, unknown>;
|
||
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
|
||
return {
|
||
productSpu: normalize("productSpu"),
|
||
demandType: normalize("demandType"),
|
||
productName: normalize("productName"),
|
||
targetAudience: normalize("targetAudience"),
|
||
mainUser: normalize("mainUser"),
|
||
usageScenario: normalize("usageScenario"),
|
||
triggerMoment: normalize("triggerMoment"),
|
||
coreNeed: normalize("coreNeed"),
|
||
riskConcern: normalize("riskConcern"),
|
||
expectedResult: normalize("expectedResult"),
|
||
coreSellingPoint: normalize("coreSellingPoint"),
|
||
positioningStatement: normalize("positioningStatement"),
|
||
userVoice: normalize("userVoice"),
|
||
competitorReference: normalize("competitorReference"),
|
||
priceBand: normalize("priceBand"),
|
||
brandTone: normalize("brandTone"),
|
||
bannedWords: normalize("bannedWords"),
|
||
};
|
||
}
|
||
|
||
function extractJsonBlock<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 buildBriefText(brief: DetailBrief, language: "zh" | "en") {
|
||
const lines =
|
||
language === "en"
|
||
? [
|
||
brief.productSpu ? `Product SPU: ${brief.productSpu}` : "",
|
||
brief.demandType ? `Demand type: ${brief.demandType}` : "",
|
||
brief.productName ? `Product: ${brief.productName}` : "",
|
||
`Primary buyer audience: ${brief.targetAudience}`,
|
||
brief.mainUser ? `Primary user: ${brief.mainUser}` : "",
|
||
`Main usage scenario: ${brief.usageScenario}`,
|
||
brief.triggerMoment ? `Trigger moment: ${brief.triggerMoment}` : "",
|
||
`Core demand: ${brief.coreNeed}`,
|
||
brief.riskConcern ? `Risk concern: ${brief.riskConcern}` : "",
|
||
brief.expectedResult ? `Expected result: ${brief.expectedResult}` : "",
|
||
brief.coreSellingPoint ? `Core mental keyword: ${brief.coreSellingPoint}` : "",
|
||
brief.positioningStatement ? `Positioning statement: ${brief.positioningStatement}` : "",
|
||
brief.userVoice ? `User voice samples: ${brief.userVoice}` : "",
|
||
brief.competitorReference ? `Competitor reference: ${brief.competitorReference}` : "",
|
||
brief.priceBand ? `Price band: ${brief.priceBand}` : "",
|
||
brief.brandTone ? `Brand tone: ${brief.brandTone}` : "",
|
||
brief.bannedWords ? `Banned words: ${brief.bannedWords}` : "",
|
||
]
|
||
: [
|
||
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
|
||
brief.demandType ? `需求类型:${brief.demandType}` : "",
|
||
brief.productName ? `产品:${brief.productName}` : "",
|
||
`主购买人群:${brief.targetAudience}`,
|
||
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
|
||
`主场景:${brief.usageScenario}`,
|
||
brief.triggerMoment ? `触发时刻:${brief.triggerMoment}` : "",
|
||
`核心需求:${brief.coreNeed}`,
|
||
brief.riskConcern ? `用户顾虑:${brief.riskConcern}` : "",
|
||
brief.expectedResult ? `期望结果:${brief.expectedResult}` : "",
|
||
brief.coreSellingPoint ? `核心心智词:${brief.coreSellingPoint}` : "",
|
||
brief.positioningStatement ? `一句话定位:${brief.positioningStatement}` : "",
|
||
brief.userVoice ? `用户原话样本:${brief.userVoice}` : "",
|
||
brief.competitorReference ? `竞品对标:${brief.competitorReference}` : "",
|
||
brief.priceBand ? `价格带:${brief.priceBand}` : "",
|
||
brief.brandTone ? `品牌语气:${brief.brandTone}` : "",
|
||
brief.bannedWords ? `禁用词:${brief.bannedWords}` : "",
|
||
];
|
||
|
||
return lines.filter(Boolean).join("\n");
|
||
}
|
||
|
||
function buildDefaultGlobalStyle(language: "zh" | "en") {
|
||
const typographySpec = buildTypographySpec(language);
|
||
if (language === "en") {
|
||
return {
|
||
designLanguage: "Clean premium e-commerce detail storytelling",
|
||
typography: {
|
||
fontFamily: typographySpec.fontFamily,
|
||
title: typographySpec.title,
|
||
subtitle: typographySpec.subtitle,
|
||
body: typographySpec.body,
|
||
},
|
||
layout: {
|
||
grid: "Unified 12-column vertical layout with consistent alignment",
|
||
safeArea: typographySpec.safeArea,
|
||
rhythm: typographySpec.rhythm,
|
||
},
|
||
palette: "Keep one unified palette with stable contrast and accent color",
|
||
};
|
||
}
|
||
return {
|
||
designLanguage: "高转化电商详情叙事风格(统一、克制、专业)",
|
||
typography: {
|
||
fontFamily: typographySpec.fontFamily,
|
||
title: typographySpec.title,
|
||
subtitle: typographySpec.subtitle,
|
||
body: typographySpec.body,
|
||
},
|
||
layout: {
|
||
grid: "统一 12 栅格竖版布局,所有屏幕对齐关系一致",
|
||
safeArea: typographySpec.safeArea,
|
||
rhythm: typographySpec.rhythm,
|
||
},
|
||
palette: "全套保持同一主色/强调色/中性色体系,明暗对比稳定",
|
||
};
|
||
}
|
||
|
||
function buildFallbackScreens(params: {
|
||
prompt: string;
|
||
count: number;
|
||
language: "zh" | "en";
|
||
brief: DetailBrief;
|
||
coreFocus: DetailCoreFocusContext;
|
||
globalStyle: DetailPlanResponse["globalStyle"];
|
||
referenceImageNotes: string[];
|
||
hasModelImage: boolean;
|
||
hasBundleImages: boolean;
|
||
bundleImageCount: number;
|
||
}): DetailPlanScreen[] {
|
||
const {
|
||
prompt,
|
||
count,
|
||
language,
|
||
brief,
|
||
coreFocus,
|
||
globalStyle,
|
||
referenceImageNotes,
|
||
hasModelImage,
|
||
hasBundleImages,
|
||
bundleImageCount,
|
||
} = params;
|
||
const productLabel =
|
||
brief.productName || (language === "en" ? "the product" : "该产品");
|
||
const mainScenario = brief.usageScenario || (language === "en" ? "daily life" : "日常场景");
|
||
const coreNeed = brief.coreNeed || (language === "en" ? "user demand" : "核心需求");
|
||
const positioning =
|
||
brief.positioningStatement || (language === "en" ? "No explicit positioning statement" : "未提供一句话定位");
|
||
|
||
const defaultObjectives =
|
||
language === "en"
|
||
? [
|
||
"Hero poster cover with one core value",
|
||
"Pain-point resonance and trigger moment",
|
||
"Solution structure and key mechanism",
|
||
"Differentiation reinforcement",
|
||
"Evidence and trust closing",
|
||
]
|
||
: [
|
||
"首屏海报主视觉聚焦单一核心卖点",
|
||
"痛点共鸣与触发场景放大",
|
||
"解决方案与关键机制说明",
|
||
"差异化优势重复强化",
|
||
"证据背书与信任收口",
|
||
];
|
||
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
|
||
const defaultTitles =
|
||
language === "en"
|
||
? [
|
||
coreFocus.firstScreenTitle,
|
||
"Core Benefit Proof",
|
||
"Mechanism Breakdown",
|
||
"Difference Reinforcement",
|
||
"Trust Evidence",
|
||
]
|
||
: [
|
||
coreFocus.firstScreenTitle,
|
||
"核心收益可证",
|
||
"机制拆解",
|
||
"差异强化",
|
||
"证据收口",
|
||
];
|
||
|
||
return Array.from({ length: count }).map((_, index) => {
|
||
const screenType =
|
||
screenTypeSequence[index] || (index % 3 === 0 ? "B" : index % 3 === 1 ? "A" : "C");
|
||
let objective =
|
||
defaultObjectives[index] ||
|
||
(language === "en"
|
||
? `Extended detail frame ${index + 1}`
|
||
: `延展分镜 ${index + 1}`);
|
||
const isBundleScreen = hasBundleImages && index === count - 1;
|
||
const title =
|
||
isBundleScreen
|
||
? language === "en"
|
||
? "Bundle Cross-sell"
|
||
: "搭配推荐"
|
||
: language === "en"
|
||
? defaultTitles[index] || `Comfort Upgrade ${index + 1}`
|
||
: defaultTitles[index] || "舒适升级";
|
||
const subtitle =
|
||
isBundleScreen
|
||
? language === "en"
|
||
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
|
||
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
|
||
: index === 0
|
||
? coreFocus.firstScreenSubtitle
|
||
: language === "en"
|
||
? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
|
||
: `围绕“${coreFocus.anchor}”,在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
|
||
const copy =
|
||
isBundleScreen
|
||
? language === "en"
|
||
? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.`
|
||
: `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。`
|
||
: language === "en"
|
||
? `${productLabel} turns key pain points into visible outcomes under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.`
|
||
: `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`;
|
||
let layoutNote =
|
||
isBundleScreen
|
||
? language === "en"
|
||
? "Main product remains primary; companion products are secondary with clear hierarchy and no visual clutter."
|
||
: "主产品为视觉主角,搭配产品作为辅助,层级清晰避免喧宾夺主。"
|
||
: language === "en"
|
||
? `Use fixed layout rhythm. Keep title/subtitle/body hierarchy identical to all other frames, and preserve text safe-area.`
|
||
: `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致,并严格保留文字安全区。`;
|
||
const useModel = !isBundleScreen && hasModelImage && (index === 1 || index === 3 || index >= 5);
|
||
const modelReason = useModel
|
||
? language === "en"
|
||
? "This frame needs human scenario demonstration, so use the fixed model image."
|
||
: "本屏需要人物场景演示,使用固定模特图。"
|
||
: language === "en"
|
||
? "This frame focuses on product-only communication without human figure."
|
||
: "本屏聚焦产品信息展示,不使用人物。";
|
||
const bundleShowcase = isBundleScreen;
|
||
const bundleReason = bundleShowcase
|
||
? language === "en"
|
||
? "Optional bundle references were provided; this frame must show scenario-based product pairing for cross-sell."
|
||
: "用户提供了搭配产品图,本屏必须展示场景化产品搭配以增强关联销售。"
|
||
: language === "en"
|
||
? "This frame is not the bundle showcase frame."
|
||
: "本屏非搭配展示页。";
|
||
const fabe =
|
||
language === "en"
|
||
? {
|
||
b: `Get ${coreNeed} resolved with less effort and risk.`,
|
||
f: `Show concrete product structure and material facts.`,
|
||
a: `Outperform generic options in core scenario performance.`,
|
||
e: `Use test data, certification, and before-after comparisons.`,
|
||
}
|
||
: {
|
||
b: `用户可低成本获得“${coreNeed}”的结果。`,
|
||
f: "给出可验证的结构、材质或工艺事实。",
|
||
a: "强调在主场景下优于普通方案的关键优势。",
|
||
e: "使用检测、认证、对比图等证据收尾。",
|
||
};
|
||
|
||
if (index === 0 && !isBundleScreen) {
|
||
objective =
|
||
language === "en"
|
||
? "Poster-style hero cover for the One selling-point, making the buying reason obvious in one glance."
|
||
: "海报级首屏封面,聚焦The One核心卖点,一眼说明购买理由。";
|
||
layoutNote =
|
||
language === "en"
|
||
? "Hero poster layout: dominant single product focal point, cinematic lighting and contrast, clear top headline safe area, no collage cards."
|
||
: "首屏海报布局:单一主体强焦点,电影感光影与对比,顶部标题区清晰,禁止拼贴卡片式说明。";
|
||
}
|
||
|
||
const visualPrompt =
|
||
language === "en"
|
||
? [
|
||
`E-commerce detail frame ${index + 1}, objective: ${objective}.`,
|
||
`Fixed vertical canvas 790x1300 pixels.`,
|
||
`Keep exact product identity consistent across all frames.`,
|
||
`Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
|
||
"Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.",
|
||
`Core selling-point anchor: ${coreFocus.anchor}; this frame must stay aligned to this anchor.`,
|
||
index === 0
|
||
? "Screen 1 hero-cover rule: this must look like a poster key visual with one dominant subject, strong focus contrast, and no collage cards."
|
||
: "",
|
||
`Positioning statement: ${positioning}.`,
|
||
`Global style: ${globalStyle.designLanguage}.`,
|
||
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
|
||
`Layout: ${globalStyle.layout.grid}; ${globalStyle.layout.safeArea}; ${globalStyle.layout.rhythm}.`,
|
||
`Palette: ${globalStyle.palette}.`,
|
||
referenceImageNotes.length > 0
|
||
? `Reference image set roles: ${referenceImageNotes.join(" | ")}. Keep product identity and details fully consistent.`
|
||
: "",
|
||
bundleShowcase
|
||
? `This must be a bundle showcase frame: show the hero product with companion products from optional bundle refs in one coherent scenario.`
|
||
: "",
|
||
useModel
|
||
? "Integrate the same human model from the uploaded model image."
|
||
: "No human figure in this frame.",
|
||
`Commercial product photography, typography-safe area, no extra watermark.`,
|
||
`Task context: ${prompt}.`,
|
||
].join(" ")
|
||
: [
|
||
`电商详情页第${index + 1}屏,目标:${objective}。`,
|
||
"画布固定竖版 790x1300 像素。",
|
||
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
|
||
`主场景:${mainScenario};核心需求:${coreNeed}。`,
|
||
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
|
||
`核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`,
|
||
index === 0
|
||
? "首屏封面规则:必须是海报主视觉(非说明图拼贴),单一主体大图、强聚焦、高级商业海报质感。"
|
||
: "",
|
||
`一句话定位:${positioning}。`,
|
||
`全局风格:${globalStyle.designLanguage}。`,
|
||
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
|
||
`版式规范:${globalStyle.layout.grid};${globalStyle.layout.safeArea};${globalStyle.layout.rhythm}。`,
|
||
`配色规范:${globalStyle.palette}。`,
|
||
referenceImageNotes.length > 0
|
||
? `参考图角色:${referenceImageNotes.join(";")};必须严格保持产品一致性与细节一致性。`
|
||
: "",
|
||
bundleShowcase
|
||
? "本屏必须是搭配展示页:同屏展示主产品与搭配产品,体现组合使用场景与加购价值。"
|
||
: "",
|
||
useModel ? "本屏融入固定模特图中的同一人物。" : "本屏不要出现人物。",
|
||
"商业摄影质感,留出清晰文案排版区,不要水印。",
|
||
`任务上下文:${prompt}。`,
|
||
].join("");
|
||
|
||
return {
|
||
index: index + 1,
|
||
screenType,
|
||
objective,
|
||
title,
|
||
subtitle,
|
||
copy,
|
||
layoutNote,
|
||
useModel,
|
||
modelReason,
|
||
bundleShowcase,
|
||
bundleReason,
|
||
visualPrompt,
|
||
fabe,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildFallbackPlan(params: {
|
||
prompt: string;
|
||
count: number;
|
||
language: "zh" | "en";
|
||
brief: DetailBrief;
|
||
coreFocus: DetailCoreFocusContext;
|
||
referenceImageNotes: string[];
|
||
hasModelImage: boolean;
|
||
hasBundleImages: boolean;
|
||
bundleImageCount: number;
|
||
}): DetailPlanResponse {
|
||
const { count, language } = params;
|
||
const globalStyle = buildDefaultGlobalStyle(language);
|
||
const skillFlow = buildDetailSkillFlow(language);
|
||
const fallbackScreens = buildFallbackScreens({
|
||
...params,
|
||
globalStyle,
|
||
});
|
||
const screens = enforceCoreFocusOnScreens({
|
||
screens: fallbackScreens,
|
||
coreFocus: params.coreFocus,
|
||
language,
|
||
});
|
||
const qualityChecks = evaluatePlanQuality({
|
||
screens,
|
||
hasModelImage: params.hasModelImage,
|
||
hasBundleImages: params.hasBundleImages,
|
||
coreFocus: params.coreFocus,
|
||
});
|
||
return {
|
||
summary:
|
||
language === "en"
|
||
? `Generated ${count} storyboard frames with fixed size 790x1300.`
|
||
: `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`,
|
||
prompts: screens.map((item) => item.visualPrompt),
|
||
screenSize: {
|
||
width: 790,
|
||
height: 1300,
|
||
},
|
||
globalStyle,
|
||
skillFlow,
|
||
qualityChecks,
|
||
screens,
|
||
};
|
||
}
|
||
|
||
function sanitizeScreen(
|
||
raw: unknown,
|
||
fallback: DetailPlanScreen,
|
||
language: "zh" | "en"
|
||
): DetailPlanScreen {
|
||
const source = (raw || {}) as Record<string, unknown>;
|
||
const safeText = (value: unknown, defaultValue: string) => {
|
||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||
return normalized || defaultValue;
|
||
};
|
||
|
||
const fabeSource = (source.fabe || {}) as Record<string, unknown>;
|
||
const visualPrompt = desensitizeAudienceCopy(
|
||
safeText(source.visualPrompt, fallback.visualPrompt),
|
||
language
|
||
);
|
||
const normalizeTitle = (value: string) => {
|
||
const cleaned = stripAgentPlanningPhrases(
|
||
value
|
||
.replace(/第\s*\d+\s*屏/gi, "")
|
||
.replace(/\bscreen\s*\d+\b/gi, "")
|
||
.replace(/[((]?\s*\d+\s*[))]?$/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
);
|
||
if (!cleaned) return fallback.title;
|
||
if (hasPlanningTitleMeta(cleaned)) return fallback.title;
|
||
return cleaned;
|
||
};
|
||
const normalizeSubtitle = (value: string) =>
|
||
stripAgentPlanningPhrases(
|
||
value
|
||
.replace(/第\s*\d+\s*屏/gi, "")
|
||
.replace(/\bscreen\s*\d+\b/gi, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
);
|
||
const normalizeCopy = (value: string) =>
|
||
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.copy, language);
|
||
const normalizeLayoutNote = (value: string) =>
|
||
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.layoutNote, language);
|
||
const rawScreenType = String(source.screenType || "").trim().toUpperCase();
|
||
const screenType: DetailScreenType =
|
||
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
|
||
? (rawScreenType as DetailScreenType)
|
||
: fallback.screenType;
|
||
return {
|
||
index: fallback.index,
|
||
screenType,
|
||
objective: desensitizeAudienceCopy(safeText(source.objective, fallback.objective), language),
|
||
title: desensitizeAudienceCopy(normalizeTitle(safeText(source.title, fallback.title)), language),
|
||
subtitle:
|
||
desensitizeAudienceCopy(
|
||
normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle,
|
||
language
|
||
) || fallback.subtitle,
|
||
copy: normalizeCopy(safeText(source.copy, fallback.copy)),
|
||
layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)),
|
||
useModel:
|
||
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
|
||
modelReason: desensitizeAudienceCopy(safeText(source.modelReason, fallback.modelReason), language),
|
||
bundleShowcase:
|
||
typeof source.bundleShowcase === "boolean"
|
||
? source.bundleShowcase
|
||
: fallback.bundleShowcase,
|
||
bundleReason: desensitizeAudienceCopy(safeText(source.bundleReason, fallback.bundleReason), language),
|
||
visualPrompt: visualPrompt.includes("790x1300")
|
||
? visualPrompt
|
||
: `${visualPrompt} 画布固定 790x1300 像素。`,
|
||
fabe: {
|
||
b: safeText(fabeSource.b, fallback.fabe.b),
|
||
f: safeText(fabeSource.f, fallback.fabe.f),
|
||
a: safeText(fabeSource.a, fallback.fabe.a),
|
||
e: safeText(fabeSource.e, fallback.fabe.e),
|
||
},
|
||
};
|
||
}
|
||
|
||
function sanitizePlannedScreens(
|
||
raw: unknown,
|
||
fallbackScreens: DetailPlanScreen[],
|
||
language: "zh" | "en"
|
||
): DetailPlanScreen[] {
|
||
if (!Array.isArray(raw)) {
|
||
return fallbackScreens;
|
||
}
|
||
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback, language));
|
||
}
|
||
|
||
function sanitizeGlobalStyle(
|
||
raw: unknown,
|
||
fallback: DetailPlanResponse["globalStyle"],
|
||
): DetailPlanResponse["globalStyle"] {
|
||
const source = (raw || {}) as Record<string, unknown>;
|
||
const toText = (value: unknown, defaultValue: string) => {
|
||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||
return normalized || defaultValue;
|
||
};
|
||
const typography = (source.typography || {}) as Record<string, unknown>;
|
||
const layout = (source.layout || {}) as Record<string, unknown>;
|
||
return {
|
||
designLanguage: toText(source.designLanguage, fallback.designLanguage),
|
||
typography: {
|
||
fontFamily: toText(typography.fontFamily, fallback.typography.fontFamily),
|
||
title: toText(typography.title, fallback.typography.title),
|
||
subtitle: toText(typography.subtitle, fallback.typography.subtitle),
|
||
body: toText(typography.body, fallback.typography.body),
|
||
},
|
||
layout: {
|
||
grid: toText(layout.grid, fallback.layout.grid),
|
||
safeArea: toText(layout.safeArea, fallback.layout.safeArea),
|
||
rhythm: toText(layout.rhythm, fallback.layout.rhythm),
|
||
},
|
||
palette: toText(source.palette, fallback.palette),
|
||
};
|
||
}
|
||
|
||
function evaluatePlanQuality(params: {
|
||
screens: DetailPlanScreen[];
|
||
hasModelImage: boolean;
|
||
hasBundleImages: boolean;
|
||
coreFocus?: DetailCoreFocusContext;
|
||
}): { passed: boolean; issues: string[] } {
|
||
const { screens, hasModelImage, hasBundleImages, coreFocus } = params;
|
||
const issues: string[] = [];
|
||
if (screens.length === 0) {
|
||
issues.push("未生成任何分镜。");
|
||
}
|
||
|
||
const firstFive = screens.slice(0, 5);
|
||
if (firstFive.length >= 1 && firstFive[0]?.screenType !== "B") {
|
||
issues.push("第1屏应为B类场景共鸣页。");
|
||
}
|
||
if (!hasFirstScreenPosterSignal(firstFive[0])) {
|
||
issues.push("首屏缺少“封面海报感”约束(主视觉/海报/封面/KV),需强化首屏主视觉表现。");
|
||
}
|
||
if (firstFive.length >= 2 && firstFive[1]?.screenType !== "A") {
|
||
issues.push("第2屏建议为A类技术公信页。");
|
||
}
|
||
if (firstFive.length >= 4 && firstFive[3]?.screenType !== "C") {
|
||
issues.push("第4屏建议为C类细节佐证页。");
|
||
}
|
||
|
||
const typeCounter = firstFive.reduce(
|
||
(acc, screen) => {
|
||
acc[screen.screenType] += 1;
|
||
return acc;
|
||
},
|
||
{ A: 0, B: 0, C: 0 } as Record<DetailScreenType, number>,
|
||
);
|
||
|
||
if (typeCounter.B < 2) {
|
||
issues.push("前5屏应至少包含2屏B类场景页。");
|
||
}
|
||
if (typeCounter.A < 1) {
|
||
issues.push("前5屏应至少包含1屏A类公信页。");
|
||
}
|
||
if (typeCounter.C < 1) {
|
||
issues.push("前5屏应至少包含1屏C类细节页。");
|
||
}
|
||
|
||
const hasMissingFABE = screens.some(
|
||
(screen) =>
|
||
!screen.fabe.b.trim() ||
|
||
!screen.fabe.f.trim() ||
|
||
!screen.fabe.a.trim() ||
|
||
!screen.fabe.e.trim(),
|
||
);
|
||
if (hasMissingFABE) {
|
||
issues.push("存在分镜FABE不完整。");
|
||
}
|
||
|
||
const hasMetaTitle = screens.some((screen) => hasPlanningTitleMeta(screen.title));
|
||
if (hasMetaTitle) {
|
||
issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。");
|
||
}
|
||
|
||
const titleLengthOutOfRange = screens.some((screen, index) => {
|
||
const len = visibleTitleLength(screen.title);
|
||
const minLen = index === 0 ? 6 : 4;
|
||
const maxLen = index === 0 ? 20 : 10;
|
||
return len < minLen || len > maxLen;
|
||
});
|
||
if (titleLengthOutOfRange) {
|
||
issues.push("存在分镜主标题长度不符合规范(首屏6-20字,其余4-10字)。");
|
||
}
|
||
|
||
const firstTitle = String(screens[0]?.title || "").trim();
|
||
if (firstTitle && !/[,,::·|]/.test(firstTitle)) {
|
||
issues.push("首屏主标题需采用“命名词+需求句”结构(示例:甩甩杆,自拍不手抖)。");
|
||
}
|
||
|
||
const subtitleTooLong = screens.some((screen) => {
|
||
const normalized = screen.subtitle.replace(/\s+/g, "");
|
||
return normalized.length > 38 || /[。!?.!?].+[。!?.!?]/.test(screen.subtitle);
|
||
});
|
||
if (subtitleTooLong) {
|
||
issues.push("存在分镜副标题过长或多句,建议保持一条结果句。");
|
||
}
|
||
|
||
const hasGenericFluff = screens.some((screen) => {
|
||
const fullText = `${screen.title} ${screen.subtitle} ${screen.copy}`;
|
||
return GENERIC_FLUFF_WORDS_ZH.some((word) => fullText.includes(word));
|
||
});
|
||
if (hasGenericFluff) {
|
||
issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。");
|
||
}
|
||
|
||
if (coreFocus && coreFocus.focusTerms.length > 0 && screens.length > 0) {
|
||
const firstScreen = screens[0];
|
||
const firstScreenText = `${firstScreen?.title || ""} ${firstScreen?.subtitle || ""} ${firstScreen?.copy || ""}`;
|
||
if (!containsCoreTerms(firstScreenText, coreFocus.focusTerms)) {
|
||
issues.push("首屏未聚焦核心卖点锚点,主标题与副标题需直接承接核心卖点。");
|
||
}
|
||
|
||
const offTopicCount = screens.filter((screen) => {
|
||
const merged = `${screen.objective} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`;
|
||
return !containsCoreTerms(merged, coreFocus.focusTerms);
|
||
}).length;
|
||
const allowOffTopic = Math.max(1, Math.floor(screens.length * 0.15));
|
||
if (offTopicCount > allowOffTopic) {
|
||
issues.push("多屏文案偏离核心卖点,整套详情需围绕同一核心卖点展开。");
|
||
}
|
||
|
||
const namingWord = String(coreFocus.namingToken || firstScreen?.title || "").trim();
|
||
if (namingWord) {
|
||
const repeatCount = screens
|
||
.slice(1)
|
||
.filter((screen) => String(`${screen.subtitle} ${screen.copy}`).includes(namingWord)).length;
|
||
if (repeatCount < Math.min(2, Math.max(1, screens.length - 1))) {
|
||
issues.push("首屏命名词未在后续页面重复强化,建议至少2屏复现以沉淀新搜索词。");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasModelImage) {
|
||
const modelScreenCount = screens.filter((screen) => screen.useModel).length;
|
||
if (modelScreenCount === 0) {
|
||
issues.push("已提供模特图但没有人物场景分镜。");
|
||
}
|
||
}
|
||
|
||
if (hasBundleImages) {
|
||
const bundleScreenCount = screens.filter((screen) => screen.bundleShowcase).length;
|
||
if (bundleScreenCount === 0) {
|
||
issues.push("已提供搭配产品图但没有搭配展示分镜。");
|
||
}
|
||
}
|
||
|
||
return {
|
||
passed: issues.length === 0,
|
||
issues,
|
||
};
|
||
}
|
||
|
||
async function runDualDirectorPlanPreflight(params: {
|
||
model: any;
|
||
language: "zh" | "en";
|
||
brief: DetailBrief;
|
||
coreFocus: DetailCoreFocusContext;
|
||
globalStyle: DetailPlanResponse["globalStyle"];
|
||
screens: DetailPlanScreen[];
|
||
hasModelImage: boolean;
|
||
hasBundleImages: boolean;
|
||
}) {
|
||
const defaultResult = {
|
||
passed: true,
|
||
designDirectorScore: 88,
|
||
marketingDirectorScore: 88,
|
||
issues: [] as string[],
|
||
rewriteApplied: false,
|
||
candidateGlobalStyle: params.globalStyle,
|
||
candidateScreens: params.screens,
|
||
};
|
||
|
||
try {
|
||
const payload = JSON.stringify(
|
||
{
|
||
globalStyle: params.globalStyle,
|
||
screens: params.screens.map((screen) => ({
|
||
index: screen.index,
|
||
screenType: screen.screenType,
|
||
objective: screen.objective,
|
||
title: screen.title,
|
||
subtitle: screen.subtitle,
|
||
copy: screen.copy,
|
||
layoutNote: screen.layoutNote,
|
||
useModel: screen.useModel,
|
||
bundleShowcase: screen.bundleShowcase,
|
||
fabe: screen.fabe,
|
||
visualPrompt: screen.visualPrompt,
|
||
})),
|
||
},
|
||
null,
|
||
2,
|
||
);
|
||
|
||
const briefText = buildBriefText(params.brief, params.language);
|
||
const instruction =
|
||
params.language === "en"
|
||
? [
|
||
"Preflight this storyboard before image generation using dual-director standards.",
|
||
"Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).",
|
||
"Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).",
|
||
"Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.",
|
||
"Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.",
|
||
"Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.",
|
||
"Must keep canvas 790x1300, unified typography hierarchy, and consistent style.",
|
||
`All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`,
|
||
params.hasModelImage
|
||
? "Model image is provided: only use where needed, keep identity consistent."
|
||
: "No model image: all frames must avoid human model dependence.",
|
||
params.hasBundleImages
|
||
? "Bundle refs provided: keep exactly one bundle showcase frame."
|
||
: "No bundle refs: bundle showcase must be false in all frames.",
|
||
"If not pass, rewrite globalStyle and all screens directly to a production-ready version.",
|
||
"Return strict JSON only:",
|
||
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
||
"Current plan JSON:",
|
||
payload,
|
||
"Input brief:",
|
||
briefText,
|
||
]
|
||
: [
|
||
"在生图前,按双总监标准对当前分镜做预审。",
|
||
"角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。",
|
||
"角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。",
|
||
"禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。",
|
||
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
|
||
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
|
||
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
|
||
"首屏必须具备“封面海报感”:单一主视觉、强焦点、清晰标题区,不得做拼贴式说明页。",
|
||
`整套文案必须围绕同一核心卖点锚点:“${params.coreFocus.anchor}”,不得跑题。`,
|
||
params.hasModelImage
|
||
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
|
||
: "未提供模特图:所有分镜不得依赖模特人物。",
|
||
params.hasBundleImages
|
||
? "已提供搭配图:必须且仅保留1屏搭配展示页。"
|
||
: "未提供搭配图:所有分镜 bundleShowcase=false。",
|
||
"若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。",
|
||
"只返回严格JSON:",
|
||
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
||
"当前方案JSON:",
|
||
payload,
|
||
"输入简报:",
|
||
briefText,
|
||
];
|
||
|
||
const { text } = await generateText({
|
||
model: params.model,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: instruction.join("\n"),
|
||
},
|
||
],
|
||
system:
|
||
params.language === "en"
|
||
? "You are both an art director and a marketing director for high-conversion e-commerce detail pages. Output JSON only."
|
||
: "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。",
|
||
temperature: 0.1,
|
||
maxTokens: 2000,
|
||
} as any);
|
||
|
||
const parsed = extractJsonBlock<{
|
||
pass?: unknown;
|
||
designDirectorScore?: unknown;
|
||
marketingDirectorScore?: unknown;
|
||
issues?: unknown;
|
||
revisedGlobalStyle?: unknown;
|
||
revisedScreens?: unknown;
|
||
}>(text || "");
|
||
|
||
const designRaw = Number(parsed?.designDirectorScore);
|
||
const marketingRaw = Number(parsed?.marketingDirectorScore);
|
||
const designDirectorScore = Number.isFinite(designRaw)
|
||
? Math.max(0, Math.min(100, Math.round(designRaw)))
|
||
: defaultResult.designDirectorScore;
|
||
const marketingDirectorScore = Number.isFinite(marketingRaw)
|
||
? Math.max(0, Math.min(100, Math.round(marketingRaw)))
|
||
: defaultResult.marketingDirectorScore;
|
||
const issues = Array.isArray(parsed?.issues)
|
||
? parsed.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8)
|
||
: [];
|
||
const rawPass = typeof parsed?.pass === "boolean"
|
||
? parsed.pass
|
||
: designDirectorScore >= 85 && marketingDirectorScore >= 85 && issues.length === 0;
|
||
|
||
let candidateGlobalStyle = params.globalStyle;
|
||
let candidateScreens = params.screens;
|
||
let rewriteApplied = false;
|
||
|
||
if (parsed?.revisedGlobalStyle && typeof parsed.revisedGlobalStyle === "object") {
|
||
candidateGlobalStyle = sanitizeGlobalStyle(parsed.revisedGlobalStyle, params.globalStyle);
|
||
rewriteApplied = true;
|
||
}
|
||
if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) {
|
||
candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens, params.language);
|
||
rewriteApplied = true;
|
||
}
|
||
|
||
return {
|
||
passed: rawPass,
|
||
designDirectorScore,
|
||
marketingDirectorScore,
|
||
issues,
|
||
rewriteApplied,
|
||
candidateGlobalStyle,
|
||
candidateScreens,
|
||
};
|
||
} catch (error) {
|
||
console.error("[Canvas Detail Plan] preflight error:", error);
|
||
return defaultResult;
|
||
}
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const body = (await request.json()) as DetailPlanRequest;
|
||
const prompt = String(body.prompt || "").trim();
|
||
if (!prompt) {
|
||
return NextResponse.json({ success: false, error: "请输入详情需求" }, { status: 400 });
|
||
}
|
||
|
||
const baseCount = clampCount(body.count);
|
||
const referenceImages = sanitizeReferenceImages(body.referenceImages);
|
||
const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes);
|
||
const modelImageUrl = String(body.modelImageUrl || "").trim();
|
||
const bundleImageUrls = sanitizeBundleImages(body.bundleImageUrls);
|
||
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
|
||
const hasBundleImages = bundleImageUrls.length > 0;
|
||
const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount;
|
||
const language = normalizeOutputLanguage(body.outputLanguage);
|
||
const brief = normalizeDetailBrief(body.brief);
|
||
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: "请补充必填项:主购买人群、需求场景、核心需求",
|
||
},
|
||
{ status: 400 },
|
||
);
|
||
}
|
||
const coreFocus = buildCoreFocusContext(brief, language);
|
||
const fallback = buildFallbackPlan({
|
||
prompt,
|
||
count,
|
||
language,
|
||
brief,
|
||
coreFocus,
|
||
referenceImageNotes,
|
||
hasModelImage,
|
||
hasBundleImages,
|
||
bundleImageCount: bundleImageUrls.length,
|
||
});
|
||
|
||
const allModels = await getEnabledAiModelsWithProvider(false);
|
||
const agentModel = await getAgentAiModelWithProvider();
|
||
const reasoningCompatible = allModels.filter(
|
||
(item) =>
|
||
!item.model.isSubModel &&
|
||
item.model.isEnabled &&
|
||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||
);
|
||
const preferredAgentConfig =
|
||
agentModel &&
|
||
reasoningCompatible.some((item) => item.model.id === agentModel.model.id)
|
||
? reasoningCompatible.find((item) => item.model.id === agentModel.model.id)
|
||
: undefined;
|
||
let plannerConfig = preferredAgentConfig;
|
||
|
||
if (referenceImages.length > 0) {
|
||
if (!plannerConfig || !supportsVisionPlanning(plannerConfig.model)) {
|
||
plannerConfig = reasoningCompatible.find((item) => supportsVisionPlanning(item.model));
|
||
}
|
||
}
|
||
if (!plannerConfig) {
|
||
plannerConfig = reasoningCompatible[0];
|
||
}
|
||
|
||
if (!plannerConfig) {
|
||
return NextResponse.json({ success: true, data: fallback });
|
||
}
|
||
|
||
console.log("[Canvas Detail Plan] Planner model selected:", {
|
||
id: plannerConfig.model.id,
|
||
displayName: plannerConfig.model.displayName,
|
||
modelId: plannerConfig.model.modelId,
|
||
codingModelId: getAiModelAgentCodingModelId(plannerConfig.model),
|
||
providerType: plannerConfig.provider.type,
|
||
supportsVision: plannerConfig.model.supportsVision,
|
||
multimodalFlag: isAiModelMultimodal({
|
||
capabilities: plannerConfig.model.capabilities,
|
||
defaultParams: plannerConfig.model.defaultParams,
|
||
}),
|
||
hasReferenceImages: referenceImages.length > 0,
|
||
});
|
||
|
||
const codingModelId = getAiModelAgentCodingModelId(plannerConfig.model);
|
||
const model = await getLanguageModelById(
|
||
plannerConfig.model.id,
|
||
codingModelId
|
||
);
|
||
if (!model) {
|
||
return NextResponse.json({ success: true, data: fallback });
|
||
}
|
||
|
||
const userInput = [
|
||
`用户需求:${prompt}`,
|
||
"以下是详情生成输入信息:",
|
||
buildBriefText(brief, language),
|
||
`目标分镜数量:${count}`,
|
||
`输出语言:${language === "en" ? "English" : "中文"}`,
|
||
"任务类型:电商详情页“AI详情生成”",
|
||
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
|
||
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
|
||
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
|
||
"首屏命名规则:标题必须是“名字+需求一句话”(如“甩甩杆,自拍不手抖”),不能只给一个名字。",
|
||
"命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。",
|
||
referenceImageNotes.length > 0
|
||
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
|
||
: "",
|
||
hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。",
|
||
hasBundleImages
|
||
? `已提供 ${bundleImageUrls.length} 张搭配产品图,必须增加1屏场景化搭配展示用于关联销售。`
|
||
: "未提供搭配产品图。",
|
||
referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
|
||
let imageInputs: Array<{ type: "image"; image: string }> = [];
|
||
if (supportsVisionPlanning(plannerConfig.model) && referenceImages.length > 0) {
|
||
if (isVolcanoResponsesPlanner(plannerConfig)) {
|
||
imageInputs = referenceImages
|
||
.slice(0, 6)
|
||
.map((url) => ({ type: "image" as const, image: url }));
|
||
} else {
|
||
const inlined = await inlineVisionImages(referenceImages, {
|
||
maxImages: 6,
|
||
maxSide: 900,
|
||
timeoutMs: 12000,
|
||
retries: 2,
|
||
loggerPrefix: "[Canvas Detail Plan]",
|
||
});
|
||
imageInputs = inlined.inlineDataUrls.map((dataUrl) => ({
|
||
type: "image" as const,
|
||
image: dataUrl,
|
||
}));
|
||
}
|
||
}
|
||
|
||
let text = "";
|
||
try {
|
||
const result = await generateText({
|
||
model,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: userInput,
|
||
},
|
||
...imageInputs,
|
||
],
|
||
},
|
||
],
|
||
system:
|
||
`你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` +
|
||
`硬性要求:\n` +
|
||
`1) 输出严格 JSON,不要其他文本。\n` +
|
||
`2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` +
|
||
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
|
||
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
|
||
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
|
||
`5.1) 首屏主标题必须是“命名词+需求句”结构(6-20字,例如“甩甩杆,自拍不手抖”),并配一条结果副标题,直接回答“为什么值得买”。\n` +
|
||
`5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` +
|
||
`5.3) 中文首屏标题优先使用“核心卖点命名词+品类后缀”结构(如:甩甩杆、安敏裤)。\n` +
|
||
`5.4) 命名词必须大白话、好记、可口头传播,避免“智能/科技/方案/系统”等抽象词。\n` +
|
||
`5.5) 后续至少2屏在副标题或正文复现该命名词,强化用户检索记忆。\n` +
|
||
`5.6) 首屏必须是封面海报构图:单一主体主视觉、强对比焦点、电影感光影、顶部标题安全区;禁止拼贴说明页。\n` +
|
||
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
|
||
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
|
||
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
|
||
`7.2) 核心需求最多保留3-5条(1条必备 + 1-2条期待 + 1条魅力),避免页面过载。\n` +
|
||
`7.3) 每个核心卖点需覆盖“体验/社交/实用”三维中的至少2维,并落地到 copy 或 layoutNote。\n` +
|
||
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
|
||
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
|
||
`9.1) 前3屏默认节奏:屏1=场景痛点共鸣(B),屏2=买点总括,屏3=证据验证(对比/实测/口碑/认证)。\n` +
|
||
`10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识;首屏6-20字(命名词+需求句),其余屏建议4-10字。\n` +
|
||
`10.1) 主标题禁止使用规划节奏词:核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展。\n` +
|
||
`11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` +
|
||
`11.1) 副标题必须为单句结果句,禁止写成长段说明。\n` +
|
||
`12) 对外文案禁止直写具体年龄/性别标签(如“25-35岁女性”),改为场景化表达(如“久坐办公场景用户”)。\n` +
|
||
(hasModelImage
|
||
? `13) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
|
||
: `13) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
|
||
(hasBundleImages
|
||
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
|
||
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
|
||
`输出格式:\n` +
|
||
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
|
||
temperature: 0.25,
|
||
maxTokens: 1800,
|
||
} as any);
|
||
text = String(result?.text || "");
|
||
} catch (error) {
|
||
const message = String((error as Error)?.message || "");
|
||
if (isAbortError(error) || /invalid scheme/i.test(message)) {
|
||
console.error("[Canvas Detail Plan] Primary planning aborted, fallback applied:", error);
|
||
const fallbackReason = /invalid scheme/i.test(message)
|
||
? "规划模型图片输入协议不兼容,已回退到稳定分镜模板。"
|
||
: "规划模型调用超时,已自动回退到稳定分镜模板。";
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
...fallback,
|
||
summary: `${fallback.summary}\n${fallbackReason}`,
|
||
qualityChecks: {
|
||
passed: false,
|
||
issues: [fallbackReason],
|
||
},
|
||
model: {
|
||
id: plannerConfig.model.id,
|
||
displayName: plannerConfig.model.displayName,
|
||
providerType: plannerConfig.provider.type,
|
||
},
|
||
preflight: {
|
||
passed: false,
|
||
designDirectorScore: 0,
|
||
marketingDirectorScore: 0,
|
||
issues: [fallbackReason],
|
||
rewriteApplied: false,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || "");
|
||
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
|
||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
|
||
hasModelImage
|
||
? item
|
||
: {
|
||
...item,
|
||
useModel: false,
|
||
modelReason: "未提供模特图,当前分镜不使用人物。",
|
||
}
|
||
);
|
||
const normalizedScreens = rawScreens.map((item) =>
|
||
hasBundleImages
|
||
? item
|
||
: {
|
||
...item,
|
||
bundleShowcase: false,
|
||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||
}
|
||
);
|
||
const coreFocusedScreens = enforceCoreFocusOnScreens({
|
||
screens: normalizedScreens,
|
||
coreFocus,
|
||
language,
|
||
});
|
||
const qualityChecksInitial = evaluatePlanQuality({
|
||
screens: coreFocusedScreens,
|
||
hasModelImage,
|
||
hasBundleImages,
|
||
coreFocus,
|
||
});
|
||
const preflight = await runDualDirectorPlanPreflight({
|
||
model,
|
||
language,
|
||
brief,
|
||
coreFocus,
|
||
globalStyle: globalStyleInitial,
|
||
screens: coreFocusedScreens,
|
||
hasModelImage,
|
||
hasBundleImages,
|
||
});
|
||
|
||
const preflightScreensRaw = preflight.candidateScreens.map((item) =>
|
||
hasBundleImages
|
||
? item
|
||
: {
|
||
...item,
|
||
bundleShowcase: false,
|
||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||
}
|
||
);
|
||
const preflightScreens = enforceCoreFocusOnScreens({
|
||
screens: preflightScreensRaw,
|
||
coreFocus,
|
||
language,
|
||
});
|
||
const qualityChecksAfterPreflight = evaluatePlanQuality({
|
||
screens: preflightScreens,
|
||
hasModelImage,
|
||
hasBundleImages,
|
||
coreFocus,
|
||
});
|
||
const preflightPassed =
|
||
(preflight.passed || preflight.rewriteApplied) &&
|
||
preflight.designDirectorScore >= 85 &&
|
||
preflight.marketingDirectorScore >= 85;
|
||
|
||
const shouldUsePreflightPlan = qualityChecksAfterPreflight.passed && preflightPassed;
|
||
const shouldFallback = !shouldUsePreflightPlan;
|
||
const screens = shouldFallback ? fallback.screens : preflightScreens;
|
||
const globalStyle = shouldFallback ? fallback.globalStyle : preflight.candidateGlobalStyle;
|
||
const qualityIssuesMerged = [
|
||
...new Set([
|
||
...qualityChecksInitial.issues,
|
||
...qualityChecksAfterPreflight.issues,
|
||
...preflight.issues.map((issue) => `双总监预审:${issue}`),
|
||
]),
|
||
];
|
||
const qualityChecks = {
|
||
passed: !shouldFallback,
|
||
issues: shouldFallback
|
||
? qualityIssuesMerged.length > 0
|
||
? qualityIssuesMerged
|
||
: ["双总监预审未通过,已回退到稳定分镜模板。"]
|
||
: qualityIssuesMerged,
|
||
};
|
||
const prompts = screens.map((item) => item.visualPrompt);
|
||
const summaryRaw = String(parsed?.summary || "").trim();
|
||
const summary = desensitizeAudienceCopy(
|
||
!shouldFallback
|
||
? summaryRaw || fallback.summary
|
||
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
|
||
language
|
||
);
|
||
|
||
const response: DetailPlanResponse = {
|
||
summary,
|
||
prompts,
|
||
screenSize: {
|
||
width: 790,
|
||
height: 1300,
|
||
},
|
||
globalStyle,
|
||
skillFlow: fallback.skillFlow,
|
||
qualityChecks: qualityChecks.passed
|
||
? qualityChecks
|
||
: {
|
||
passed: false,
|
||
issues: [...qualityChecks.issues],
|
||
},
|
||
preflight: {
|
||
passed: !shouldFallback,
|
||
designDirectorScore: preflight.designDirectorScore,
|
||
marketingDirectorScore: preflight.marketingDirectorScore,
|
||
issues: preflight.issues,
|
||
rewriteApplied: preflight.rewriteApplied,
|
||
},
|
||
screens,
|
||
model: {
|
||
id: plannerConfig.model.id,
|
||
displayName: plannerConfig.model.displayName,
|
||
providerType: plannerConfig.provider.type,
|
||
},
|
||
};
|
||
|
||
return NextResponse.json({ success: true, data: response });
|
||
} catch (error) {
|
||
console.error("[Canvas Detail Plan] Error:", error);
|
||
return NextResponse.json(
|
||
{ success: false, error: error instanceof Error ? error.message : "详情规划失败" },
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
}
|