diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts index 97d9be07c..0f7870ba7 100644 --- a/app/(chat)/api/chat/_lib/image-failover.ts +++ b/app/(chat)/api/chat/_lib/image-failover.ts @@ -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 || ""), }; diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index de8057322..399f05d5f 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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 => { 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", diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 76223afa8..e9ffba684 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -463,6 +463,31 @@ function buildCanvasMediaMarkdown(params: { ].join("\n\n"); } +function isInternalCanvasImageGenerationFailureText(parts: Array) { + 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) { diff --git a/components/message.tsx b/components/message.tsx index 7693f0e32..4ff575742 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -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 + )) ); } diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index b4209b01b..30c32f393 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -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 { images.push(urls); @@ -392,6 +401,21 @@ async function extractAndUploadImages(response: any): Promise { + 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 { 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; - 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 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) { diff --git a/lib/ai/providers/huanke-gemini.ts b/lib/ai/providers/huanke-gemini.ts index f6fa917c1..363774219 100644 --- a/lib/ai/providers/huanke-gemini.ts +++ b/lib/ai/providers/huanke-gemini.ts @@ -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"); diff --git a/lib/ai/providers/volcano-vision.ts b/lib/ai/providers/volcano-vision.ts index 098a6cb58..d356c2017 100644 --- a/lib/ai/providers/volcano-vision.ts +++ b/lib/ai/providers/volcano-vision.ts @@ -575,7 +575,7 @@ function formatImageResponse( imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE ): string { if (images.length === 0) { - return "生成图片失败,未找到图片数据"; + throw new Error("生成图片失败,未找到图片数据"); } // 🆕 统一使用 ||| 分隔符格式(与 Gemini 保持一致)