fix(image-quality): always reuse original image URLs in follow-up edits
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-12 15:43:12 +08:00
parent ec00b35658
commit ef8326fd33
4 changed files with 132 additions and 67 deletions

View File

@ -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<string>();
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) {

View File

@ -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 {

View File

@ -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();

125
lib/image-url.ts Normal file
View File

@ -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<string>();
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;
}