文字编辑支持删除条目并传递到生图
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

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({
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: [

View File

@ -167,6 +167,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [textEditError, setTextEditError] = useState<string | null>(null);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(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) => ({
.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: item.editedText.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,10 +1461,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{textEditItems.map((item, index) => (
<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">
文本 {index + 1}
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
</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">
原文:{item.originalText}
</div>
@ -1442,6 +1486,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
/>
</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 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
size="sm"
disabled={isDetectingText || isApplyingTextEdit || textEditItems.length === 0}
disabled={
isDetectingText ||
isApplyingTextEdit ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
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" />}

View File

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