fix: reject unchanged text edit results

This commit is contained in:
Your Name 2026-06-01 21:47:50 +08:00
parent 08323571aa
commit 61bb42a266
2 changed files with 85 additions and 8 deletions

View File

@ -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<string>) {
return new Set(
Array.from(urls || [])
.map(getComparableImageIdentity)
.filter(Boolean)
);
}
function isExcludedImageUrl(url: string, excludedIdentities: Set<string>) {
const identity = getComparableImageIdentity(url);
return Boolean(identity && excludedIdentities.has(identity));
}
function collectImageUrlsFromUnknown(value: unknown, output: Set<string>) {
if (value === null || value === undefined) return;
@ -261,25 +291,38 @@ function collectImageUrlsFromUnknown(value: unknown, output: Set<string>) {
}
}
function pickGeneratedImageUrl(urls: Iterable<string>) {
function pickGeneratedImageUrl(
urls: Iterable<string>,
excludedIdentities = new Set<string>()
) {
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<string>();
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,
});

View File

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