ai-chatbot/components/agents/AgentImageTextEditDialog.tsx
Your Name 9837ff0b05
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: polish canvas send icon and text edit dialog
2026-04-29 11:37:08 +08:00

568 lines
20 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 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;
}
}
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 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,
});
if (!result.success || !result.data?.suggestions?.length) {
throw new Error(result.error || "未生成可用文案建议");
}
setSuggestionState((current) => ({
...current,
[item.id]: {
isLoading: false,
suggestions: result.data?.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) => {
handleTextItemChange(id, value);
toast.success("已套用推荐文案");
}, [handleTextItemChange]);
const handleApplyTextEdit = useCallback(async () => {
if (!normalizedImageUrl) {
toast.error("当前图片地址无效");
return;
}
if (!isHttpImageUrl(normalizedImageUrl)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isApplyingTextEdit) {
return;
}
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);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改或删除至少一段文字");
return;
}
setIsApplyingTextEdit(true);
setTextEditError(null);
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: normalizedImageUrl,
items: requestItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.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);
}
}, [
deletedTextItems,
isApplyingTextEdit,
normalizedImageUrl,
onApplySuccess,
onOpenChange,
textEditItems,
]);
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 ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
onClick={() => void handleApplyTextEdit()}
>
{isApplyingTextEdit ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Type className="mr-2 h-4 w-4" />
)}
确定生图
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}