"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react"; import { toast } from "sonner"; import { TextEditColorField } from "@/components/canvas/TextEditColorField"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { imageGenerationService, type CanvasTextEditPayloadItem, type CanvasTextSuggestionItem, } from "@/services/canvas"; interface AgentImageTextEditDialogProps { open: boolean; imageUrl?: string | null; title?: string | null; onOpenChange: (open: boolean) => void; onApplySuccess?: (result: { originalUrl: string; thumbnailUrl?: string | null; model?: string | null }) => void; copySuggestionContext?: { audience?: string | null; usageScenario?: string | null; coreNeed?: string | null; productName?: string | null; workflowSummary?: string | null; workflowPrompt?: string | null; userPrompt?: string | null; presetName?: string | null; } | null; } interface AgentImageTextEditItem { id: string; originalText: string; editedText: string; textColor?: string; confidence?: number; } interface DeletedTextItem { id: string; originalText: string; } interface SuggestionStateItem { isLoading: boolean; suggestions: CanvasTextSuggestionItem[]; error?: string | null; model?: string | null; } function buildTextEditRequestItems( textEditItems: AgentImageTextEditItem[], deletedTextItems: DeletedTextItem[] ) { const changedItems: CanvasTextEditPayloadItem[] = textEditItems .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, textColor: normalizeOptionalHexColor(item.textColor), action, }; }) .filter( (item) => item.originalText.length > 0 && (item.action === "delete" || (item.editedText && item.editedText.length > 0 && (item.originalText !== item.editedText || Boolean(item.textColor)))) ); const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems .map((item) => ({ id: item.id, originalText: item.originalText.trim(), action: "delete" as const, })) .filter((item) => item.originalText.length > 0); return [...changedItems, ...removedItems]; } function isHttpImageUrl(value?: string | null) { if (!value) return false; try { const parsed = new URL(String(value).trim()); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } 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, title, onOpenChange, onApplySuccess, copySuggestionContext = null, }: AgentImageTextEditDialogProps) { const normalizedImageUrl = useMemo(() => String(imageUrl || "").trim(), [imageUrl]); const [textEditItems, setTextEditItems] = useState([]); const [deletedTextItems, setDeletedTextItems] = useState([]); const [isDetectingText, setIsDetectingText] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [textEditError, setTextEditError] = useState(null); const [detectedSourceUrl, setDetectedSourceUrl] = useState(null); const [suggestionState, setSuggestionState] = useState>({}); const copyContextSummary = useMemo(() => { const chips = [ copySuggestionContext?.audience ? `人群:${copySuggestionContext.audience}` : "", copySuggestionContext?.usageScenario ? `场景:${copySuggestionContext.usageScenario}` : "", copySuggestionContext?.coreNeed ? `需求:${copySuggestionContext.coreNeed}` : "", ].filter(Boolean); return chips.join(" / "); }, [ copySuggestionContext?.audience, copySuggestionContext?.coreNeed, copySuggestionContext?.usageScenario, ]); const detectTextItems = useCallback( async (forceRefresh = false) => { if (!normalizedImageUrl) { setTextEditItems([]); setDeletedTextItems([]); setTextEditError("当前图片地址无效"); return; } if (!isHttpImageUrl(normalizedImageUrl)) { setTextEditItems([]); setDeletedTextItems([]); setTextEditError("仅支持 URL 图片,不允许 base64 传输"); return; } if (isDetectingText) { return; } if (!forceRefresh && detectedSourceUrl === normalizedImageUrl && textEditItems.length > 0) { setDeletedTextItems([]); setTextEditError(null); return; } setIsDetectingText(true); setTextEditError(null); try { const result = await imageGenerationService.detectCanvasText(normalizedImageUrl); if (!result.success) { throw new Error(result.error || "文字识别失败"); } const detectedItems: AgentImageTextEditItem[] = []; (result.data?.items || []).forEach((item, index) => { const text = String(item.text || "").trim(); if (!text) { return; } detectedItems.push({ id: item.id || String(index + 1), originalText: text, editedText: text, textColor: undefined, confidence: item.confidence, }); }); setTextEditItems(detectedItems); setDeletedTextItems([]); setDetectedSourceUrl(normalizedImageUrl); if (detectedItems.length === 0) { toast.info("未识别到可编辑文字"); } else { toast.success(`已识别 ${detectedItems.length} 段文字`); } } catch (error) { const message = error instanceof Error ? error.message : "文字识别失败"; setTextEditError(message); toast.error(message); } finally { setIsDetectingText(false); } }, [detectedSourceUrl, isDetectingText, normalizedImageUrl, textEditItems.length] ); useEffect(() => { if (!open) { return; } setTextEditError(null); if (detectedSourceUrl === normalizedImageUrl && textEditItems.length > 0) { setDeletedTextItems([]); return; } setTextEditItems([]); setDeletedTextItems([]); setSuggestionState({}); void detectTextItems(true); }, [detectTextItems, detectedSourceUrl, normalizedImageUrl, open, textEditItems.length]); const handleTextItemChange = useCallback((id: string, value: string) => { setTextEditItems((prev) => prev.map((item) => (item.id === id ? { ...item, editedText: value } : item)) ); }, []); const handleTextItemColorChange = useCallback((id: string, value?: string) => { setTextEditItems((prev) => prev.map((item) => item.id === id ? { ...item, textColor: normalizeOptionalHexColor(value) } : item ) ); }, []); const handleRemoveTextItem = useCallback((id: string) => { setTextEditItems((prev) => { const target = prev.find((item) => item.id === id); if (!target?.originalText) { return prev.filter((item) => item.id !== id); } 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 textEditRequestItems = useMemo( () => buildTextEditRequestItems(textEditItems, deletedTextItems), [deletedTextItems, textEditItems] ); const hasTextEditChanges = textEditRequestItems.length > 0; const handleGenerateSuggestions = useCallback( async (item: AgentImageTextEditItem) => { if (!normalizedImageUrl) { toast.error("当前图片地址无效"); return; } if (!isHttpImageUrl(normalizedImageUrl)) { toast.error("仅支持 URL 图片,不允许 base64 传输"); return; } setSuggestionState((current) => ({ ...current, [item.id]: { isLoading: true, suggestions: current[item.id]?.suggestions || [], error: null, model: current[item.id]?.model || null, }, })); try { const result = await imageGenerationService.generateCanvasTextSuggestions({ imageUrl: normalizedImageUrl, originalText: item.originalText, currentText: item.editedText, title: title || undefined, pageTexts: textEditItems.map((entry) => entry.editedText.trim() || entry.originalText.trim()), audience: copySuggestionContext?.audience || undefined, usageScenario: copySuggestionContext?.usageScenario || undefined, coreNeed: copySuggestionContext?.coreNeed || undefined, productName: copySuggestionContext?.productName || undefined, workflowSummary: copySuggestionContext?.workflowSummary || undefined, workflowPrompt: copySuggestionContext?.workflowPrompt || undefined, userPrompt: copySuggestionContext?.userPrompt || undefined, presetName: copySuggestionContext?.presetName || undefined, }); const currentItem = textEditItems.find((entry) => entry.id === item.id); const blockedTexts = new Set( [item.originalText, currentItem?.editedText] .map((entry) => String(entry || "").trim()) .filter(Boolean) ); const suggestions = (result.data?.suggestions || []).filter((suggestion) => { const text = String(suggestion.text || "").trim(); return Boolean(text) && !blockedTexts.has(text); }); if (!result.success || suggestions.length === 0) { throw new Error(result.error || "未生成可用文案建议"); } setSuggestionState((current) => ({ ...current, [item.id]: { isLoading: false, suggestions, error: null, model: result.data?.model || null, }, })); toast.success("已生成 5 条 AI 文案建议"); } catch (error) { const message = error instanceof Error ? error.message : "文案建议生成失败"; setSuggestionState((current) => ({ ...current, [item.id]: { isLoading: false, suggestions: current[item.id]?.suggestions || [], error: message, model: current[item.id]?.model || null, }, })); toast.error(message); } }, [copySuggestionContext, normalizedImageUrl, textEditItems, title] ); const handleApplySuggestion = useCallback((id: string, value: string) => { const nextText = String(value || "").trim(); const target = textEditItems.find((item) => item.id === id); if (!nextText) { toast.info("这条推荐文案为空,请换一条"); return; } if (target && nextText === target.originalText.trim()) { toast.info("这条和原文一样,不会触发生成,请换一条推荐文案"); return; } if (target && nextText === target.editedText.trim()) { toast.info("这条已经在输入框里了"); return; } handleTextItemChange(id, nextText); toast.success("已套用推荐文案,可以点击确定生图"); }, [handleTextItemChange, textEditItems]); const handleApplyTextEdit = useCallback(async () => { if (!normalizedImageUrl) { toast.error("当前图片地址无效"); return; } if (!isHttpImageUrl(normalizedImageUrl)) { toast.error("仅支持 URL 图片,不允许 base64 传输"); return; } if (isApplyingTextEdit) { return; } if (textEditRequestItems.length === 0) { toast.info("请先修改文字、选择文字颜色,或点击垃圾桶删除该文字"); return; } setIsApplyingTextEdit(true); setTextEditError(null); try { const result = await imageGenerationService.editCanvasText({ imageUrl: normalizedImageUrl, items: textEditRequestItems, }); if (!result.success || !result.data?.originalUrl) { throw new Error(result.error || "文字替换失败"); } if (isSameImageAsset(result.data.originalUrl, normalizedImageUrl)) { throw new Error("模型返回的仍是原图,未完成文字替换,请换一条文案或稍后重试"); } toast.success("文字替换已完成"); setDeletedTextItems([]); setDetectedSourceUrl(result.data.originalUrl); onApplySuccess?.({ originalUrl: result.data.originalUrl, thumbnailUrl: result.data.thumbnailUrl, model: result.data.model || null, }); onOpenChange(false); } catch (error) { const message = error instanceof Error ? error.message : "文字替换失败"; setTextEditError(message); toast.error(message); } finally { setIsApplyingTextEdit(false); } }, [ isApplyingTextEdit, normalizedImageUrl, onApplySuccess, onOpenChange, textEditRequestItems, ]); return ( 编辑文字 识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。 {title ? ` 当前处理:${title}` : ""}
{normalizedImageUrl ? (
{title
) : null} {copyContextSummary ? (
AI 文案建议会优先参考:{copyContextSummary}
) : null} {isDetectingText ? (
正在识别图片文字...
) : null} {textEditError && !isDetectingText ? (
{textEditError}
) : null} {!isDetectingText && textEditItems.length === 0 && !textEditError ? (
未识别到可编辑文字,可点击“重新识别”再试一次。
) : null} {textEditItems.map((item, index) => (
文本 {index + 1} {typeof item.confidence === "number" ? ` · 置信度 ${(item.confidence * 100).toFixed(0)}%` : ""}
原文:{item.originalText}
handleTextItemChange(item.id, event.target.value)} placeholder="输入替换后的文字" /> handleTextItemColorChange(item.id, value)} />
一键 AI 文案 {suggestionState[item.id]?.model ? ` · ${suggestionState[item.id]?.model}` : ""}
{suggestionState[item.id]?.error ? (
{suggestionState[item.id]?.error}
) : null} {suggestionState[item.id]?.isLoading ? (
正在结合图片和人群信息生成文案建议...
) : null} {suggestionState[item.id]?.suggestions?.length ? (
{suggestionState[item.id].suggestions.map((suggestion, suggestionIndex) => ( ))}
) : !suggestionState[item.id]?.isLoading ? (
会生成 5 条字数接近、语气更生活化的候选文案,点击即可替换到当前输入框。
) : null}
))} {deletedTextItems.length > 0 ? (
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
) : null}
); }