From 2913bf77be763cecd424be41410e49f8e7ca26e4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 12:15:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=83=AD=E4=BF=AE=E5=A4=8D=E7=94=9F=E5=9B=BE?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=A4=B1=E6=95=88=E5=AF=BC=E8=87=B4=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(chat)/api/chat/route.ts | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index f620d8f33..9e8c990fb 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -481,6 +481,96 @@ function mimeTypeToExtension(mimeType: string) { return "jpg"; } +function getExtensionFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const ext = pathname.split(".").pop()?.toLowerCase(); + if (!ext) return "jpg"; + if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) return ext; + } catch { + // ignore + } + return "jpg"; +} + +async function uploadRemoteImageToStorage(imageUrl: string): Promise { + const response = await fetch(imageUrl, { + method: "GET", + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error(`Remote image fetch failed: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + if (contentType && !contentType.startsWith("image/")) { + throw new Error(`Remote url is not an image: ${contentType}`); + } + + const arrayBuffer = await response.arrayBuffer(); + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error("Remote image content is empty"); + } + + const ext = contentType ? mimeTypeToExtension(contentType) : getExtensionFromUrl(imageUrl); + const fileName = `remote-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; + const uploaded = await uploadFile(fileName, arrayBuffer); + return uploaded.url; +} + +async function isRemoteImageAccessible(imageUrl: string): Promise { + try { + const headResponse = await fetch(imageUrl, { + method: "HEAD", + signal: AbortSignal.timeout(10_000), + }); + + if (headResponse.ok) { + return true; + } + + if (headResponse.status !== 405 && headResponse.status !== 403) { + return false; + } + + const probeResponse = await fetch(imageUrl, { + method: "GET", + headers: { + Range: "bytes=0-0", + }, + signal: AbortSignal.timeout(10_000), + }); + return probeResponse.ok; + } catch { + return false; + } +} + +async function ensureHostedImageUrl(imageUrl: string): Promise { + const normalizedUrl = imageUrl.trim(); + if (!normalizedUrl) { + throw new Error("Image URL is empty"); + } + + if (normalizedUrl.startsWith("data:image")) { + return uploadDataUrlImage(normalizedUrl); + } + + if (!/^https?:\/\//i.test(normalizedUrl)) { + throw new Error(`Unsupported image URL: ${normalizedUrl}`); + } + + if (/\/uploads\//i.test(normalizedUrl)) { + const accessible = await isRemoteImageAccessible(normalizedUrl); + if (accessible) { + return normalizedUrl; + } + } + + return uploadRemoteImageToStorage(normalizedUrl); +} + async function uploadDataUrlImage(dataUrl: string): Promise { const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i); if (!match) { @@ -508,7 +598,7 @@ async function normalizeAssistantImageMarkdown( text: string, imageSize = "2K" ): Promise { - if (!text || !text.includes("data:image")) return text; + if (!text) return text; const markdownDataUrlRegex = /!\[([^\]]*)\]\s*\(\s*(data:image\/[a-z0-9+.-]+;base64,[^)]+)\s*\)/gim; const markdownMatches = [...text.matchAll(markdownDataUrlRegex)]; @@ -558,11 +648,53 @@ async function normalizeAssistantImageMarkdown( } } + const markdownHttpImageRegex = /!\[([^\]]*)\]\s*\(\s*(https?:\/\/[^)\s]+)\s*\)/gim; + const markdownHttpMatches = [...normalizedText.matchAll(markdownHttpImageRegex)]; + + if (markdownHttpMatches.length > 0) { + let rebuilt = ""; + let cursor = 0; + + for (const match of markdownHttpMatches) { + const matchText = match[0]; + const altText = match[1] || ""; + const thumbnailUrl = (match[2] || "").trim(); + const start = match.index ?? -1; + if (start < 0) continue; + + rebuilt += normalizedText.slice(cursor, start); + + try { + const { originalUrl, imageSize: parsedImageSize } = parseImageAltMetadata( + altText, + thumbnailUrl + ); + const hostedThumbnailUrl = await ensureHostedImageUrl(thumbnailUrl); + const hostedOriginalUrl = + originalUrl && originalUrl !== thumbnailUrl + ? await ensureHostedImageUrl(originalUrl) + : hostedThumbnailUrl; + + uploadedUrls.push(hostedThumbnailUrl); + uploadedUrls.push(hostedOriginalUrl); + rebuilt += `![thumb|||${hostedOriginalUrl}|||${parsedImageSize || imageSize}](${hostedThumbnailUrl})`; + } catch (error) { + console.error("[Chat API] Failed to normalize remote image URL:", error); + rebuilt += ""; + } + + cursor = start + matchText.length; + } + + rebuilt += normalizedText.slice(cursor); + normalizedText = rebuilt; + } + const hasMarkdownImage = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i.test(normalizedText); if (!hasMarkdownImage && uploadedUrls.length > 0) { const uniqueUrls = [...new Set(uploadedUrls)]; console.log( - `[Chat API] Converted ${uniqueUrls.length} data URL image(s) to hosted link(s)` + `[Chat API] Converted ${uniqueUrls.length} image link(s) to hosted link(s)` ); return uniqueUrls .map((url) => `![thumb|||${url}|||${imageSize}](${url})`)