diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 89d220951..05d7e6668 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -48,6 +48,14 @@ function supportsVisionPlanning(model: { ); } +function isVolcanoResponsesPlanner(config: { + provider?: { type?: string | null; baseUrl?: string | null } | null; +}) { + if (!config?.provider || config.provider.type !== "custom") return false; + const baseUrl = String(config.provider.baseUrl || "").toLowerCase(); + return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl); +} + type DetailPlanRequest = { prompt?: string; count?: number; @@ -1645,18 +1653,26 @@ export async function POST(request: Request) { .filter(Boolean) .join("\n"); - const imageInputs = - supportsVisionPlanning(plannerConfig.model) && referenceImages.length > 0 - ? ( - await inlineVisionImages(referenceImages, { - maxImages: 6, - maxSide: 900, - timeoutMs: 12000, - retries: 2, - loggerPrefix: "[Canvas Detail Plan]", - }) - ).inlineDataUrls.map((dataUrl) => ({ type: "image" as const, image: dataUrl })) - : []; + 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: 6, + maxSide: 900, + timeoutMs: 12000, + retries: 2, + loggerPrefix: "[Canvas Detail Plan]", + }); + imageInputs = inlined.inlineDataUrls.map((dataUrl) => ({ + type: "image" as const, + image: dataUrl, + })); + } + } let text = ""; try { @@ -1714,16 +1730,20 @@ export async function POST(request: Request) { } as any); text = String(result?.text || ""); } catch (error) { - if (isAbortError(error)) { + const message = String((error as Error)?.message || ""); + if (isAbortError(error) || /invalid scheme/i.test(message)) { console.error("[Canvas Detail Plan] Primary planning aborted, fallback applied:", error); + const fallbackReason = /invalid scheme/i.test(message) + ? "规划模型图片输入协议不兼容,已回退到稳定分镜模板。" + : "规划模型调用超时,已自动回退到稳定分镜模板。"; return NextResponse.json({ success: true, data: { ...fallback, - summary: `${fallback.summary}\n规划模型超时,已回退到稳定分镜模板。`, + summary: `${fallback.summary}\n${fallbackReason}`, qualityChecks: { passed: false, - issues: ["规划模型调用超时,已自动回退到稳定分镜模板。"], + issues: [fallbackReason], }, model: { id: plannerConfig.model.id, @@ -1734,7 +1754,7 @@ export async function POST(request: Request) { passed: false, designDirectorScore: 0, marketingDirectorScore: 0, - issues: ["规划阶段超时,已启用稳定模板回退。"], + issues: [fallbackReason], rewriteApplied: false, }, }, diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts index 80c1e5ff2..45b537c85 100644 --- a/app/api/canvas/detail-review/route.ts +++ b/app/api/canvas/detail-review/route.ts @@ -13,7 +13,7 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; export const runtime = "nodejs"; -export const maxDuration = 60; +export const maxDuration = 180; type DetailReviewRequest = { outputLanguage?: string; @@ -66,6 +66,33 @@ function supportsVisionReview(model: { ); } +function isVolcanoResponsesReviewer(config: { + provider?: { type?: string | null; baseUrl?: string | null } | null; +}) { + if (!config?.provider || config.provider.type !== "custom") return false; + const baseUrl = String(config.provider.baseUrl || "").toLowerCase(); + return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl); +} + +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 normalizeLanguage(value: unknown): "zh" | "en" { const raw = String(value || "").toLowerCase(); if (raw.includes("en") || raw.includes("english")) return "en"; @@ -246,35 +273,68 @@ export async function POST(request: Request) { .filter(Boolean) .join("\n"); - const { inlineDataUrls } = await inlineVisionImages([imageUrl, ...referenceImages], { - maxImages: 6, - maxSide: 900, - timeoutMs: 12000, - retries: 2, - loggerPrefix: "[Canvas Detail Review]", - }); - const effectiveReviewImages = inlineDataUrls.length > 0 ? inlineDataUrls : [imageUrl, ...referenceImages]; + const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6); + let effectiveReviewImages = reviewImageCandidates; + if (!isVolcanoResponsesReviewer(reviewerConfig)) { + const { inlineDataUrls } = await inlineVisionImages(reviewImageCandidates, { + maxImages: 6, + maxSide: 900, + timeoutMs: 12000, + retries: 2, + loggerPrefix: "[Canvas Detail Review]", + }); + effectiveReviewImages = inlineDataUrls.length > 0 ? inlineDataUrls : reviewImageCandidates; + } const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [ { type: "text", text: promptParts }, ...effectiveReviewImages.map((image) => ({ type: "image" as const, image })), ]; - const { text } = await generateText({ - model, - messages: [ - { - role: "user", - content, - }, - ], - system: - language === "en" - ? "You are both an Art Director and a Marketing Director for high-conversion e-commerce detail pages. Evaluate with strict professional standards and output JSON only." - : "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。", - temperature: 0.1, - maxTokens: 800, - } as any); + let text = ""; + try { + const result = await generateText({ + model, + messages: [ + { + role: "user", + content, + }, + ], + system: + language === "en" + ? "You are both an Art Director and a Marketing Director for high-conversion e-commerce detail pages. Evaluate with strict professional standards and output JSON only." + : "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。", + temperature: 0.1, + maxTokens: 800, + } as any); + text = String(result?.text || ""); + } catch (error) { + const message = String((error as Error)?.message || ""); + if (isAbortError(error) || /invalid scheme/i.test(message)) { + const fallbackIssues = isAbortError(error) + ? ["质检模型超时,已跳过二次审稿并保留本屏结果。"] + : ["质检模型图片输入协议不兼容,已跳过二次审稿并保留本屏结果。"]; + return NextResponse.json({ + success: true, + data: { + pass: true, + score: 78, + designDirectorScore: 78, + marketingDirectorScore: 78, + issues: fallbackIssues, + layoutFixes: [], + revisedPrompt: fallbackPrompt, + model: { + id: reviewerConfig.model.id, + displayName: reviewerConfig.model.displayName, + providerType: reviewerConfig.provider.type, + }, + }, + }); + } + throw error; + } const parsed = extractJsonBlock<{ pass?: unknown; diff --git a/lib/ai/providers/volcano-multimodal.ts b/lib/ai/providers/volcano-multimodal.ts index acde7b20f..dcbe46e09 100644 --- a/lib/ai/providers/volcano-multimodal.ts +++ b/lib/ai/providers/volcano-multimodal.ts @@ -130,8 +130,12 @@ async function callResponsesApi(params: { imageUrls: string[]; timeout: number; }) { + const remoteImageUrls = params.imageUrls + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)); + const content = [ - ...params.imageUrls.map((url) => ({ type: "input_image", image_url: url })), + ...remoteImageUrls.map((url) => ({ type: "input_image", image_url: url })), { type: "input_text", text: params.text || "请根据图片内容进行分析。" }, ]; @@ -190,7 +194,7 @@ async function callResponsesApi(params: { export function createVolcanoMultimodal(config: VolcanoMultimodalConfig) { const endpoint = normalizeResponsesEndpoint(config.baseURL); - const timeout = config.timeout ?? 60000; + const timeout = config.timeout ?? 120000; const toV2Usage = (usage: { promptTokens?: number; completionTokens?: number }) => { const inputTokens = usage.promptTokens ?? 0; const outputTokens = usage.completionTokens ?? 0;