fix: reject unchanged text edit results
This commit is contained in:
parent
08323571aa
commit
61bb42a266
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user