From ef8326fd33c92afe2189d6ff48af1fd7add1d95f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 15:43:12 +0800 Subject: [PATCH] fix(image-quality): always reuse original image URLs in follow-up edits --- app/(chat)/api/chat/route.ts | 32 +------ components/canvas/CanvasChatPanel.tsx | 28 +----- components/message-actions.tsx | 14 +-- lib/image-url.ts | 125 ++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 lib/image-url.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index fb70dc431..aa1cf17e8 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -50,6 +50,7 @@ import { db } from "@/lib/db"; import type { DBMessage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema"; import { ChatSDKError } from "@/lib/errors"; +import { extractImageUrlsFromText } from "@/lib/image-url"; import { uploadFile } from "@/lib/storage/service"; import type { ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; @@ -498,36 +499,7 @@ function parseImageAltMetadata(altText: string, thumbnailUrl: string): { } function extractOriginalImageUrlsFromText(text: string): string[] { - const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g; - const imageUrls: string[] = []; - const added = new Set(); - let hasMarkdownImages = false; - let match: RegExpExecArray | null; - - while ((match = imageRegex.exec(text)) !== null) { - hasMarkdownImages = true; - const altText = match[1] || ""; - const thumbnailUrl = match[2]; - const { originalUrl } = parseImageAltMetadata(altText, thumbnailUrl); - const finalUrl = originalUrl || thumbnailUrl; - if (finalUrl && /^https?:\/\//i.test(finalUrl) && !added.has(finalUrl)) { - added.add(finalUrl); - imageUrls.push(finalUrl); - } - } - - if (!hasMarkdownImages) { - const urlRegex = /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp|bmp|svg)(?:\?[^\s"'<>]*)?/gi; - const matchedUrls = text.match(urlRegex) || []; - for (const url of matchedUrls) { - if (!added.has(url)) { - added.add(url); - imageUrls.push(url); - } - } - } - - return imageUrls; + return extractImageUrlsFromText(text); } function mimeTypeToExtension(mimeType: string) { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 4aa3bb10c..ed2bffa18 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; +import { extractImageUrlsFromText } from "@/lib/image-url"; import type { Attachment, ChatMessage } from "@/lib/types"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; @@ -101,32 +102,7 @@ function getImageDimensions(url: string): Promise<{ // 从消息中提取图片 URL(只提取原始图片,避免重复) function extractImageUrls(text: string): string[] { - const urls: string[] = []; - - // 匹配 markdown 图片格式: ![thumb|||originalUrl|||size](thumbnailUrl) - // 只提取原始图片 URL(第一个 ||| 和第二个 ||| 之间的 URL) - const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/g; - let match; - let hasMarkdownImages = false; - - while ((match = markdownRegex.exec(text)) !== null) { - if (match[1]) { - urls.push(match[1]); // 原始图片 URL - hasMarkdownImages = true; - } - } - - // 只有在没有 markdown 格式图片时,才匹配普通图片 URL - // 这样可以避免重复匹配缩略图 URL - if (!hasMarkdownImages) { - const urlRegex = /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp)/gi; - const urlMatches = text.match(urlRegex); - if (urlMatches) { - urls.push(...urlMatches.filter(url => !urls.includes(url))); - } - } - - return urls; + return extractImageUrlsFromText(text); } function extractUserTextPrompt(message: ChatMessage): string { diff --git a/components/message-actions.tsx b/components/message-actions.tsx index 1e21de40a..c50ac301d 100644 --- a/components/message-actions.tsx +++ b/components/message-actions.tsx @@ -5,6 +5,7 @@ import { useSWRConfig } from "swr"; import { useCopyToClipboard } from "usehooks-ts"; import type { Vote } from "@/lib/db/schema"; import type { ChatMessage } from "@/lib/types"; +import { extractImageUrlsFromText } from "@/lib/image-url"; import { Action, Actions } from "./elements/actions"; import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons"; @@ -49,17 +50,8 @@ export function PureMessageActions({ // 检查消息中是否包含图片 const extractImageUrl = () => { if (!textFromParts) return null; - - // 匹配 Markdown 图片格式:![thumb|originalUrl](thumbnailUrl) 或 ![](url) - const imageRegex = /!\[(?:thumb\|([^\]]+))?\]\(([^)]+)\)/; - const match = textFromParts.match(imageRegex); - - if (match) { - // 如果有原图 URL,使用原图;否则使用缩略图 - return match[1] || match[2]; - } - - return null; + const imageUrls = extractImageUrlsFromText(textFromParts); + return imageUrls[0] || null; }; const imageUrl = extractImageUrl(); diff --git a/lib/image-url.ts b/lib/image-url.ts new file mode 100644 index 000000000..9f7f4a8cb --- /dev/null +++ b/lib/image-url.ts @@ -0,0 +1,125 @@ +const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim; +const PLAIN_IMAGE_URL_REGEX = + /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp|bmp|svg)(?:\?[^\s"'<>]*)?/gi; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +export function isLikelyThumbnailUrl(url: string): boolean { + const lowerUrl = url.toLowerCase(); + if ( + lowerUrl.includes("/thumb/") || + lowerUrl.includes("thumbnail") || + lowerUrl.includes("gemini-thumb-") || + lowerUrl.includes("openai-thumb-") || + lowerUrl.includes("volcano-thumb-") || + lowerUrl.includes("alibaba-image-thumb-") || + lowerUrl.includes("ruxa-image-thumb-") + ) { + return true; + } + + return /(?:^|[-_/])thumb(?:[-_.\\/]|$)/i.test(url); +} + +export function inferOriginalImageUrl(url: string): string { + if (!isHttpUrl(url)) return url; + + if (url.includes("gemini-thumb-")) { + return url.replace("gemini-thumb-", "gemini-orig-"); + } + if (url.includes("openai-thumb-")) { + return url.replace("openai-thumb-", "openai-orig-"); + } + if (url.includes("volcano-thumb-")) { + return url.replace("volcano-thumb-", "volcano-orig-"); + } + if (url.includes("alibaba-image-thumb-")) { + return url.replace("alibaba-image-thumb-", "alibaba-image-orig-"); + } + if (url.includes("ruxa-image-thumb-")) { + return url.replace("ruxa-image-thumb-", "ruxa-image-orig-"); + } + + if (url.includes("/thumb/")) { + return url.replace("/thumb/", "/"); + } + + if (url.includes("-thumb.")) { + return url.replace("-thumb.", "."); + } + + return url; +} + +export function extractOriginalImageUrlFromAlt(altText: string, fallbackUrl: string): string { + let originalUrl = fallbackUrl; + + if (altText.includes("thumb|||")) { + const content = altText.substring(altText.indexOf("thumb|||") + 8); + const parts = content.split("|||"); + if (parts[0] && isHttpUrl(parts[0])) { + originalUrl = parts[0]; + } + } else if (altText.includes("thumb$$")) { + const content = altText.substring(altText.indexOf("thumb$$") + 7); + const parts = content.split("$$"); + if (parts[0] && isHttpUrl(parts[0])) { + originalUrl = parts[0]; + } + } else if (altText.includes("thumb|")) { + const content = altText.substring(altText.indexOf("thumb|") + 6); + const parts = content.split("|"); + if (parts[0] && isHttpUrl(parts[0])) { + originalUrl = parts[0]; + } + } + + if (originalUrl === fallbackUrl || !isHttpUrl(originalUrl)) { + originalUrl = inferOriginalImageUrl(fallbackUrl); + } + + return originalUrl; +} + +export function extractImageUrlsFromText(text: string): string[] { + if (!text) return []; + + const urls: string[] = []; + const added = new Set(); + + const addUrl = (url: string) => { + if (!url || !isHttpUrl(url) || added.has(url)) return; + added.add(url); + urls.push(url); + }; + + let hasMarkdownImages = false; + let markdownMatch: RegExpExecArray | null; + MARKDOWN_IMAGE_REGEX.lastIndex = 0; + + while ((markdownMatch = MARKDOWN_IMAGE_REGEX.exec(text)) !== null) { + hasMarkdownImages = true; + const altText = markdownMatch[1] || ""; + const thumbnailUrl = markdownMatch[2] || ""; + const originalUrl = extractOriginalImageUrlFromAlt(altText, thumbnailUrl); + addUrl(originalUrl || thumbnailUrl); + } + + if (hasMarkdownImages) return urls; + + const rawUrls = text.match(PLAIN_IMAGE_URL_REGEX) || []; + if (!rawUrls.length) return urls; + + const nonThumbnailUrls = rawUrls.filter((url) => !isLikelyThumbnailUrl(url)); + const candidates = nonThumbnailUrls.length + ? nonThumbnailUrls + : rawUrls.map((url) => inferOriginalImageUrl(url)); + + for (const url of candidates) { + addUrl(url); + } + + return urls; +}