fix: reject empty image results across chat providers

This commit is contained in:
134 2026-06-15 16:52:23 +08:00
parent ffed16d802
commit 95d4bccceb
7 changed files with 187 additions and 49 deletions

View File

@ -241,7 +241,7 @@ export async function generateImageWithFailoverCandidate(params: {
}
return {
text: candidateResult.text || "图片生成失败",
text: candidateResult.text || "",
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
};

View File

@ -341,6 +341,30 @@ import {
const chatApiLogger = createLogger("chat.route");
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
const normalizedMarkdown = String(markdown || "").trim();
const imageUrls = extractImageUrlsFromText(normalizedMarkdown);
if (imageUrls.length > 0) {
return normalizedMarkdown;
}
const reason = INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(normalizedMarkdown)
? normalizedMarkdown
: "模型响应未包含可用图片";
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
}
function getPublicImageGenerationErrorText(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
if (!message || INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message)) {
return "图片生成失败,请稍后重试";
}
return message;
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1,
@ -6333,21 +6357,6 @@ export async function POST(request: Request) {
return injectHistoryImagesToLastUserMessage(messagesForGemini, smartAutoHistoryImages);
};
const assertGeneratedImageMarkdown = (markdown: string, modelLabel: string) => {
const normalizedMarkdown = String(markdown || "").trim();
const imageUrls = extractImageUrlsFromText(normalizedMarkdown);
if (imageUrls.length > 0) {
return normalizedMarkdown;
}
const failureHintPattern =
/生成图片失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用/i;
const reason = failureHintPattern.test(normalizedMarkdown)
? normalizedMarkdown
: "模型响应未包含可用图片";
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
};
// 🆕 尝试生成图片的函数(支持备用模型切换)
const tryGenerateImage = async (modelIdToUse: string, isRetry = false): Promise<string> => {
chatApiLogger.info(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`);
@ -6368,7 +6377,7 @@ export async function POST(request: Request) {
overrideModelId: isRetry ? modelIdToUse : undefined, // 🆕 使用备用模型 ID
});
return assertGeneratedImageMarkdown(
return assertGeneratedImageMarkdownHasImage(
result.text || "",
isRetry ? `备用模型 ${modelIdToUse}` : `主模型 ${modelIdToUse}`,
);
@ -6447,7 +6456,7 @@ export async function POST(request: Request) {
imageSize: defaultImageQualityParams.imageSize,
},
});
imageMarkdown = assertGeneratedImageMarkdown(
imageMarkdown = assertGeneratedImageMarkdownHasImage(
fallbackResult.text,
`备用模型 ${fallbackResult.displayName}`,
);
@ -6515,7 +6524,7 @@ export async function POST(request: Request) {
imageCount: defaultImageQualityParams.imageCount,
},
});
imageMarkdown = assertGeneratedImageMarkdown(
imageMarkdown = assertGeneratedImageMarkdownHasImage(
fallbackResult.text,
`跨服务备用模型 ${fallbackResult.displayName}`,
);
@ -6710,7 +6719,10 @@ export async function POST(request: Request) {
imageParams: effectiveImageGenerationParams,
});
const imageMarkdown = volcanoResult.text || "图片生成失败";
const imageMarkdown = assertGeneratedImageMarkdownHasImage(
volcanoResult.text || "",
volcanoModel.model.displayName || volcanoModel.model.modelId,
);
const imageMarkdownWithModel = prependImageModelBadge(
imageMarkdown,
volcanoModel.model.displayName || volcanoModel.model.modelId,
@ -7116,7 +7128,10 @@ export async function POST(request: Request) {
throw new Error(result.text || "图片生成失败");
}
return result.text || "图片生成失败";
return assertGeneratedImageMarkdownHasImage(
result.text || "",
isRetry ? `备用模型 ${modelIdToUse}` : `主模型 ${modelIdToUse}`,
);
};
let imageMarkdown = "";
@ -7166,7 +7181,10 @@ export async function POST(request: Request) {
imageCount: defaultImageQualityParams.imageCount,
},
});
imageMarkdown = fallbackResult.text;
imageMarkdown = assertGeneratedImageMarkdownHasImage(
fallbackResult.text,
`备用模型 ${fallbackResult.displayName}`,
);
usedModelId = fallbackResult.usedModelId;
fallbackSuccess = true;
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
@ -7262,7 +7280,10 @@ export async function POST(request: Request) {
throw new Error(candidateResult.text || "图片生成失败");
}
imageMarkdown = candidateResult.text || "图片生成失败";
imageMarkdown = assertGeneratedImageMarkdownHasImage(
candidateResult.text || "",
`跨服务备用模型 ${candidate.model.displayName}`,
);
usedModelId = candidate.model.modelId;
fallbackSuccess = true;
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -7392,7 +7413,7 @@ export async function POST(request: Request) {
// 返回错误消息
const errorMessageId = generateUUID();
const errorText = error instanceof Error ? error.message : "图片生成失败,请稍后重试";
const errorText = getPublicImageGenerationErrorText(error);
writeAssistantTextChunk(
writer,
errorText,
@ -7537,7 +7558,10 @@ export async function POST(request: Request) {
if (result?.finishReason === "error") {
throw new Error(result.text || "图片生成失败");
}
return result.text || "图片生成失败";
return assertGeneratedImageMarkdownHasImage(
result.text || "",
isRetry ? `备用模型 ${modelIdToUse}` : `主模型 ${modelIdToUse}`,
);
};
let imageMarkdown = "";
@ -7584,7 +7608,10 @@ export async function POST(request: Request) {
imageCount: defaultImageQualityParams.imageCount,
},
});
imageMarkdown = fallbackResult.text;
imageMarkdown = assertGeneratedImageMarkdownHasImage(
fallbackResult.text,
`备用模型 ${fallbackResult.displayName}`,
);
usedModelId = fallbackResult.usedModelId;
fallbackSuccess = true;
chatApiLogger.info(`[Chat API] ✅ Volcano fallback model ${fallbackResult.displayName} succeeded!`);
@ -7679,7 +7706,10 @@ export async function POST(request: Request) {
throw new Error(candidateResult.text || "图片生成失败");
}
imageMarkdown = candidateResult.text || "图片生成失败";
imageMarkdown = assertGeneratedImageMarkdownHasImage(
candidateResult.text || "",
`跨服务备用模型 ${candidate.model.displayName}`,
);
usedModelId = candidate.model.modelId;
fallbackSuccess = true;
chatApiLogger.info(`[Chat API] ✅ Volcano cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -8387,11 +8417,15 @@ export async function POST(request: Request) {
abortSignal: AbortSignal.timeout(imageRequestTimeoutMs),
});
const rawText = generated.text?.trim() || "图片生成失败,请稍后重试";
return normalizeAssistantImageMarkdown(
const rawText = generated.text?.trim() || "";
const normalizedMarkdown = await normalizeAssistantImageMarkdown(
rawText,
effectiveImageParams?.imageSize || "2K"
);
return assertGeneratedImageMarkdownHasImage(
normalizedMarkdown,
currentModelConfig?.displayName || actualModelId,
);
};
let normalizedText = "";
@ -8440,7 +8474,10 @@ export async function POST(request: Request) {
imageCount: defaultImageQualityParams.imageCount,
},
});
normalizedText = fallbackResult.text;
normalizedText = assertGeneratedImageMarkdownHasImage(
fallbackResult.text,
`备用模型 ${fallbackResult.displayName}`,
);
usedModelId = fallbackResult.usedModelId;
}
fallbackSuccess = true;
@ -8540,7 +8577,10 @@ export async function POST(request: Request) {
throw new Error(candidateResult.text || "图片生成失败");
}
normalizedText = candidateResult.text || "图片生成失败";
normalizedText = assertGeneratedImageMarkdownHasImage(
candidateResult.text || "",
`跨服务备用模型 ${candidate.model.displayName}`,
);
usedModelId = candidate.model.modelId;
fallbackSuccess = true;
chatApiLogger.info(`[Chat API] ✅ OpenAI-compatible cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -8664,8 +8704,7 @@ export async function POST(request: Request) {
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
const errorMessageId = generateUUID();
const errorText =
error instanceof Error ? error.message : "图片生成失败,请稍后重试";
const errorText = getPublicImageGenerationErrorText(error);
dataStream.write({
type: "data-appendMessage",

View File

@ -463,6 +463,31 @@ function buildCanvasMediaMarkdown(params: {
].join("\n\n");
}
function isInternalCanvasImageGenerationFailureText(parts: Array<any>) {
const text = parts
.filter((part) => part?.type === "text" && typeof part?.text === "string")
.map((part) => part.text)
.join("\n")
.trim();
if (!text) return false;
const normalized = stripCanvasModelRoutingLines(stripMediaMarkup(text)).trim();
if (!normalized) return false;
if (extractAssistantImageUrls(normalized).length > 0 || extractVideoUrls(normalized).length > 0) {
return false;
}
return (
/^(?:生成图片失败|图片生成失败)[,,::\s]*(?:未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片)?$/i.test(
normalized
) ||
(/未返回可用图片/i.test(normalized) &&
/未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|模型响应未包含可用图片/i.test(
normalized
))
);
}
function findCanvasImageAnchorNode(
nodes: CanvasNode[],
params: {
@ -8498,6 +8523,13 @@ export function CanvasChatPanel({
cleanedParts.push({ type: "text", text: fallbackContent } as any);
}
if (
message.role === "assistant" &&
isInternalCanvasImageGenerationFailureText(cleanedParts)
) {
return acc;
}
let hasContent = false;
for (const part of cleanedParts) {
if (part?.type === "text" && part?.text && String(part.text).trim().length > 0) {

View File

@ -198,8 +198,14 @@ function isInternalImageGenerationFailureText(text: string): boolean {
const normalized = stripModelBadgeLines(filterAgentRoutingNoise(filterVideoContent(text))).trim();
if (!normalized) return false;
if (extractImageUrlsFromText(normalized).length > 0) return false;
return /^(?:生成图片失败|图片生成失败)[,,::\s]*(?:未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用)?$/i.test(
normalized
return (
/^(?:生成图片失败|图片生成失败)[,,::\s]*(?:未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片)?$/i.test(
normalized
) ||
(/未返回可用图片/i.test(normalized) &&
/未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|模型响应未包含可用图片/i.test(
normalized
))
);
}

View File

@ -365,6 +365,15 @@ function extractImageDataFromText(text: string): Array<{ url: string; isBase64:
return images;
}
function parseDataImageUrl(value: string): { mimeType: string; base64Data: string } | null {
const match = String(value || "").trim().match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/);
if (!match) return null;
return {
mimeType: match[1] || "image/png",
base64Data: match[2],
};
}
/**
* 从 API 响应中提取图片并上传 base64 到 S3
* 返回图片数据数组(包含缩略图和原图 URL)
@ -381,8 +390,8 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
queuedUrls.add(normalized);
if (normalized.startsWith("data:")) {
const imageData = extractImageDataFromText(normalized)[0];
if (imageData?.isBase64 && imageData.base64Data) {
const imageData = parseDataImageUrl(normalized);
if (imageData?.base64Data) {
const uploadPromise = uploadBase64ToS3(imageData.base64Data, imageData.mimeType)
.then((urls) => {
images.push(urls);
@ -392,6 +401,21 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
return;
}
if (!/^https?:\/\//i.test(normalized)) {
for (const imageData of extractImageDataFromText(normalized)) {
if (imageData.isBase64 && imageData.base64Data) {
const uploadPromise = uploadBase64ToS3(imageData.base64Data, imageData.mimeType)
.then((urls) => {
images.push(urls);
});
uploadPromises.push(uploadPromise);
} else {
queueRemoteImageUrl(imageData.url, source);
}
}
return;
}
logger.debug(
`[Gemini Image] Found ${source} image URL: ${normalized.substring(0, 100)}...`
);
@ -410,16 +434,48 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
const queueImageValue = (value: unknown, source: string) => {
if (!value) return;
if (Array.isArray(value)) {
value.forEach((entry) => queueImageValue(entry, source));
return;
}
if (typeof value === "string") {
queueRemoteImageUrl(value, source);
return;
}
if (typeof value === "object") {
const item = value as Record<string, any>;
queueRemoteImageUrl(
item.url || item.imageUrl || item.image_url || item.original || item.thumbnail,
source
);
[
item.url,
item.imageUrl,
item.image_url,
item.image,
item.original,
item.originalUrl,
item.original_url,
item.thumbnail,
item.outputUrl,
item.output_url,
item.resultUrl,
item.result_url,
item.fileUrl,
item.file_url,
].forEach((candidate) => queueRemoteImageUrl(candidate, source));
const base64Data =
item.b64_json ||
item.base64 ||
item.base64Data ||
item.image_base64 ||
item.imageBase64;
if (typeof base64Data === "string" && base64Data.trim()) {
const mimeType = item.mime_type || item.mimeType || "image/png";
queueRemoteImageUrl(
base64Data.startsWith("data:image/")
? base64Data
: `data:${mimeType};base64,${base64Data.trim()}`,
source
);
}
}
};
@ -434,11 +490,17 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
{ value: response?.images, source: "images" },
{ value: response?.data?.images, source: "data.images" },
{ value: response?.data?.results, source: "data.results" },
{ value: response?.imageUrls, source: "imageUrls" },
{ value: response?.image_urls, source: "image_urls" },
{ value: response?.data?.imageUrls, source: "data.imageUrls" },
{ value: response?.data?.image_urls, source: "data.image_urls" },
{ value: response?.output, source: "output" },
{ value: response?.outputs, source: "outputs" },
{ value: response?.data?.output, source: "data.output" },
{ value: response?.data?.outputs, source: "data.outputs" },
];
for (const item of directArrays) {
if (Array.isArray(item.value)) {
item.value.forEach((entry) => queueImageValue(entry, item.source));
}
queueImageValue(item.value, item.source);
}
// 1. 尝试 OpenAI Images API 格式 (data[].b64_json / data[].url)
@ -567,7 +629,7 @@ function formatImageResponse(
imageSize: string = "2K"
): string {
if (images.length === 0) {
return "生成图片失败,未找到图片数据";
throw new Error("生成图片失败,未找到图片数据");
}
// 返回 Markdown 格式的图片,在 alt 文本中编码原图 URL 和 imageSize
@ -608,8 +670,7 @@ async function sendGrsaiGptImageRequest(params: {
model: modelId,
prompt,
size,
aspectRatio: size,
shutProgress: true,
shutProgress: false,
};
if (imageUrls.length > 0) {

View File

@ -355,7 +355,7 @@ function formatImageResponse(
imageSize: string = "2K"
): string {
if (images.length === 0) {
return "生成图片失败,未找到图片数据";
throw new Error("生成图片失败,未找到图片数据");
}
return images.map((img) => `![thumb|||${img.original}|||${imageSize}](${img.thumbnail})`).join("\n\n");

View File

@ -575,7 +575,7 @@ function formatImageResponse(
imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE
): string {
if (images.length === 0) {
return "生成图片失败,未找到图片数据";
throw new Error("生成图片失败,未找到图片数据");
}
// 🆕 统一使用 ||| 分隔符格式(与 Gemini 保持一致)