文字编辑支持删除条目并传递到生图

This commit is contained in:
Your Name 2026-02-07 21:53:37 +08:00
parent 18a3495d2c
commit 927514ad6b
3 changed files with 132 additions and 34 deletions

View File

@ -10,7 +10,8 @@ export const maxDuration = 120;
const textItemSchema = z.object({ const textItemSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
originalText: z.string().min(1), originalText: z.string().min(1),
editedText: z.string().min(1), editedText: z.string().optional(),
action: z.enum(["replace", "delete"]).optional(),
}); });
const requestSchema = z.object({ const requestSchema = z.object({
@ -56,22 +57,39 @@ function parseImageFromMarkdown(markdown: string): { originalUrl: string; thumbn
throw new Error("未解析到生成图片"); throw new Error("未解析到生成图片");
} }
function buildTextEditPrompt(replacements: Array<{ originalText: string; editedText: string }>) { function buildTextEditPrompt(
const replacementLines = replacements replacements: Array<{ originalText: string; editedText: string }>,
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`) deletions: Array<{ originalText: string }>,
.join("\n"); ) {
const sections: string[] = [
return [
"请基于输入图片执行精确文字替换。", "请基于输入图片执行精确文字替换。",
"硬性要求:", "硬性要求:",
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。", "1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。", "2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。", "3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。", "4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
"替换清单:", ];
replacementLines,
"请返回一张最终完成的图片。", if (replacements.length > 0) {
].join("\n"); const replacementLines = replacements
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
.join("\n");
sections.push("替换清单:", replacementLines);
}
if (deletions.length > 0) {
const deletionLines = deletions
.map((item, index) => `${index + 1}. 删除文字: ${item.originalText}`)
.join("\n");
sections.push(
"删除清单:",
deletionLines,
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
);
}
sections.push("请返回一张最终完成的图片。");
return sections.join("\n");
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -92,16 +110,41 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
} }
const replacements = parsed.data.items const normalizedItems = parsed.data.items
.map((item) => ({ .map((item) => ({
id: item.id, id: item.id,
originalText: item.originalText.trim(), originalText: item.originalText.trim(),
editedText: item.editedText.trim(), editedText: (item.editedText || "").trim(),
})) action: item.action,
.filter((item) => item.originalText && item.editedText && item.originalText !== item.editedText); }));
if (replacements.length === 0) { const replacements = normalizedItems
return NextResponse.json({ success: false, error: "未检测到有效的文字改动" }, { status: 400 }); .filter(
(item) =>
item.originalText &&
item.editedText &&
item.action !== "delete" &&
item.originalText !== item.editedText,
)
.map((item) => ({
id: item.id,
originalText: item.originalText,
editedText: item.editedText,
}));
const deletions = normalizedItems
.filter(
(item) =>
item.originalText &&
(item.action === "delete" || item.editedText.length === 0),
)
.map((item) => ({
id: item.id,
originalText: item.originalText,
}));
if (replacements.length === 0 && deletions.length === 0) {
return NextResponse.json({ success: false, error: "未检测到有效的文字改动或删除" }, { status: 400 });
} }
const models = await getEnabledAiModelsWithProvider(); const models = await getEnabledAiModelsWithProvider();
@ -116,7 +159,7 @@ export async function POST(request: Request) {
apiKey: selected.provider.apiKey, apiKey: selected.provider.apiKey,
}); });
const prompt = buildTextEditPrompt(replacements); const prompt = buildTextEditPrompt(replacements, deletions);
const result = await provider.chat(selected.model.modelId).doGenerate({ const result = await provider.chat(selected.model.modelId).doGenerate({
messages: [ messages: [

View File

@ -167,6 +167,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [textEditError, setTextEditError] = useState<string | null>(null); const [textEditError, setTextEditError] = useState<string | null>(null);
const [isDetectingText, setIsDetectingText] = useState(false); const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
// 文件输入的 ref // 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -201,6 +202,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
useEffect(() => { useEffect(() => {
if (!textEditPanelOpen) { if (!textEditPanelOpen) {
setTextEditItems(normalizeTextEditItems(data.textEditItems)); setTextEditItems(normalizeTextEditItems(data.textEditItems));
setDeletedTextItems([]);
setTextEditError(null); setTextEditError(null);
} }
}, [data.textEditItems, textEditPanelOpen]); }, [data.textEditItems, textEditPanelOpen]);
@ -333,6 +335,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const cached = normalizeTextEditItems(data.textEditItems); const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0) { if (cached.length > 0) {
setTextEditItems(cached); setTextEditItems(cached);
setDeletedTextItems([]);
setTextEditError(null); setTextEditError(null);
return; return;
} }
@ -362,6 +365,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
.filter((item): item is ImageTextEditItem => !!item); .filter((item): item is ImageTextEditItem => !!item);
setTextEditItems(detectedItems); setTextEditItems(detectedItems);
setDeletedTextItems([]);
updateNode(node.id, { updateNode(node.id, {
data: { data: {
@ -395,6 +399,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const cached = normalizeTextEditItems(data.textEditItems); const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0 && data.textEditSource === data.src) { if (cached.length > 0 && data.textEditSource === data.src) {
setTextEditItems(cached); setTextEditItems(cached);
setDeletedTextItems([]);
return; return;
} }
@ -407,6 +412,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
); );
}; };
const handleRemoveTextItem = (id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
if (target?.originalText) {
setDeletedTextItems((deletedPrev) => {
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
return [...deletedPrev, { id, originalText: target.originalText }];
});
}
return prev.filter((item) => item.id !== id);
});
};
const handleApplyTextEdit = useCallback(async () => { const handleApplyTextEdit = useCallback(async () => {
if (!data.src) { if (!data.src) {
toast.error("请先上传图片"); toast.error("请先上传图片");
@ -419,20 +437,34 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
} }
const changedItems: CanvasTextEditPayloadItem[] = textEditItems const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => ({ .map((item) => {
const editedText = item.editedText.trim();
const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace";
return {
id: item.id, id: item.id,
originalText: item.originalText.trim(), originalText: item.originalText.trim(),
editedText: item.editedText.trim(), editedText,
})) action,
};
})
.filter( .filter(
(item) => (item) =>
item.originalText.length > 0 && item.originalText.length > 0 &&
item.editedText.length > 0 && ((item.action === "delete") || (item.editedText && item.editedText.length > 0 && item.originalText !== item.editedText)),
item.originalText !== item.editedText,
); );
if (changedItems.length === 0) { const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
toast.info("请先修改至少一段文字"); .map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
action: "delete" as const,
}))
.filter((item) => item.originalText.length > 0);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改或删除至少一段文字");
return; return;
} }
@ -463,7 +495,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
try { try {
const result = await imageGenerationService.editCanvasText({ const result = await imageGenerationService.editCanvasText({
imageUrl: data.src, imageUrl: data.src,
items: changedItems, items: requestItems,
}); });
if (!result.success || !result.data?.originalUrl) { if (!result.success || !result.data?.originalUrl) {
@ -490,6 +522,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}); });
setTextEditPanelOpen(false); setTextEditPanelOpen(false);
setDeletedTextItems([]);
toast.success("文字替换已完成"); toast.success("文字替换已完成");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败"; const message = error instanceof Error ? error.message : "文字替换失败";
@ -507,7 +540,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setIsApplyingTextEdit(false); setIsApplyingTextEdit(false);
setIsProcessing(false); setIsProcessing(false);
} }
}, [addNode, data, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]); }, [addNode, data, deletedTextItems, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]);
// 触发文件选择 // 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => { const handleSelectFile = (e: React.MouseEvent) => {
@ -1428,10 +1461,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{textEditItems.map((item, index) => ( {textEditItems.map((item, index) => (
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2"> <div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
文本 {index + 1} 文本 {index + 1}
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`} {typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
</div> </div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveTextItem(item.id)}
title="删除该条文字"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground line-clamp-2"> <div className="text-xs text-muted-foreground line-clamp-2">
原文:{item.originalText} 原文:{item.originalText}
</div> </div>
@ -1442,6 +1486,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
/> />
</div> </div>
))} ))}
{deletedTextItems.length > 0 && (
<div className="rounded-md border border-dashed border-orange-300 bg-orange-50/60 px-3 py-2 text-xs text-orange-700">
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
)}
</div> </div>
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70"> <div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
@ -1456,7 +1506,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={isDetectingText || isApplyingTextEdit || textEditItems.length === 0} disabled={
isDetectingText ||
isApplyingTextEdit ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
onClick={handleApplyTextEdit} onClick={handleApplyTextEdit}
> >
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />} {isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}

View File

@ -17,7 +17,8 @@ export interface CanvasTextDetectItem {
export interface CanvasTextEditPayloadItem { export interface CanvasTextEditPayloadItem {
id: string; id: string;
originalText: string; originalText: string;
editedText: string; editedText?: string;
action?: 'replace' | 'delete';
} }
// 画布服务 // 画布服务