diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index 05052e178..d7c8039da 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -225,6 +225,36 @@ function isHttpImageUrl(value: unknown) { } } +function getComparableImageIdentity(value: unknown) { + const raw = normalizeGeneratedImageUrl(value); + if (!isHttpImageUrl(raw)) { + return ""; + } + + try { + const parsed = new URL(raw); + parsed.hash = ""; + // Query params often include thumbnails/cache-busters. Same host+path still means + // the model returned the input asset instead of a newly generated image. + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.toLowerCase(); + } catch { + return raw.split("#")[0]?.split("?")[0]?.trim().toLowerCase() || ""; + } +} + +function createExcludedImageIdentitySet(urls?: Iterable) { + return new Set( + Array.from(urls || []) + .map(getComparableImageIdentity) + .filter(Boolean) + ); +} + +function isExcludedImageUrl(url: string, excludedIdentities: Set) { + const identity = getComparableImageIdentity(url); + return Boolean(identity && excludedIdentities.has(identity)); +} + function collectImageUrlsFromUnknown(value: unknown, output: Set) { if (value === null || value === undefined) return; @@ -261,25 +291,38 @@ function collectImageUrlsFromUnknown(value: unknown, output: Set) { } } -function pickGeneratedImageUrl(urls: Iterable) { +function pickGeneratedImageUrl( + urls: Iterable, + excludedIdentities = new Set() +) { const candidates = Array.from(new Set(Array.from(urls).map(normalizeGeneratedImageUrl))) - .filter(isHttpImageUrl); + .filter(isHttpImageUrl) + .filter((url) => !isExcludedImageUrl(url, excludedIdentities)); const imageLike = candidates.find((url) => /\.(avif|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url) ); return imageLike || candidates[0] || ""; } -function parseImageFromGenerationText(text: string): { originalUrl: string; thumbnailUrl: string } { +function parseImageFromGenerationText( + text: string, + excludedSourceUrls: string[] = [] +): { originalUrl: string; thumbnailUrl: string } { const markdown = String(text || "").trim(); + const excludedIdentities = createExcludedImageIdentitySet(excludedSourceUrls); + const seenUrls = new Set(); const strictMatch = markdown.match(/!\[thumb\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/); if (strictMatch) { const originalUrl = normalizeGeneratedImageUrl(strictMatch[1]); const thumbnailUrl = normalizeGeneratedImageUrl(strictMatch[2]); - if (isHttpImageUrl(originalUrl)) { + [originalUrl, thumbnailUrl].filter(isHttpImageUrl).forEach((url) => seenUrls.add(url)); + if (isHttpImageUrl(originalUrl) && !isExcludedImageUrl(originalUrl, excludedIdentities)) { return { originalUrl, - thumbnailUrl: isHttpImageUrl(thumbnailUrl) ? thumbnailUrl : originalUrl, + thumbnailUrl: + isHttpImageUrl(thumbnailUrl) && !isExcludedImageUrl(thumbnailUrl, excludedIdentities) + ? thumbnailUrl + : originalUrl, }; } } @@ -291,10 +334,11 @@ function parseImageFromGenerationText(text: string): { originalUrl: string; thum const url = normalizeGeneratedImageUrl(markdownMatch[1]); if (isHttpImageUrl(url)) { markdownUrls.add(url); + seenUrls.add(url); } } - const markdownUrl = pickGeneratedImageUrl(markdownUrls); + const markdownUrl = pickGeneratedImageUrl(markdownUrls, excludedIdentities); if (markdownUrl) { return { originalUrl: markdownUrl, @@ -308,8 +352,9 @@ function parseImageFromGenerationText(text: string): { originalUrl: string; thum } catch { collectImageUrlsFromUnknown(markdown, collectedUrls); } + collectedUrls.forEach((url) => seenUrls.add(url)); - const url = pickGeneratedImageUrl(collectedUrls); + const url = pickGeneratedImageUrl(collectedUrls, excludedIdentities); if (url) { return { originalUrl: url, @@ -317,6 +362,10 @@ function parseImageFromGenerationText(text: string): { originalUrl: string; thum }; } + if (Array.from(seenUrls).some((url) => isExcludedImageUrl(url, excludedIdentities))) { + throw new Error("模型返回的是输入原图,未完成文字替换"); + } + throw new Error("未解析到生成图片"); } @@ -331,6 +380,7 @@ function buildTextEditPrompt( "2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。", "3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。", "4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。", + "5. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。", ]; if (replacements.length > 0) { @@ -358,6 +408,7 @@ function buildTextEditPrompt( async function generateTextEditImageWithFailover(params: { candidates: TextEditModelCandidate[]; imageUrl: string; + sourceImageUrls: string[]; prompt: string; }) { let lastError: unknown = null; @@ -399,7 +450,7 @@ async function generateTextEditImageWithFailover(params: { throw new Error(resultText); } - const parsed = parseImageFromGenerationText(resultText); + const parsed = parseImageFromGenerationText(resultText, params.sourceImageUrls); return { ...parsed, model: selected.model.displayName, @@ -492,6 +543,7 @@ export async function POST(request: Request) { const { originalUrl, thumbnailUrl, model } = await generateTextEditImageWithFailover({ candidates, imageUrl: generationSourceImageUrl, + sourceImageUrls: [imageUrl, generationSourceImageUrl], prompt, }); diff --git a/components/agents/AgentImageTextEditDialog.tsx b/components/agents/AgentImageTextEditDialog.tsx index 482162f04..f99e28ca4 100644 --- a/components/agents/AgentImageTextEditDialog.tsx +++ b/components/agents/AgentImageTextEditDialog.tsx @@ -101,6 +101,27 @@ function isHttpImageUrl(value?: string | null) { } } +function getComparableImageIdentity(value?: string | null) { + const raw = String(value || "").trim(); + if (!isHttpImageUrl(raw)) { + return ""; + } + + try { + const parsed = new URL(raw); + parsed.hash = ""; + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.toLowerCase(); + } catch { + return raw.split("#")[0]?.split("?")[0]?.trim().toLowerCase() || ""; + } +} + +function isSameImageAsset(left?: string | null, right?: string | null) { + const leftIdentity = getComparableImageIdentity(left); + const rightIdentity = getComparableImageIdentity(right); + return Boolean(leftIdentity && rightIdentity && leftIdentity === rightIdentity); +} + export function AgentImageTextEditDialog({ open, imageUrl, @@ -380,6 +401,10 @@ export function AgentImageTextEditDialog({ throw new Error(result.error || "文字替换失败"); } + if (isSameImageAsset(result.data.originalUrl, normalizedImageUrl)) { + throw new Error("模型返回的仍是原图,未完成文字替换,请换一条文案或稍后重试"); + } + toast.success("文字替换已完成"); setDeletedTextItems([]); setDetectedSourceUrl(result.data.originalUrl);