fix: harden detail plan and canvas image generation fallbacks
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-24 19:50:45 +08:00
parent 1dc893ee5c
commit 3534028ff0
2 changed files with 170 additions and 75 deletions

View File

@ -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,

View File

@ -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);