From 153efc8f9685cb1b2454537647f61a51bbd4bab2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Feb 2026 03:19:18 +0800 Subject: [PATCH] fix: fallback ai detail plan on planner abort timeout --- app/api/canvas/detail-plan/route.ts | 129 +++++++++++++++++++--------- 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index fd66d09d5..7adb468a9 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -9,7 +9,26 @@ import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; export const runtime = "nodejs"; -export const maxDuration = 60; +export const maxDuration = 180; + +function isAbortError(error: unknown): boolean { + const target = error as { + name?: unknown; + message?: unknown; + code?: unknown; + ABORT_ERR?: unknown; + }; + const name = String(target?.name || ""); + const message = String(target?.message || ""); + const code = Number(target?.code); + const abortCode = Number(target?.ABORT_ERR); + return ( + name === "AbortError" || + code === 20 || + abortCode === 20 || + /aborted|abort/i.test(message) + ); +} function supportsVisionPlanning(model: { supportsVision?: boolean | null; @@ -1054,45 +1073,77 @@ export async function POST(request: Request) { ? referenceImages.map((url) => ({ type: "image" as const, image: url })) : []; - const { text } = await generateText({ - model, - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: userInput, + let text = ""; + try { + const result = await generateText({ + model, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: userInput, + }, + ...imageInputs, + ], + }, + ], + system: + `你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` + + `硬性要求:\n` + + `1) 输出严格 JSON,不要其他文本。\n` + + `2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` + + `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + + `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + + `6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` + + `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + + `8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` + + `9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` + + `10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` + + `11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` + + (hasModelImage + ? `12) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` + : `12) 未提供模特图:所有分镜均设置 useModel=false。\n`) + + (hasBundleImages + ? `13) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` + : `13) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + + `输出格式:\n` + + `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, + temperature: 0.25, + maxTokens: 1800, + } as any); + text = String(result?.text || ""); + } catch (error) { + if (isAbortError(error)) { + console.error("[Canvas Detail Plan] Primary planning aborted, fallback applied:", error); + return NextResponse.json({ + success: true, + data: { + ...fallback, + summary: `${fallback.summary}\n规划模型超时,已回退到稳定分镜模板。`, + qualityChecks: { + passed: false, + issues: ["规划模型调用超时,已自动回退到稳定分镜模板。"], }, - ...imageInputs, - ], - }, - ], - system: - `你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` + - `硬性要求:\n` + - `1) 输出严格 JSON,不要其他文本。\n` + - `2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` + - `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + - `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + - `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + - `6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` + - `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + - `8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` + - `9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` + - `10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` + - `11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` + - (hasModelImage - ? `12) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` - : `12) 未提供模特图:所有分镜均设置 useModel=false。\n`) + - (hasBundleImages - ? `13) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` - : `13) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + - `输出格式:\n` + - `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, - temperature: 0.25, - maxTokens: 1800, - } as any); + model: { + id: plannerConfig.model.id, + displayName: plannerConfig.model.displayName, + providerType: plannerConfig.provider.type, + }, + preflight: { + passed: false, + designDirectorScore: 0, + marketingDirectorScore: 0, + issues: ["规划阶段超时,已启用稳定模板回退。"], + rewriteApplied: false, + }, + }, + }); + } + throw error; + } const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || ""); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);