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})`)