diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index 7b35297..311e278 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -10,7 +10,8 @@ export const maxDuration = 120; const textItemSchema = z.object({ id: 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({ @@ -56,22 +57,39 @@ function parseImageFromMarkdown(markdown: string): { originalUrl: string; thumbn throw new Error("未解析到生成图片"); } -function buildTextEditPrompt(replacements: Array<{ originalText: string; editedText: string }>) { - const replacementLines = replacements - .map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`) - .join("\n"); - - return [ +function buildTextEditPrompt( + replacements: Array<{ originalText: string; editedText: string }>, + deletions: Array<{ originalText: string }>, +) { + const sections: string[] = [ "请基于输入图片执行精确文字替换。", "硬性要求:", "1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。", "2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。", "3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。", "4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。", - "替换清单:", - replacementLines, - "请返回一张最终完成的图片。", - ].join("\n"); + ]; + + if (replacements.length > 0) { + 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) { @@ -92,16 +110,41 @@ export async function POST(request: Request) { return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); } - const replacements = parsed.data.items + const normalizedItems = parsed.data.items .map((item) => ({ id: item.id, originalText: item.originalText.trim(), - editedText: item.editedText.trim(), - })) - .filter((item) => item.originalText && item.editedText && item.originalText !== item.editedText); + editedText: (item.editedText || "").trim(), + action: item.action, + })); - if (replacements.length === 0) { - return NextResponse.json({ success: false, error: "未检测到有效的文字改动" }, { status: 400 }); + const replacements = normalizedItems + .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(); @@ -116,7 +159,7 @@ export async function POST(request: Request) { apiKey: selected.provider.apiKey, }); - const prompt = buildTextEditPrompt(replacements); + const prompt = buildTextEditPrompt(replacements, deletions); const result = await provider.chat(selected.model.modelId).doGenerate({ messages: [ diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 8f18187..a1fd9bf 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -167,6 +167,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [textEditError, setTextEditError] = useState(null); const [isDetectingText, setIsDetectingText] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); + const [deletedTextItems, setDeletedTextItems] = useState>([]); // 文件输入的 ref const fileInputRef = useRef(null); @@ -201,6 +202,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { useEffect(() => { if (!textEditPanelOpen) { setTextEditItems(normalizeTextEditItems(data.textEditItems)); + setDeletedTextItems([]); setTextEditError(null); } }, [data.textEditItems, textEditPanelOpen]); @@ -333,6 +335,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const cached = normalizeTextEditItems(data.textEditItems); if (cached.length > 0) { setTextEditItems(cached); + setDeletedTextItems([]); setTextEditError(null); return; } @@ -362,6 +365,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { .filter((item): item is ImageTextEditItem => !!item); setTextEditItems(detectedItems); + setDeletedTextItems([]); updateNode(node.id, { data: { @@ -395,6 +399,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const cached = normalizeTextEditItems(data.textEditItems); if (cached.length > 0 && data.textEditSource === data.src) { setTextEditItems(cached); + setDeletedTextItems([]); 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 () => { if (!data.src) { toast.error("请先上传图片"); @@ -419,20 +437,34 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { } const changedItems: CanvasTextEditPayloadItem[] = textEditItems - .map((item) => ({ - id: item.id, - originalText: item.originalText.trim(), - editedText: item.editedText.trim(), - })) + .map((item) => { + const editedText = item.editedText.trim(); + const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace"; + return { + id: item.id, + originalText: item.originalText.trim(), + editedText, + action, + }; + }) .filter( (item) => item.originalText.length > 0 && - item.editedText.length > 0 && - item.originalText !== item.editedText, + ((item.action === "delete") || (item.editedText && item.editedText.length > 0 && item.originalText !== item.editedText)), ); - if (changedItems.length === 0) { - toast.info("请先修改至少一段文字"); + const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems + .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; } @@ -463,7 +495,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { try { const result = await imageGenerationService.editCanvasText({ imageUrl: data.src, - items: changedItems, + items: requestItems, }); if (!result.success || !result.data?.originalUrl) { @@ -490,6 +522,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { }); setTextEditPanelOpen(false); + setDeletedTextItems([]); toast.success("文字替换已完成"); } catch (error) { const message = error instanceof Error ? error.message : "文字替换失败"; @@ -507,7 +540,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setIsApplyingTextEdit(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) => { @@ -1428,9 +1461,20 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {textEditItems.map((item, index) => (
-
- 文本 {index + 1} - {typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`} +
+
+ 文本 {index + 1} + {typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`} +
+
原文:{item.originalText} @@ -1442,6 +1486,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { />
))} + + {deletedTextItems.length > 0 && ( +
+ 已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。 +
+ )}
@@ -1456,7 +1506,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {