2481 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { generateText } from "ai";
import { auth } from "@/app/(auth)/auth";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
getSiteSettings,
} from "@/lib/db/queries";
import {
getAiModelAgentCodingModelId,
isAiModelMultimodal,
} from "@/lib/db/ai-model-flags";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import {
buildDetailKimiGenerateOptions,
type DetailKimiGenerateOptions,
} from "@/lib/ai/detail-agent-kimi-profile";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { createLogger } from "@/lib/observability/logger";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
buildDetailUiProtocol,
type DetailUiProtocol,
} from "@/lib/detail-ui-protocol";
import { buildDetailUiSurfaceSnapshot } from "@/lib/detail-ui-surface-adapter";
import {
normalizeDetailUiDslV2Document,
type DetailUiDslV2Document,
} from "@/lib/detail-ui-dsl-v2";
import {
buildDetailUiDesignSkillFlowStep,
buildDetailUiDesignSkillPrompt,
buildDetailUiDesignSkillSpec,
summarizeDetailUiDesignSkill,
type DetailUiDesignSkillSpec,
type DetailUiDesignSkillSummary,
} from "@/lib/detail-ui-design-skill";
import { buildDetailBrandStyleBoard } from "@/lib/detail-ui-style-board";
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
export const runtime = "nodejs";
export const maxDuration = 180;
const DETAIL_PLAN_INLINE_MAX_IMAGES = 4;
const DETAIL_PLAN_INLINE_TIMEOUT_MS = 4000;
const DETAIL_PLAN_INLINE_RETRIES = 1;
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
const logger = createLogger("canvas-detail-plan");
const DETAIL_MODEL_USAGE_HINT_PATTERN =
/模特|人物|人像|真人|人体|露脸|人脸|面部|发型|体型|肤色|穿搭|服装|试坐|试用|手持|拿着|自拍|合照|真人演示|人物场景|场景演示|使用者|顾客.{0,8}使用|model|person|people|human|face|hair|body|outfit|wearing|holding|selfie|lifestyle\s+demo/i;
const DETAIL_NO_MODEL_HINT_PATTERN =
/不要出现人物|不出现人物|不要有人物|无人|无人物|只展示产品|产品信息展示|不使用人物|不使用模特|禁止出现人物|no\s+(human|person|people)|without\s+(human|person|people)|product[-\s]?only/i;
function isAbortError(error: unknown): boolean {
const target = error as {
name?: unknown;
message?: unknown;
code?: unknown;
ABORT_ERR?: unknown;
};
const name = String(target?.name || "");
const message = String(target?.message || "");
const code = Number(target?.code);
const abortCode = Number(target?.ABORT_ERR);
return (
name === "AbortError" ||
code === 20 ||
abortCode === 20 ||
/aborted|abort/i.test(message)
);
}
function isReferenceImageDownloadError(error: unknown): boolean {
const target = error as { name?: unknown; message?: unknown };
const name = String(target?.name || "");
const message = String(target?.message || "");
return (
/AI_DownloadError/i.test(name) ||
/Failed to download/i.test(message) ||
/404\s*Not\s*Found/i.test(message) ||
/403\s*Forbidden/i.test(message) ||
/image.*download/i.test(message)
);
}
function isTransientNetworkError(error: unknown): boolean {
const target = error as {
message?: unknown;
cause?: unknown;
};
const message = String(target?.message || "");
const cause = target?.cause as { message?: unknown; code?: unknown } | undefined;
const causeMessage = String(cause?.message || "");
const causeCode = String(cause?.code || "");
return /fetch failed|enotfound|econnreset|econnrefused|socket|timeout/i.test(
`${message} ${causeMessage} ${causeCode}`
);
}
function supportsVisionPlanning(model: {
supportsVision?: boolean | null;
capabilities?: unknown;
defaultParams?: unknown;
}) {
return (
Boolean(model.supportsVision) ||
isAiModelMultimodal({
capabilities: model.capabilities,
defaultParams: model.defaultParams,
})
);
}
function isVolcanoResponsesPlanner(config: {
provider?: { type?: string | null; baseUrl?: string | null } | null;
}) {
if (!config?.provider || config.provider.type !== "custom") return false;
const baseUrl = String(config.provider.baseUrl || "").toLowerCase();
return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl);
}
async function generateTextWithTimeout(params: any, timeoutMs: number) {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`OperationTimeoutError: generateText timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([
generateText({
...params,
abortSignal: controller.signal,
} as any),
timeoutPromise,
]);
} finally {
if (timer) clearTimeout(timer);
}
}
type DetailPlanRequest = {
prompt?: string;
count?: number;
generationMode?: string;
outputLanguage?: string;
referenceImages?: string[];
referenceImageNotes?: string[];
modelImageUrl?: string;
bundleImageUrls?: string[];
brief?: {
productSpu?: string;
demandType?: string;
productName?: string;
targetAudience?: string;
mainUser?: string;
usageScenario?: string;
triggerMoment?: string;
coreNeed?: string;
riskConcern?: string;
expectedResult?: string;
coreSellingPoint?: string;
positioningStatement?: string;
userVoice?: string;
competitorReference?: string;
priceBand?: string;
brandTone?: string;
bannedWords?: string;
};
};
type DetailGenerationMode = "classic" | "agent_layout";
type DetailBrief = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
usageScenario: string;
triggerMoment: string;
coreNeed: string;
riskConcern: string;
expectedResult: string;
coreSellingPoint: string;
positioningStatement: string;
userVoice: string;
competitorReference: string;
priceBand: string;
brandTone: string;
bannedWords: string;
};
type DetailScreenType = "A" | "B" | "C";
type DetailPlanScreen = {
index: number;
screenType: DetailScreenType;
objective: string;
title: string;
subtitle: string;
copy: string;
layoutNote: string;
useModel: boolean;
modelReason: string;
bundleShowcase: boolean;
bundleReason: string;
visualPrompt: string;
fabe: {
b: string;
f: string;
a: string;
e: string;
};
};
type DetailPlanResponse = {
generationMode?: DetailGenerationMode;
summary: string;
prompts: string[];
screenSize: {
width: number;
height: number;
};
globalStyle: {
designLanguage: string;
typography: {
fontFamily: string;
title: string;
subtitle: string;
body: string;
};
layout: {
grid: string;
safeArea: string;
rhythm: string;
};
palette: string;
};
skillFlow: {
name: string;
version: string;
steps: string[];
};
designSkill?: DetailUiDesignSkillSummary;
qualityChecks: {
passed: boolean;
issues: string[];
};
uiValidation?: {
passed: boolean;
issues: string[];
};
preflight?: {
passed: boolean;
designDirectorScore: number;
marketingDirectorScore: number;
issues: string[];
rewriteApplied: boolean;
};
screens: DetailPlanScreen[];
uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
brandStyleBoard?: UiScreenDocument;
model?: {
id: string;
displayName: string;
providerType: string;
};
};
function buildFixedModelIdentityInstruction(language: "zh" | "en") {
return language === "en"
? "Fixed model identity lock: if this frame includes a person, use only the same person from the uploaded model image. Keep face, hairstyle, body type, skin tone, and outfit style consistent; do not replace with another person. Pose and camera angle may adapt to the composition."
: "固定模特身份硬约束:如果本屏出现人物,只允许使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换成其他人。姿势、视线和机位可按构图调整。";
}
function screenSuggestsModelUsage(screen: DetailPlanScreen) {
const text = [
screen.objective,
screen.title,
screen.subtitle,
screen.copy,
screen.layoutNote,
screen.modelReason,
screen.visualPrompt,
screen.fabe?.b,
screen.fabe?.f,
screen.fabe?.a,
screen.fabe?.e,
]
.filter(Boolean)
.join(" ");
return (
DETAIL_MODEL_USAGE_HINT_PATTERN.test(text) &&
!DETAIL_NO_MODEL_HINT_PATTERN.test(text)
);
}
function normalizeDetailModelUsage(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
language: "zh" | "en";
}) {
const { screens, hasModelImage, language } = params;
const identityInstruction = buildFixedModelIdentityInstruction(language);
return screens.map((screen) => {
if (!hasModelImage) {
return {
...screen,
useModel: false,
modelReason:
language === "en"
? "No model image was provided, so this frame does not use a fixed model."
: "未提供模特图,当前分镜不使用人物。",
};
}
const shouldUseModel = screen.useModel || screenSuggestsModelUsage(screen);
if (!shouldUseModel) {
return screen;
}
const modelReason =
screen.modelReason && !DETAIL_NO_MODEL_HINT_PATTERN.test(screen.modelReason)
? screen.modelReason
: language === "en"
? "This frame contains a human/model scenario, so the uploaded fixed model must be used."
: "本屏包含人物/模特展示语义,必须使用上传的固定模特图。";
const visualPrompt = screen.visualPrompt.includes(identityInstruction)
? screen.visualPrompt
: `${screen.visualPrompt} ${identityInstruction}`;
return {
...screen,
useModel: true,
modelReason,
visualPrompt,
};
});
}
type DetailPlanPreflight = {
passed: boolean;
designDirectorScore: number;
marketingDirectorScore: number;
issues: string[];
rewriteApplied: boolean;
};
type DetailCoreFocusContext = {
anchor: string;
primaryNeed: string;
namingToken: string;
firstScreenTitle: string;
firstScreenSubtitle: string;
focusTerms: string[];
};
const AGENT_PLANNING_PHRASE_PATTERNS = [
/第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
/本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
/这一屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
/第\s*[0-9]+\s*屏(?:展示|讲|说明|介绍|主要|重点)?/gi,
/\bscreen\s*[0-9]+\s*(shows?|focuses on|introduces?)?/gi,
/第一屏展示啥/gi,
];
const TITLE_META_TERMS = [
"核心价值",
"场景痛点",
"方案拆解",
"机制拆解",
"核心机制拆解",
"核心收益可证",
"差异强化",
"细节证明",
"信任收口",
"证据收口",
"卖点延展",
"解决方案",
"价值延展",
"证据背书",
];
const RENDER_META_PATTERNS: RegExp[] = [
/第\s*\d+\s*屏/gi,
/\bscreen\s*\d+\b/gi,
/\bframe\s*\d+\b/gi,
/核心机制拆解/gi,
/机制拆解/gi,
/差异强化/gi,
/证据收口/gi,
/核心收益可证/gi,
/方案拆解/gi,
/信任收口/gi,
];
function hasPlanningTitleMeta(value: string): boolean {
const text = String(value || "").trim();
if (!text) return false;
if (/第\s*\d+\s*屏|screen\s*\d+/i.test(text)) return true;
return TITLE_META_TERMS.some((term) => text.includes(term));
}
function stripAgentPlanningPhrases(value: string): string {
let result = value;
for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) {
result = result.replace(pattern, " ");
}
return result.replace(/\s+/g, " ").trim();
}
function stripRenderMetaTerms(value: string): string {
let result = String(value || "");
for (const pattern of RENDER_META_PATTERNS) {
result = result.replace(pattern, " ");
}
return result.replace(/\s+/g, " ").trim();
}
function desensitizeAudienceCopy(value: string, language: "zh" | "en"): string {
let result = String(value || "");
if (!result) return result;
if (language === "en") {
result = result
.replace(/\b\d{1,2}\s*(?:-|~|to)\s*\d{1,2}\s*years?\s*old\b/gi, "target users")
.replace(
/\b(?:for|toward|targeting)\s+[^,.;\n]{0,48}\b(?:women|men|female|male|audience|users?)\b/gi,
"for target scenario users"
)
.replace(/\b(?:primary audience|target audience)\s*:\s*[^,.;\n]+/gi, "target scenario users");
} else {
result = result
.replace(/\d{1,2}\s*(?:-|~|~|到|至)\s*\d{1,2}\s*岁(?:[男女]性)?/g, "目标用户")
.replace(
/(?:面向|针对)[^,。;\n]{0,40}(?:人群|用户|女性|男性|宝妈|白领|学生|新手)/g,
"围绕目标场景用户"
)
.replace(
/(?:主购买人群|目标人群|适用人群)\s*[::]\s*[^,。;\n]+/g,
"目标场景用户"
);
}
return result.replace(/\s+/g, " ").replace(/,{2,}/g, ",").trim();
}
const GENERIC_FLUFF_WORDS_ZH = [
"高级感",
"轻奢",
"超好用",
"巨能装",
"适合所有场景",
"适合所有人",
];
function visibleTitleLength(value: string): number {
return value
.replace(/[^\p{L}\p{N}]+/gu, "")
.trim().length;
}
function splitToCoreTerms(value: string, language: "zh" | "en"): string[] {
const raw = String(value || "").trim();
if (!raw) return [];
if (language === "en") {
return Array.from(
new Set(
raw
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((term) => term.trim())
.filter((term) => term.length >= 3)
.slice(0, 6)
)
);
}
const compact = raw.replace(/\s+/g, "");
const clauses = compact
.split(/[,。;、::,.!?!?/|()()\-—]+/g)
.map((item) => item.trim())
.filter(Boolean);
const seed = clauses[0] || compact;
const terms = new Set<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"
? "Hero-cover rule: single dominant product visual, cinematic light, strong focal contrast, clean headline-safe top area, no collage."
: "首屏必须是封面海报感:单一主视觉主体、电影级光影、强焦点对比、顶部清晰标题安全区,禁止拼贴说明图。";
let title = screen.title;
let subtitle = screen.subtitle;
let objective = screen.objective;
let copy = screen.copy;
let layoutNote = screen.layoutNote;
let benefit = screen.fabe.b;
if (index === 0) {
title =
language === "zh"
? coreFocus.firstScreenTitle
: hasPlanningTitleMeta(title) || !containsCoreTerms(title, coreFocus.focusTerms)
? coreFocus.firstScreenTitle
: title;
subtitle = containsCoreTerms(`${subtitle} ${copy}`, coreFocus.focusTerms)
? subtitle
: coreFocus.firstScreenSubtitle;
objective =
language === "en"
? `Hero poster cover for the One selling-point in one glance, instantly answer why it is worth buying.`
: "封面海报化呈现The One核心卖点,一眼回答“为什么值得买”。";
layoutNote =
language === "en"
? "Poster composition: product hero occupies 55-70% visual weight; top text safe area is clean; avoid dense multi-module layout."
: "海报封面构图:产品主体占画面55-70%视觉权重,顶部标题区留白清晰,避免密集小模块拼贴。";
}
if (!containsCoreTerms(objective, coreFocus.focusTerms)) {
objective =
language === "en"
? `${objective}. Keep reinforcing "${coreFocus.anchor}".`
: `${objective},持续强化“${coreFocus.anchor}”。`;
}
if (!focused) {
copy =
language === "en"
? `${copy} Keep all messaging aligned to "${coreFocus.anchor}" and "${coreFocus.primaryNeed}".`
: `${copy} 全屏文案围绕“${coreFocus.anchor}”,统一服务“${coreFocus.primaryNeed}”。`;
benefit =
language === "en"
? `Deliver "${coreFocus.primaryNeed}" under "${coreFocus.anchor}" with lower effort and clearer payoff.`
: `在“${coreFocus.anchor}”下交付“${coreFocus.primaryNeed}”结果,降低决策与使用成本。`;
}
if (
language === "zh" &&
index > 0 &&
index <= 3 &&
namingWord &&
!String(`${subtitle} ${copy}`).includes(namingWord)
) {
copy = `${copy} 持续强化“${namingWord}”这一记忆点,形成独有搜索词。`;
}
return {
...screen,
title,
subtitle,
objective,
copy,
layoutNote,
fabe: {
...screen.fabe,
b: benefit,
},
visualPrompt: (() => {
let nextPrompt = screen.visualPrompt.includes(coreFocus.anchor)
? screen.visualPrompt
: `${screen.visualPrompt} ${promptCoreLine}`;
if (index === 0 && !/海报|封面|主视觉|kv|hero|poster|cover/i.test(nextPrompt)) {
nextPrompt = `${nextPrompt} ${firstScreenPosterLine}`;
}
return nextPrompt;
})(),
};
});
}
function buildTypographySpec(language: "zh" | "en") {
if (language === "en") {
return {
fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei",
title:
"Main title 64px, weight 700, line-height 1.15, max 12 Chinese chars equivalent, single line preferred, at most 2 lines",
subtitle:
"Subtitle 34px, weight 600, line-height 1.25, placed 16px below title, max 30 Chinese chars equivalent",
body:
"Body text 24px, weight 500, line-height 1.45, 10px paragraph gap, max 4 lines per block",
safeArea:
"Top text safe-area starts at 64px and ends within 360px; keep product hero away from text zone",
rhythm:
"Horizontal margin 56px, inter-module gap 24px, all screens keep identical spacing rhythm",
};
}
return {
fontFamily: "Noto Sans SC / PingFang SC / Microsoft YaHei",
title:
"主标题 64px、字重700、行高1.15,建议1-2行,首屏控制在6-20字(命名词+需求句),不得出现“第X屏”",
subtitle:
"副标题 34px、字重600、行高1.25,位于主标题下方16px,控制在30字以内",
body:
"正文 24px、字重500、行高1.45、段间距10px,每块最多4行,避免密集堆字",
safeArea: "顶部文字安全区 y=64~360,产品主体避免压住标题区",
rhythm: "左右边距56px,模块间距24px,全套页面保持相同留白节奏",
};
}
function buildDetailSkillFlow(language: "zh" | "en") {
if (language === "en") {
return {
name: "AI Detail Generation Skill",
version: "1.1",
steps: [
"Positioning Card: identify buyer, user, scenario, trigger, risk, expected outcome.",
"Need Decomposition: prioritize core demand and convert feature to buying reason.",
"Golden Five-Screen Structure: core value, differentiation, trust proof.",
"FABE Copying: each frame includes Benefit, Feature, Advantage, Evidence.",
"A/B/C Visual Rhythm: emotional scene + rational proof + conversion closure.",
"Global Consistency: fixed size, unified typography hierarchy, layout rhythm and style.",
"Per-frame Quality Review: check title hierarchy, typography, and layout clarity, then regenerate once if needed.",
],
};
}
return {
name: "AI详情生成技能",
version: "1.1",
steps: [
"定位卡:明确购买人群、使用者、主场景、触发时刻、顾虑与期望结果。",
"需求拆解:按核心需求优先级,把卖点转成买点表达。",
"黄金五屏:前2屏讲核心卖点,3-4屏做差异强化,第5屏证据收口。",
"FABE文案:每屏都输出利益(B)、特征(F)、优势(A)、证据(E)。",
"A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾。",
"全局一致性:固定尺寸、统一字体层级、统一排版节奏与设计风格。",
"逐屏质检:每屏生成后检查标题层级/版式密度/文字可读性,不达标自动二次修图。",
],
};
}
function applyDesignSkillToFlow(
flow: DetailPlanResponse["skillFlow"],
designSkill: DetailUiDesignSkillSpec
): DetailPlanResponse["skillFlow"] {
return {
...flow,
version: "1.2",
steps: [...flow.steps, buildDetailUiDesignSkillFlowStep(designSkill)],
};
}
function clampCount(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) return 14;
return Math.min(20, Math.max(5, Math.round(num)));
}
function sanitizeReferenceImages(images: unknown): string[] {
if (!Array.isArray(images)) return [];
return images
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 12);
}
function sanitizeReferenceImageNotes(notes: unknown): string[] {
if (!Array.isArray(notes)) return [];
return notes
.map((item) => String(item || "").replace(/\s+/g, " ").trim())
.filter(Boolean)
.slice(0, 12);
}
function sanitizeBundleImages(images: unknown): string[] {
if (!Array.isArray(images)) return [];
return images
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 3);
}
function normalizeOutputLanguage(value: unknown): "zh" | "en" {
const raw = String(value || "").toLowerCase();
if (raw.includes("en") || raw.includes("english") || raw.includes("英文")) {
return "en";
}
return "zh";
}
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function normalizeDetailBrief(value: unknown): DetailBrief {
const raw = (value || {}) as Record<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",
"Comfort benefit clarity in real use",
"Differentiated value in key details",
"Trust proof and confidence closing",
]
: [
"首屏海报主视觉聚焦单一核心卖点",
"痛点共鸣与触发场景放大",
"真实使用中的舒适收益说明",
"关键细节体现差异优势",
"证据背书与购买信心建立",
];
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
const defaultTitles =
language === "en"
? [
coreFocus.firstScreenTitle,
"Sit Longer, Feel Better",
"Comfort That Holds Up",
"Stability You Can Trust",
"Built to Last Daily",
]
: [
coreFocus.firstScreenTitle,
"久坐更轻松",
"贴合更舒适",
"稳固更省心",
"耐用不将就",
];
return Array.from({ length: count }).map((_, index) => {
const screenType =
screenTypeSequence[index] || (index % 3 === 0 ? "B" : index % 3 === 1 ? "A" : "C");
let objective =
defaultObjectives[index] ||
(language === "en"
? `Extended detail frame ${index + 1}`
: `延展分镜 ${index + 1}`);
const isBundleScreen = hasBundleImages && index === count - 1;
const title =
isBundleScreen
? language === "en"
? "Bundle Cross-sell"
: "搭配推荐"
: language === "en"
? defaultTitles[index] || `Comfort Upgrade ${index + 1}`
: defaultTitles[index] || "舒适升级";
const subtitle =
isBundleScreen
? language === "en"
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
: index === 0
? coreFocus.firstScreenSubtitle
: language === "en"
? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
: `围绕“${coreFocus.anchor}”,在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
const copy =
isBundleScreen
? language === "en"
? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.`
: `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。`
: language === "en"
? `${productLabel} turns key pain points into visible outcomes under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.`
: `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`;
let layoutNote =
isBundleScreen
? language === "en"
? "Main product remains primary; companion products are secondary with clear hierarchy and no visual clutter."
: "主产品为视觉主角,搭配产品作为辅助,层级清晰避免喧宾夺主。"
: language === "en"
? `Use fixed layout rhythm. Keep title/subtitle/body hierarchy identical to all other frames, and preserve text safe-area.`
: `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致,并严格保留文字安全区。`;
const useModel = !isBundleScreen && hasModelImage && (index === 1 || index === 3 || index >= 5);
const modelReason = useModel
? language === "en"
? "This frame needs human scenario demonstration, so use the fixed model image."
: "本屏需要人物场景演示,使用固定模特图。"
: language === "en"
? "This frame focuses on product-only communication without human figure."
: "本屏聚焦产品信息展示,不使用人物。";
const bundleShowcase = isBundleScreen;
const bundleReason = bundleShowcase
? language === "en"
? "Optional bundle references were provided; this frame must show scenario-based product pairing for cross-sell."
: "用户提供了搭配产品图,本屏必须展示场景化产品搭配以增强关联销售。"
: language === "en"
? "This frame is not the bundle showcase frame."
: "本屏非搭配展示页。";
const fabe =
language === "en"
? {
b: `Get ${coreNeed} resolved with less effort and risk.`,
f: `Show concrete product structure and material facts.`,
a: `Outperform generic options in core scenario performance.`,
e: `Use test data, certification, and before-after comparisons.`,
}
: {
b: `用户可低成本获得“${coreNeed}”的结果。`,
f: "给出可验证的结构、材质或工艺事实。",
a: "强调在主场景下优于普通方案的关键优势。",
e: "使用检测、认证、对比图等证据收尾。",
};
if (index === 0 && !isBundleScreen) {
objective =
language === "en"
? "Poster-style hero cover for the One selling-point, making the buying reason obvious in one glance."
: "海报级首屏封面,聚焦The One核心卖点,一眼说明购买理由。";
layoutNote =
language === "en"
? "Hero poster layout: dominant single product focal point, cinematic lighting and contrast, clear top headline safe area, no collage cards."
: "首屏海报布局:单一主体强焦点,电影感光影与对比,顶部标题区清晰,禁止拼贴卡片式说明。";
}
const visualPrompt =
language === "en"
? [
`E-commerce detail frame, objective: ${objective}.`,
`Fixed vertical canvas 790x1300 pixels.`,
`Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
"Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.",
`Core selling-point anchor: ${coreFocus.anchor}; this frame must stay aligned to this anchor.`,
index === 0
? "Hero-cover rule: this must look like a poster key visual with one dominant subject, strong focus contrast, and no collage cards."
: "",
`Positioning statement: ${positioning}.`,
`Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
`Layout: ${globalStyle.layout.grid}; ${globalStyle.layout.safeArea}; ${globalStyle.layout.rhythm}.`,
`Palette: ${globalStyle.palette}.`,
referenceImageNotes.length > 0
? `Reference image set roles: ${referenceImageNotes.join(" | ")}. Keep product identity and details fully consistent.`
: "",
bundleShowcase
? `This must be a bundle showcase frame: show the hero product with companion products from optional bundle refs in one coherent scenario.`
: "",
useModel
? "Integrate the same human model from the uploaded model image."
: "No human figure in this frame.",
`Commercial product photography, typography-safe area, no extra watermark.`,
`Task context: ${prompt}.`,
].join(" ")
: [
`电商详情页分镜,目标:${objective}。`,
"画布固定竖版 790x1300 像素。",
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};核心需求:${coreNeed}。`,
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
`核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`,
index === 0
? "首屏封面规则:必须是海报主视觉(非说明图拼贴),单一主体大图、强聚焦、高级商业海报质感。"
: "",
`一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
`版式规范:${globalStyle.layout.grid};${globalStyle.layout.safeArea};${globalStyle.layout.rhythm}。`,
`配色规范:${globalStyle.palette}。`,
referenceImageNotes.length > 0
? `参考图角色:${referenceImageNotes.join(";")};必须严格保持产品一致性与细节一致性。`
: "",
bundleShowcase
? "本屏必须是搭配展示页:同屏展示主产品与搭配产品,体现组合使用场景与加购价值。"
: "",
useModel ? "本屏融入固定模特图中的同一人物。" : "本屏不要出现人物。",
"商业摄影质感,留出清晰文案排版区,不要水印。",
`任务上下文:${prompt}。`,
].join("");
return {
index: index + 1,
screenType,
objective,
title,
subtitle,
copy,
layoutNote,
useModel,
modelReason,
bundleShowcase,
bundleReason,
visualPrompt,
fabe,
};
});
}
function buildFallbackPlan(params: {
prompt: string;
count: number;
generationMode: DetailGenerationMode;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
designSkill: DetailUiDesignSkillSpec;
referenceImageNotes: string[];
hasModelImage: boolean;
hasBundleImages: boolean;
bundleImageCount: number;
}): DetailPlanResponse {
const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = applyDesignSkillToFlow(buildDetailSkillFlow(language), params.designSkill);
const designSkillSummary = summarizeDetailUiDesignSkill(params.designSkill);
const fallbackScreens = buildFallbackScreens({
...params,
globalStyle,
});
const screens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
}),
hasModelImage: params.hasModelImage,
language,
});
const qualityChecks = evaluatePlanQuality({
screens,
hasModelImage: params.hasModelImage,
hasBundleImages: params.hasBundleImages,
coreFocus: params.coreFocus,
});
const uiProtocol = buildDetailUiProtocol({
generationMode: params.generationMode,
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
screens,
});
const uiValidation = validateDetailUiProtocol(uiProtocol);
const mergedIssues = [
...qualityChecks.issues,
...uiValidation.issues.map((item) => item.message),
];
return {
generationMode: params.generationMode,
summary:
language === "en"
? `Generated ${count} storyboard frames with fixed size 790x1300.`
: `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`,
prompts: screens.map((item) => item.visualPrompt),
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow,
designSkill: designSkillSummary,
qualityChecks: {
passed: qualityChecks.passed && uiValidation.passed,
issues: mergedIssues,
},
uiValidation: {
passed: uiValidation.passed,
issues: uiValidation.issues.map((item) => item.message),
},
screens,
uiProtocol,
uiSurface:
params.generationMode === "agent_layout"
? buildDetailUiSurfaceSnapshot({
generationMode: params.generationMode,
screenSize: {
width: 790,
height: 1300,
},
summary:
language === "en"
? `Generated ${count} storyboard frames with fixed size 790x1300.`
: `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`,
globalStyle,
screens,
uiProtocol,
uiValidation: {
passed: uiValidation.passed,
issues: uiValidation.issues.map((item) => item.message),
},
})
: undefined,
brandStyleBoard: buildDetailBrandStyleBoard({
language,
prompt: params.prompt,
brief: params.brief,
globalStyle,
designSkill: designSkillSummary,
}),
};
}
function sanitizeScreen(
raw: unknown,
fallback: DetailPlanScreen,
language: "zh" | "en"
): DetailPlanScreen {
const source = (raw || {}) as Record<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(
stripAgentPlanningPhrases(
safeText(source.visualPrompt, fallback.visualPrompt)
.replace(/第\s*\d+\s*屏/gi, "")
.replace(/\bscreen\s*\d+\b/gi, "")
.replace(/\bframe\s*\d+\b/gi, "")
.replace(/\s+/g, " ")
.trim()
),
language
);
const normalizeTitle = (value: string) => {
const cleaned = stripAgentPlanningPhrases(
value
.replace(/第\s*\d+\s*屏/gi, "")
.replace(/\bscreen\s*\d+\b/gi, "")
.replace(/[((]?\s*\d+\s*[))]?$/g, "")
.replace(/\s+/g, " ")
.trim()
);
if (!cleaned) return fallback.title;
if (hasPlanningTitleMeta(cleaned)) return fallback.title;
return cleaned;
};
const normalizeSubtitle = (value: string) =>
stripAgentPlanningPhrases(
value
.replace(/第\s*\d+\s*屏/gi, "")
.replace(/\bscreen\s*\d+\b/gi, "")
.replace(/\s+/g, " ")
.trim()
);
const normalizeCopy = (value: string) =>
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.copy, language);
const normalizeLayoutNote = (value: string) =>
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.layoutNote, language);
const rawScreenType = String(source.screenType || "").trim().toUpperCase();
const screenType: DetailScreenType =
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
? (rawScreenType as DetailScreenType)
: fallback.screenType;
return {
index: fallback.index,
screenType,
objective: desensitizeAudienceCopy(safeText(source.objective, fallback.objective), language),
title: desensitizeAudienceCopy(normalizeTitle(safeText(source.title, fallback.title)), language),
subtitle:
desensitizeAudienceCopy(
normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle,
language
) || fallback.subtitle,
copy: normalizeCopy(safeText(source.copy, fallback.copy)),
layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)),
useModel:
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
modelReason: desensitizeAudienceCopy(safeText(source.modelReason, fallback.modelReason), language),
bundleShowcase:
typeof source.bundleShowcase === "boolean"
? source.bundleShowcase
: fallback.bundleShowcase,
bundleReason: desensitizeAudienceCopy(safeText(source.bundleReason, fallback.bundleReason), language),
visualPrompt: visualPrompt.includes("790x1300")
? visualPrompt
: `${visualPrompt} 画布固定 790x1300 像素。`,
fabe: {
b: safeText(fabeSource.b, fallback.fabe.b),
f: safeText(fabeSource.f, fallback.fabe.f),
a: safeText(fabeSource.a, fallback.fabe.a),
e: safeText(fabeSource.e, fallback.fabe.e),
},
};
}
function sanitizePlannedScreens(
raw: unknown,
fallbackScreens: DetailPlanScreen[],
language: "zh" | "en"
): DetailPlanScreen[] {
if (!Array.isArray(raw)) {
return fallbackScreens;
}
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback, language));
}
function sanitizeScreensForRender(params: {
screens: DetailPlanScreen[];
coreFocus: DetailCoreFocusContext;
language: "zh" | "en";
}): DetailPlanScreen[] {
const { screens, coreFocus, language } = params;
return screens.map((screen, index) => {
const cleanedTitle = stripRenderMetaTerms(screen.title);
const title =
cleanedTitle && !hasPlanningTitleMeta(cleanedTitle)
? cleanedTitle
: index === 0
? coreFocus.firstScreenTitle
: language === "zh"
? "舒适升级"
: "Comfort Upgrade";
const subtitleRaw = stripRenderMetaTerms(screen.subtitle);
const subtitle =
subtitleRaw || (index === 0 ? coreFocus.firstScreenSubtitle : screen.subtitle);
return {
...screen,
title,
subtitle,
objective: stripRenderMetaTerms(screen.objective),
copy: stripRenderMetaTerms(screen.copy),
layoutNote: stripRenderMetaTerms(screen.layoutNote),
modelReason: stripRenderMetaTerms(screen.modelReason),
bundleReason: stripRenderMetaTerms(screen.bundleReason),
visualPrompt: stripRenderMetaTerms(screen.visualPrompt),
fabe: {
b: stripRenderMetaTerms(screen.fabe.b),
f: stripRenderMetaTerms(screen.fabe.f),
a: stripRenderMetaTerms(screen.fabe.a),
e: stripRenderMetaTerms(screen.fabe.e),
},
};
});
}
function sanitizeGlobalStyle(
raw: unknown,
fallback: DetailPlanResponse["globalStyle"],
): DetailPlanResponse["globalStyle"] {
const source = (raw || {}) as Record<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;
isLocalMoonshot?: boolean;
kimiOptions?: DetailKimiGenerateOptions;
timeoutMs?: number;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
designSkillPrompt: string;
globalStyle: DetailPlanResponse["globalStyle"];
screens: DetailPlanScreen[];
hasModelImage: boolean;
hasBundleImages: boolean;
}) {
const defaultResult = {
passed: true,
designDirectorScore: 88,
marketingDirectorScore: 88,
issues: [] as string[],
rewriteApplied: false,
candidateGlobalStyle: params.globalStyle,
candidateScreens: params.screens,
};
try {
const payload = JSON.stringify(
{
globalStyle: params.globalStyle,
screens: params.screens.map((screen) => ({
index: screen.index,
screenType: screen.screenType,
objective: screen.objective,
title: screen.title,
subtitle: screen.subtitle,
copy: screen.copy,
layoutNote: screen.layoutNote,
useModel: screen.useModel,
bundleShowcase: screen.bundleShowcase,
fabe: screen.fabe,
visualPrompt: screen.visualPrompt,
})),
},
null,
2,
);
const briefText = buildBriefText(params.brief, params.language);
const instruction =
params.language === "en"
? [
"Preflight this storyboard before image generation using dual-director standards.",
"Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).",
"Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).",
"Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.",
"Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.",
"Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.",
"Must keep canvas 790x1300, unified typography hierarchy, and consistent style.",
`All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`,
params.hasModelImage
? "Model image is provided: only use where needed, keep identity consistent."
: "No model image: all frames must avoid human model dependence.",
params.hasBundleImages
? "Bundle refs provided: keep exactly one bundle showcase frame."
: "No bundle refs: bundle showcase must be false in all frames.",
"Apply this UI design skill as a hard visual preflight contract:",
params.designSkillPrompt,
"If not pass, rewrite globalStyle and all screens directly to a production-ready version.",
"Return strict JSON only:",
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
"Current plan JSON:",
payload,
"Input brief:",
briefText,
]
: [
"在生图前,按双总监标准对当前分镜做预审。",
"角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。",
"角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。",
"禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。",
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
"首屏必须具备“封面海报感”:单一主视觉、强焦点、清晰标题区,不得做拼贴式说明页。",
`整套文案必须围绕同一核心卖点锚点:“${params.coreFocus.anchor}”,不得跑题。`,
params.hasModelImage
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
: "未提供模特图:所有分镜不得依赖模特人物。",
params.hasBundleImages
? "已提供搭配图:必须且仅保留1屏搭配展示页。"
: "未提供搭配图:所有分镜 bundleShowcase=false。",
"必须把以下 UI 设计技能作为硬性视觉预审契约:",
params.designSkillPrompt,
"若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。",
"只返回严格JSON:",
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
"当前方案JSON:",
payload,
"输入简报:",
briefText,
];
const { text } = await generateTextWithTimeout({
model: params.model,
messages: [
{
role: "user",
content: instruction.join("\n"),
},
],
system:
params.language === "en"
? "You are both an art director and a marketing director for high-conversion e-commerce detail pages. Output JSON only."
: "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。",
temperature: params.isLocalMoonshot ? 1 : 0.1,
maxTokens: 2000,
...(params.kimiOptions || {}),
}, params.timeoutMs || DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS);
const parsed = extractJsonBlock<{
pass?: unknown;
designDirectorScore?: unknown;
marketingDirectorScore?: unknown;
issues?: unknown;
revisedGlobalStyle?: unknown;
revisedScreens?: unknown;
}>(text || "");
const designRaw = Number(parsed?.designDirectorScore);
const marketingRaw = Number(parsed?.marketingDirectorScore);
const designDirectorScore = Number.isFinite(designRaw)
? Math.max(0, Math.min(100, Math.round(designRaw)))
: defaultResult.designDirectorScore;
const marketingDirectorScore = Number.isFinite(marketingRaw)
? Math.max(0, Math.min(100, Math.round(marketingRaw)))
: defaultResult.marketingDirectorScore;
const issues = Array.isArray(parsed?.issues)
? parsed.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8)
: [];
const rawPass = typeof parsed?.pass === "boolean"
? parsed.pass
: designDirectorScore >= 85 && marketingDirectorScore >= 85 && issues.length === 0;
let candidateGlobalStyle = params.globalStyle;
let candidateScreens = params.screens;
let rewriteApplied = false;
if (parsed?.revisedGlobalStyle && typeof parsed.revisedGlobalStyle === "object") {
candidateGlobalStyle = sanitizeGlobalStyle(parsed.revisedGlobalStyle, params.globalStyle);
rewriteApplied = true;
}
if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) {
candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens, params.language);
rewriteApplied = true;
}
return {
passed: rawPass,
designDirectorScore,
marketingDirectorScore,
issues,
rewriteApplied,
candidateGlobalStyle,
candidateScreens,
};
} catch (error) {
logger.warn("preflight_failed", error);
return defaultResult;
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as DetailPlanRequest;
const prompt = String(body.prompt || "").trim();
if (!prompt) {
return NextResponse.json({ success: false, error: "请输入详情需求" }, { status: 400 });
}
const baseCount = clampCount(body.count);
const referenceImagesRaw = sanitizeReferenceImages(body.referenceImages);
const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes);
const modelImageUrlRaw = String(body.modelImageUrl || "").trim();
const bundleImageUrlsRaw = sanitizeBundleImages(body.bundleImageUrls);
const [referenceImageCheck, bundleImageCheck, modelImageCheck] = await Promise.all([
filterReachableRemoteUrls(referenceImagesRaw, { timeoutMs: 3000 }),
filterReachableRemoteUrls(bundleImageUrlsRaw, { timeoutMs: 3000 }),
modelImageUrlRaw
? filterReachableRemoteUrls([modelImageUrlRaw], { timeoutMs: 3000 })
: Promise.resolve({ kept: [] as string[], dropped: [] as string[] }),
]);
const referenceImages = referenceImageCheck.kept.slice(0, 12);
const bundleImageUrls = bundleImageCheck.kept.slice(0, 3);
const modelImageUrl = modelImageCheck.kept[0] || "";
if (referenceImageCheck.dropped.length > 0) {
logger.warn("reference_images_dropped", {
count: referenceImageCheck.dropped.length,
});
}
if (bundleImageCheck.dropped.length > 0) {
logger.warn("bundle_images_dropped", {
count: bundleImageCheck.dropped.length,
});
}
if (modelImageUrlRaw && !modelImageUrl) {
logger.warn("model_image_dropped");
}
const effectiveReferenceImageNotes =
referenceImages.length === referenceImagesRaw.length ? referenceImageNotes : [];
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
const hasBundleImages = bundleImageUrls.length > 0;
const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount;
const generationMode = normalizeDetailGenerationMode(body.generationMode);
const language = normalizeOutputLanguage(body.outputLanguage);
const brief = normalizeDetailBrief(body.brief);
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
return NextResponse.json(
{
success: false,
error: "请补充必填项:主购买人群、需求场景、核心需求",
},
{ status: 400 },
);
}
const coreFocus = buildCoreFocusContext(brief, language);
const designSkill = buildDetailUiDesignSkillSpec({
language,
generationMode,
screenCount: count,
hasReferenceImages: referenceImages.length > 0,
hasModelImage,
hasBundleImages,
productName: brief.productName,
brandTone: brief.brandTone,
});
const designSkillPrompt = buildDetailUiDesignSkillPrompt(designSkill);
const fallback = buildFallbackPlan({
prompt,
count,
generationMode,
language,
brief,
coreFocus,
designSkill,
referenceImageNotes: effectiveReferenceImageNotes,
hasModelImage,
hasBundleImages,
bundleImageCount: bundleImageUrls.length,
});
const [allModels, agentModel, currentSiteSettings] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
getSiteSettings(),
]);
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
siteSettingModelId: currentSiteSettings?.canvasDetailAgentModelId,
});
const reasoningCompatible = allModels.filter(
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&
reasoningCompatible.some(
(item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId,
)
? reasoningCompatible.find(
(item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId,
)
: undefined;
const fallbackAgentConfig =
agentModel &&
reasoningCompatible.some((item) => item.model.id === agentModel.model.id)
? reasoningCompatible.find((item) => item.model.id === agentModel.model.id)
: undefined;
const preferredAgentConfig = configuredDetailAgentConfig || fallbackAgentConfig;
let plannerConfig = preferredAgentConfig;
if (!localDetailAgentResolved && referenceImages.length > 0) {
if (!plannerConfig || !supportsVisionPlanning(plannerConfig.model)) {
plannerConfig = reasoningCompatible.find((item) => supportsVisionPlanning(item.model));
}
}
if (!localDetailAgentResolved && !plannerConfig) {
plannerConfig = reasoningCompatible[0];
}
if (!localDetailAgentResolved && !plannerConfig) {
return NextResponse.json({ success: true, data: fallback });
}
const selectedModelRef = localDetailAgentResolved
? localDetailAgentResolved.modelRef
: plannerConfig!.model;
const selectedProviderRef = localDetailAgentResolved
? localDetailAgentResolved.providerRef
: {
id: plannerConfig!.provider.id,
name: plannerConfig!.provider.name,
type: plannerConfig!.provider.type,
baseUrl: plannerConfig!.provider.baseUrl,
};
const plannerKimiOptions = buildDetailKimiGenerateOptions({
purpose: "plan",
generationMode,
modelId: selectedModelRef.modelId,
providerBaseUrl: selectedProviderRef.baseUrl,
isLocalMoonshot: Boolean(localDetailAgentResolved),
});
const preflightKimiOptions = buildDetailKimiGenerateOptions({
purpose: "preflight",
generationMode,
modelId: selectedModelRef.modelId,
providerBaseUrl: selectedProviderRef.baseUrl,
isLocalMoonshot: Boolean(localDetailAgentResolved),
});
logger.info("planner_model_selected", {
id: selectedModelRef.id,
displayName: selectedModelRef.displayName,
modelId: selectedModelRef.modelId,
codingModelId: localDetailAgentResolved
? null
: getAiModelAgentCodingModelId(plannerConfig!.model),
providerType: selectedProviderRef.type,
supportsVision: selectedModelRef.supportsVision,
detailAgentSettingModelId: currentSiteSettings?.canvasDetailAgentModelId || null,
selectionSource:
localDetailAgentResolved
? "detail-agent-local-config"
: configuredDetailAgentConfig?.model.id === plannerConfig?.model.id
? "detail-agent-setting"
: fallbackAgentConfig?.model.id === plannerConfig?.model.id
? "agent-default"
: "vision-fallback",
multimodalFlag: isAiModelMultimodal({
capabilities: selectedModelRef.capabilities,
defaultParams: selectedModelRef.defaultParams,
}),
hasReferenceImages: referenceImages.length > 0,
kimiDetailProfile: plannerKimiOptions.headers
? {
thinking: plannerKimiOptions.headers["x-detail-agent-kimi-thinking"],
maxTokens: plannerKimiOptions.maxTokens,
}
: null,
});
const codingModelId = localDetailAgentResolved
? null
: getAiModelAgentCodingModelId(plannerConfig!.model);
const model = localDetailAgentResolved
? localDetailAgentResolved.model
: await getLanguageModelById(
plannerConfig!.model.id,
codingModelId
);
if (!model) {
return NextResponse.json({ success: true, data: fallback });
}
const userInput = [
`用户需求:${prompt}`,
"以下是详情生成输入信息:",
buildBriefText(brief, language),
`目标分镜数量:${count}`,
`输出语言:${language === "en" ? "English" : "中文"}`,
`生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`,
"任务类型:电商详情页“AI详情生成”",
`详情 UI 设计技能:\n${designSkillPrompt}`,
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
"首屏命名规则:标题必须是“名字+需求一句话”(如“甩甩杆,自拍不手抖”),不能只给一个名字。",
"命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。",
effectiveReferenceImageNotes.length > 0
? `参考图说明(按上传顺序):${effectiveReferenceImageNotes.join(" | ")}`
: "",
hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。",
hasBundleImages
? `已提供 ${bundleImageUrls.length} 张搭配产品图,必须增加1屏场景化搭配展示用于关联销售。`
: "未提供搭配产品图。",
referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。",
]
.filter(Boolean)
.join("\n");
let imageInputs: Array<{ type: "image"; image: string }> = [];
if (
(localDetailAgentResolved || supportsVisionPlanning(plannerConfig!.model)) &&
referenceImages.length > 0
) {
try {
if (!localDetailAgentResolved && isVolcanoResponsesPlanner(plannerConfig!)) {
imageInputs = referenceImages
.slice(0, 6)
.map((url) => ({ type: "image" as const, image: url }));
} else {
const inlined = await inlineVisionImages(referenceImages, {
maxImages: DETAIL_PLAN_INLINE_MAX_IMAGES,
maxSide: 900,
timeoutMs: DETAIL_PLAN_INLINE_TIMEOUT_MS,
retries: DETAIL_PLAN_INLINE_RETRIES,
loggerPrefix: "[Canvas Detail Plan]",
});
imageInputs = inlined.inlineDataUrls.map((dataUrl) => ({
type: "image" as const,
image: dataUrl,
}));
}
} catch (error) {
if (isReferenceImageDownloadError(error) || isTransientNetworkError(error)) {
logger.warn("vision_input_preprocessing_failed", error, {
referenceImageCount: referenceImages.length,
});
imageInputs = [];
} else {
throw error;
}
}
}
const buildPlannerRequest = (visionInputs: Array<{ type: "image"; image: string }>) => ({
model,
messages: [
{
role: "user" as const,
content: [
{
type: "text" as const,
text: userInput,
},
...visionInputs,
],
},
],
system:
`你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` +
`硬性要求:\n` +
`1) 输出严格 JSON,不要其他文本。\n` +
`2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` +
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
(generationMode === "agent_layout"
? `4.1) 当前为AGENT排版模式:visualPrompt 只描述画面层,禁止要求模型生成标题/副标题/正文等可见文字。\n`
: "") +
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`5.1) 首屏主标题必须是“命名词+需求句”结构(6-20字,例如“甩甩杆,自拍不手抖”),并配一条结果副标题,直接回答“为什么值得买”。\n` +
`5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` +
`5.3) 中文首屏标题优先使用“核心卖点命名词+品类后缀”结构(如:甩甩杆、安敏裤)。\n` +
`5.4) 命名词必须大白话、好记、可口头传播,避免“智能/科技/方案/系统”等抽象词。\n` +
`5.5) 后续至少2屏在副标题或正文复现该命名词,强化用户检索记忆。\n` +
`5.6) 首屏必须是封面海报构图:单一主体主视觉、强对比焦点、电影感光影、顶部标题安全区;禁止拼贴说明页。\n` +
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
`7.2) 核心需求最多保留3-5条(1条必备 + 1-2条期待 + 1条魅力),避免页面过载。\n` +
`7.3) 每个核心卖点需覆盖“体验/社交/实用”三维中的至少2维,并落地到 copy 或 layoutNote。\n` +
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
`9.1) 前3屏默认节奏:屏1=场景痛点共鸣(B),屏2=买点总括,屏3=证据验证(对比/实测/口碑/认证)。\n` +
`10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识;首屏6-20字(命名词+需求句),其余屏建议4-10字。\n` +
`10.1) 主标题禁止使用规划节奏词:核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展。\n` +
`11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` +
`11.1) 副标题必须为单句结果句,禁止写成长段说明。\n` +
`12) 对外文案禁止直写具体年龄/性别标签(如“25-35岁女性”),改为场景化表达(如“久坐办公场景用户”)。\n` +
(hasModelImage
? `13) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
: `13) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
(hasBundleImages
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
(generationMode === "agent_layout"
? `15) 当前为 AGENT 详情模式:除 screens 外,还必须返回 dslV2Screens。每个 dslV2Screens[i] 都是可直接渲染的 detail-ui-dsl/v2 文档,必须包含 version、screenIndex、layoutVariant、region、nodes。nodes 仅允许这些 kind:panel、stack、row、badge、heading、body、feature-card、spec-table、compare、proof、cta。\n`
+ `15.1) dslV2Screens 必须执行“详情 UI 设计技能”:region 覆盖面积控制在画布45%以内;避开商品主体/Logo/人脸/动作;不得卡片套卡片;每屏最多1个主面板+1个辅助网格/证据条;所有 id 语义化且文字短句化。\n`
: "") +
`输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}],"dslV2Screens":[{"version":"detail-ui-dsl/v2","screenIndex":1,"layoutVariant":"hero-left-panel","region":{"x":0.06,"y":0.05,"width":0.88,"height":0.46},"nodes":[{"kind":"panel","id":"hero-panel","variant":"glass","tone":"light","direction":"column","gap":18,"children":[{"kind":"badge","id":"screen-badge","icon":"sparkle","text":"核心升级","tone":"primary"},{"kind":"heading","id":"hero-title","text":"护腰椅,久坐不腰酸","level":1,"emphasis":"hero"},{"kind":"body","id":"hero-subtitle","text":"在家里加班,也能护腰/舒适","emphasis":"strong"}]}]}]}`,
temperature: 0.25,
...(localDetailAgentResolved ? { temperature: 1 } : {}),
maxTokens: 1800,
...plannerKimiOptions,
});
let text = "";
try {
const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => {
return await generateTextWithTimeout(
buildPlannerRequest(visionInputs),
plannerKimiOptions.headers ? 120_000 : DETAIL_PLAN_MAIN_TIMEOUT_MS
);
};
try {
const result = await runPlanner(imageInputs);
text = String(result?.text || "");
} catch (firstError) {
const firstMessage = String((firstError as Error)?.message || "");
const shouldRetryWithoutImages =
imageInputs.length > 0 &&
(isReferenceImageDownloadError(firstError) ||
isTransientNetworkError(firstError) ||
/operationtimeouterror|timed out|timeout/i.test(firstMessage));
if (!shouldRetryWithoutImages) {
throw firstError;
}
logger.warn("planner_retry_without_reference_images", firstError, {
imageInputCount: imageInputs.length,
});
const retryResult = await runPlanner([]);
text = String(retryResult?.text || "");
}
} catch (error) {
const message = String((error as Error)?.message || "");
if (
isAbortError(error) ||
/invalid scheme/i.test(message) ||
isReferenceImageDownloadError(error) ||
isTransientNetworkError(error) ||
/operationtimeouterror|timed out|timeout/i.test(message)
) {
logger.error("primary_planning_fallback_applied", error);
const fallbackReason = /invalid scheme/i.test(message)
? "规划模型图片输入协议不兼容,已回退到稳定分镜模板。"
: isReferenceImageDownloadError(error)
? "参考图链接不可访问(404/下载失败),已自动忽略参考图并回退稳定分镜模板。"
: isTransientNetworkError(error)
? "参考资源网络波动(DNS/连接异常),已自动回退到稳定分镜模板。"
: "规划模型调用超时,已自动回退到稳定分镜模板。";
return NextResponse.json({
success: true,
data: {
...fallback,
summary: `${fallback.summary}\n${fallbackReason}`,
qualityChecks: {
passed: false,
issues: [fallbackReason],
},
model: {
id: selectedModelRef.id,
displayName: selectedModelRef.displayName,
providerType: selectedProviderRef.type,
},
preflight: {
passed: false,
designDirectorScore: 0,
marketingDirectorScore: 0,
issues: [fallbackReason],
rewriteApplied: false,
},
},
});
}
throw error;
}
const parsed = extractJsonBlock<{
summary?: unknown;
globalStyle?: unknown;
screens?: unknown;
dslV2Screens?: unknown;
}>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
const normalizedScreensWithoutBundles = rawScreens.map((item) =>
hasBundleImages
? item
: {
...item,
bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const normalizedScreens = normalizeDetailModelUsage({
screens: normalizedScreensWithoutBundles,
hasModelImage,
language,
});
const coreFocusedScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
}),
hasModelImage,
language,
});
const qualityChecksInitial = evaluatePlanQuality({
screens: coreFocusedScreens,
hasModelImage,
hasBundleImages,
coreFocus,
});
const preflight = await runDualDirectorPlanPreflight({
model,
isLocalMoonshot: Boolean(localDetailAgentResolved),
kimiOptions: preflightKimiOptions,
timeoutMs: preflightKimiOptions.headers ? 30_000 : DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS,
language,
brief,
coreFocus,
designSkillPrompt,
globalStyle: globalStyleInitial,
screens: coreFocusedScreens,
hasModelImage,
hasBundleImages,
});
const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
hasBundleImages
? item
: {
...item,
bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const preflightScreensRaw = normalizeDetailModelUsage({
screens: preflightScreensWithoutBundles,
hasModelImage,
language,
});
const preflightScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
}),
hasModelImage,
language,
});
const qualityChecksAfterPreflight = evaluatePlanQuality({
screens: preflightScreens,
hasModelImage,
hasBundleImages,
coreFocus,
});
const preflightPassed =
(preflight.passed || preflight.rewriteApplied) &&
preflight.designDirectorScore >= 85 &&
preflight.marketingDirectorScore >= 85;
const shouldUsePreflightPlan = qualityChecksAfterPreflight.passed && preflightPassed;
const shouldFallback = !shouldUsePreflightPlan;
const screensRaw = shouldFallback ? fallback.screens : preflightScreens;
const screens = sanitizeScreensForRender({
screens: screensRaw,
coreFocus,
language,
});
const globalStyle = shouldFallback ? fallback.globalStyle : preflight.candidateGlobalStyle;
const qualityIssuesMerged = [
...new Set([
...qualityChecksInitial.issues,
...qualityChecksAfterPreflight.issues,
...preflight.issues.map((issue) => `双总监预审:${issue}`),
]),
];
const uiProtocol = buildDetailUiProtocol({
generationMode,
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
screens,
});
const uiValidation = validateDetailUiProtocol(uiProtocol);
const qualityChecks = {
passed: !shouldFallback,
issues: shouldFallback
? qualityIssuesMerged.length > 0
? qualityIssuesMerged
: ["双总监预审未通过,已回退到稳定分镜模板。"]
: qualityIssuesMerged,
};
const mergedIssues = [
...qualityChecks.issues,
...uiValidation.issues.map((item) => item.message),
];
const prompts = screens.map((item) => item.visualPrompt);
const summaryRaw = String(parsed?.summary || "").trim();
const summary = desensitizeAudienceCopy(
!shouldFallback
? summaryRaw || fallback.summary
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
language
);
const uiDslV2Screens =
!shouldFallback && !preflight.rewriteApplied && Array.isArray(parsed?.dslV2Screens)
? parsed.dslV2Screens
.map((item) => normalizeDetailUiDslV2Document(item))
.filter(Boolean)
.map((item) => ({
...item!,
surfaceId: `detail-dsl-${generationMode}-${item!.screenIndex}`,
generationMode,
screenSize: {
width: 790,
height: 1300,
},
designStyle: item!.designStyle || globalStyle,
}))
: undefined;
const designSkillSummary = summarizeDetailUiDesignSkill(designSkill);
const response: DetailPlanResponse = {
summary,
prompts,
generationMode,
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow: fallback.skillFlow,
designSkill: designSkillSummary,
qualityChecks: qualityChecks.passed
? {
passed: qualityChecks.passed && uiValidation.passed,
issues: mergedIssues,
}
: {
passed: false,
issues: [...mergedIssues],
},
uiValidation: {
passed: uiValidation.passed,
issues: uiValidation.issues.map((item) => item.message),
},
preflight: {
passed: !shouldFallback,
designDirectorScore: preflight.designDirectorScore,
marketingDirectorScore: preflight.marketingDirectorScore,
issues: preflight.issues,
rewriteApplied: preflight.rewriteApplied,
},
screens,
uiProtocol,
uiSurface:
generationMode === "agent_layout"
? buildDetailUiSurfaceSnapshot({
generationMode,
screenSize: {
width: 790,
height: 1300,
},
summary,
globalStyle,
screens,
uiProtocol,
uiValidation: {
passed: uiValidation.passed,
issues: uiValidation.issues.map((item) => item.message),
},
})
: undefined,
uiDslV2Screens,
brandStyleBoard: buildDetailBrandStyleBoard({
language,
prompt,
brief,
globalStyle,
designSkill: designSkillSummary,
}),
model: {
id: selectedModelRef.id,
displayName: selectedModelRef.displayName,
providerType: selectedProviderRef.type,
},
};
return NextResponse.json({ success: true, data: response });
} catch (error) {
logger.error("detail_plan_failed", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "详情规划失败" },
{ status: 500 },
);
}
}