From 3534028ff0cef71d554b34dc7162253f2063596a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 24 Feb 2026 19:50:45 +0800 Subject: [PATCH] fix: harden detail plan and canvas image generation fallbacks --- app/api/canvas/detail-plan/route.ts | 202 ++++++++++++++++++---------- lib/canvas/generate-image.ts | 43 +++++- 2 files changed, 170 insertions(+), 75 deletions(-) diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 9bd096b08..70260dd0e 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -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, diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 45f03b92f..dbb6b0344 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -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);