ai-chatbot/components/agents/AgentImageTextEditDialog.tsx
2026-06-01 21:47:50 +08:00

631 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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;
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,
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
item.originalText !== item.editedText))
);
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<AgentImageTextEditItem[]>([]);
const [deletedTextItems, setDeletedTextItems] = useState<DeletedTextItem[]>([]);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [detectedSourceUrl, setDetectedSourceUrl] = useState<string | null>(null);
const [suggestionState, setSuggestionState] = useState<Record<string, SuggestionStateItem>>({});
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,
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 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden border-slate-200 bg-white p-0 sm:max-w-[620px] dark:border-zinc-800 dark:bg-zinc-950">
<DialogHeader className="shrink-0 border-b border-slate-200 px-6 py-4 dark:border-zinc-800">
<DialogTitle className="flex items-center gap-2 text-left text-lg">
<Type className="h-5 w-5" />
<span>编辑文字</span>
</DialogTitle>
<DialogDescription className="text-left text-sm leading-6 text-slate-500 dark:text-zinc-400">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
{title ? ` 当前处理:${title}` : ""}
</DialogDescription>
</DialogHeader>
<div className="agent-dark-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5">
{normalizedImageUrl ? (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900">
<img
src={normalizedImageUrl}
alt={title || "待编辑结果图"}
className="block max-h-[240px] w-full object-contain"
/>
</div>
) : null}
{copyContextSummary ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
AI 文案建议会优先参考:{copyContextSummary}
</div>
) : null}
{isDetectingText ? (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
) : null}
{textEditError && !isDetectingText ? (
<div className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-500">
{textEditError}
</div>
) : null}
{!isDetectingText && textEditItems.length === 0 && !textEditError ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-4 text-sm leading-6 text-slate-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
) : null}
{textEditItems.map((item, index) => (
<div
key={item.id}
className="space-y-3 rounded-2xl border border-slate-200 p-4 dark:border-zinc-800"
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-slate-500 dark:text-zinc-400">
文本 {index + 1}
{typeof item.confidence === "number"
? ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`
: ""}
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-slate-500 hover:text-red-500 dark:text-zinc-400"
onClick={() => handleRemoveTextItem(item.id)}
title="删除该条文字"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="text-sm text-slate-500 dark:text-zinc-400">原文:{item.originalText}</div>
<Input
value={item.editedText}
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
placeholder="输入替换后的文字"
/>
<div className="space-y-3 rounded-xl bg-slate-50/80 p-3 dark:bg-zinc-900/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-slate-500 dark:text-zinc-400">
一键 AI 文案
{suggestionState[item.id]?.model
? ` · ${suggestionState[item.id]?.model}`
: ""}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-8"
disabled={suggestionState[item.id]?.isLoading || isApplyingTextEdit}
onClick={() => void handleGenerateSuggestions(item)}
>
{suggestionState[item.id]?.isLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
{suggestionState[item.id]?.suggestions?.length ? "换一批" : "生成 5 条"}
</Button>
</div>
{suggestionState[item.id]?.error ? (
<div className="rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500">
{suggestionState[item.id]?.error}
</div>
) : null}
{suggestionState[item.id]?.isLoading ? (
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在结合图片和人群信息生成文案建议...
</div>
) : null}
{suggestionState[item.id]?.suggestions?.length ? (
<div className="space-y-2">
{suggestionState[item.id].suggestions.map((suggestion, suggestionIndex) => (
<button
key={`${item.id}-suggestion-${suggestionIndex}`}
type="button"
onClick={() => handleApplySuggestion(item.id, suggestion.text)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-left transition-colors hover:border-emerald-300 hover:bg-emerald-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-emerald-700 dark:hover:bg-emerald-950/20"
>
<div className="text-sm font-medium text-slate-800 dark:text-zinc-100">
{suggestion.text}
</div>
{suggestion.reason ? (
<div className="mt-1 text-xs text-slate-500 dark:text-zinc-400">
{suggestion.reason}
</div>
) : null}
</button>
))}
</div>
) : !suggestionState[item.id]?.isLoading ? (
<div className="text-xs leading-5 text-slate-500 dark:text-zinc-400">
会生成 5 条字数接近、语气更生活化的候选文案,点击即可替换到当前输入框。
</div>
) : null}
</div>
</div>
))}
{deletedTextItems.length > 0 ? (
<div className="rounded-xl border border-orange-300 border-dashed bg-orange-50 px-4 py-3 text-sm text-orange-700 dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200">
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
) : null}
</div>
<div className="shrink-0 flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-zinc-800">
<Button
type="button"
variant="outline"
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => void detectTextItems(true)}
>
{isDetectingText ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
重新识别
</Button>
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
disabled={isApplyingTextEdit}
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
取消
</Button>
<Button
type="button"
disabled={
isDetectingText ||
isApplyingTextEdit ||
!hasTextEditChanges
}
onClick={() => void handleApplyTextEdit()}
>
{isApplyingTextEdit ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Type className="mr-2 h-4 w-4" />
)}
{hasTextEditChanges ? "确定生图" : "先修改文字"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}