fix: harden detail plan and canvas image generation fallbacks
This commit is contained in:
parent
1dc893ee5c
commit
3534028ff0
@ -18,7 +18,7 @@ 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 = 20000;
|
||||
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
|
||||
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
@ -53,6 +53,20 @@ function isReferenceImageDownloadError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@ -1808,89 +1822,129 @@ export async function POST(request: Request) {
|
||||
|
||||
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: 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,
|
||||
}));
|
||||
try {
|
||||
if (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)) {
|
||||
console.warn(
|
||||
"[Canvas Detail Plan] Vision input preprocessing failed, retry without reference images:",
|
||||
error
|
||||
);
|
||||
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`) +
|
||||
`输出格式:\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,
|
||||
});
|
||||
|
||||
let text = "";
|
||||
try {
|
||||
const result = await generateTextWithTimeout({
|
||||
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` +
|
||||
(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`) +
|
||||
`输出格式:\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,
|
||||
}, DETAIL_PLAN_MAIN_TIMEOUT_MS);
|
||||
text = String(result?.text || "");
|
||||
const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => {
|
||||
return await generateTextWithTimeout(
|
||||
buildPlannerRequest(visionInputs),
|
||||
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;
|
||||
}
|
||||
console.warn(
|
||||
"[Canvas Detail Plan] Primary planning with reference images failed, retrying without reference images:",
|
||||
firstError
|
||||
);
|
||||
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)
|
||||
) {
|
||||
console.error("[Canvas Detail Plan] Primary planning aborted, fallback applied:", error);
|
||||
@ -1898,6 +1952,8 @@ export async function POST(request: Request) {
|
||||
? "规划模型图片输入协议不兼容,已回退到稳定分镜模板。"
|
||||
: isReferenceImageDownloadError(error)
|
||||
? "参考图链接不可访问(404/下载失败),已自动忽略参考图并回退稳定分镜模板。"
|
||||
: isTransientNetworkError(error)
|
||||
? "参考资源网络波动(DNS/连接异常),已自动回退到稳定分镜模板。"
|
||||
: "规划模型调用超时,已自动回退到稳定分镜模板。";
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@ -28,6 +28,13 @@ export interface CanvasGenerateImageResult {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
"gemini",
|
||||
"alibaba-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
]);
|
||||
|
||||
function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const matches = [...markdown.matchAll(imageRegex)];
|
||||
@ -98,11 +105,41 @@ export async function generateCanvasImage(
|
||||
selectedModel = imageModels[0];
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: selectedModel.model.providerId });
|
||||
let provider = await getAiProviderById({ id: selectedModel.model.providerId });
|
||||
if (!provider) {
|
||||
throw new Error("模型提供商配置错误");
|
||||
}
|
||||
|
||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
|
||||
let fallbackModel = selectedModel;
|
||||
let fallbackProvider = provider;
|
||||
for (const candidate of imageModels) {
|
||||
const candidateProvider = await getAiProviderById({
|
||||
id: candidate.model.providerId,
|
||||
});
|
||||
if (!candidateProvider) continue;
|
||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(candidateProvider.type)) continue;
|
||||
fallbackModel = candidate;
|
||||
fallbackProvider = candidateProvider;
|
||||
break;
|
||||
}
|
||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
|
||||
throw new Error(
|
||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/RUXA/FAL 图片模型。"
|
||||
);
|
||||
}
|
||||
if (fallbackModel.model.id !== selectedModel.model.id) {
|
||||
console.warn("[Canvas Generate Image] Fallback to supported image model:", {
|
||||
requestedModel: selectedModel.model.modelId || selectedModel.model.id,
|
||||
fallbackModel: fallbackModel.model.modelId || fallbackModel.model.id,
|
||||
requestedProviderType: provider.type,
|
||||
fallbackProviderType: fallbackProvider.type,
|
||||
});
|
||||
}
|
||||
selectedModel = fallbackModel;
|
||||
provider = fallbackProvider;
|
||||
}
|
||||
|
||||
const normalizedReferenceImages = Array.from(
|
||||
new Set(
|
||||
[
|
||||
@ -183,7 +220,9 @@ export async function generateCanvasImage(
|
||||
const result = await modelInstance.doGenerate({ messages, imageParams });
|
||||
imageMarkdown = result.text || "";
|
||||
} else {
|
||||
throw new Error("不支持的图片生成模型类型");
|
||||
throw new Error(
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/RUXA/FAL 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user