From b6a12ced9f9921b574d549fa0a075c2b7c7d17b5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 24 Feb 2026 12:15:25 +0800 Subject: [PATCH] fix: cap detail-plan latency and add LLM timeouts --- app/api/canvas/detail-plan/route.ts | 32 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 05d7e6668..e7ebf0bc5 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -14,6 +14,11 @@ import { inlineVisionImages } from "@/lib/ai/vision-inline"; 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 = 55000; +const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 35000; function isAbortError(error: unknown): boolean { const target = error as { @@ -56,6 +61,19 @@ function isVolcanoResponsesPlanner(config: { return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl); } +async function generateTextWithTimeout(params: any, timeoutMs: number) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await generateText({ + ...params, + abortSignal: controller.signal, + } as any); + } finally { + clearTimeout(timer); + } +} + type DetailPlanRequest = { prompt?: string; count?: number; @@ -1465,7 +1483,7 @@ async function runDualDirectorPlanPreflight(params: { briefText, ]; - const { text } = await generateText({ + const { text } = await generateTextWithTimeout({ model: params.model, messages: [ { @@ -1479,7 +1497,7 @@ async function runDualDirectorPlanPreflight(params: { : "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。", temperature: 0.1, maxTokens: 2000, - } as any); + }, DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS); const parsed = extractJsonBlock<{ pass?: unknown; @@ -1661,10 +1679,10 @@ export async function POST(request: Request) { .map((url) => ({ type: "image" as const, image: url })); } else { const inlined = await inlineVisionImages(referenceImages, { - maxImages: 6, + maxImages: DETAIL_PLAN_INLINE_MAX_IMAGES, maxSide: 900, - timeoutMs: 12000, - retries: 2, + timeoutMs: DETAIL_PLAN_INLINE_TIMEOUT_MS, + retries: DETAIL_PLAN_INLINE_RETRIES, loggerPrefix: "[Canvas Detail Plan]", }); imageInputs = inlined.inlineDataUrls.map((dataUrl) => ({ @@ -1676,7 +1694,7 @@ export async function POST(request: Request) { let text = ""; try { - const result = await generateText({ + const result = await generateTextWithTimeout({ model, messages: [ { @@ -1727,7 +1745,7 @@ export async function POST(request: Request) { `{"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, - } as any); + }, DETAIL_PLAN_MAIN_TIMEOUT_MS); text = String(result?.text || ""); } catch (error) { const message = String((error as Error)?.message || "");