fix(detail): stabilize volcano multimodal plan/review image handling
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-22 18:48:00 +08:00
parent ac76f31878
commit 5301c3bd68
3 changed files with 126 additions and 42 deletions

View File

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

View File

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

View File

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