fix: reject empty image results across chat providers
This commit is contained in:
parent
ffed16d802
commit
95d4bccceb
@ -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 || ""),
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -355,7 +355,7 @@ function formatImageResponse(
|
||||
imageSize: string = "2K"
|
||||
): string {
|
||||
if (images.length === 0) {
|
||||
return "生成图片失败,未找到图片数据";
|
||||
throw new Error("生成图片失败,未找到图片数据");
|
||||
}
|
||||
|
||||
return images.map((img) => ``).join("\n\n");
|
||||
|
||||
@ -575,7 +575,7 @@ function formatImageResponse(
|
||||
imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE
|
||||
): string {
|
||||
if (images.length === 0) {
|
||||
return "生成图片失败,未找到图片数据";
|
||||
throw new Error("生成图片失败,未找到图片数据");
|
||||
}
|
||||
|
||||
// 🆕 统一使用 ||| 分隔符格式(与 Gemini 保持一致)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user