feat: enhance detail result image actions

This commit is contained in:
134 2026-06-15 13:28:58 +08:00
parent fe18aa0000
commit 801d2c4644
2 changed files with 407 additions and 16 deletions

View File

@ -67,6 +67,7 @@ import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor"; import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
import { ImageAnnotator } from "@/components/image-annotator"; import { ImageAnnotator } from "@/components/image-annotator";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { requestLongTaskResult } from "@/lib/client/long-task"; import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
@ -218,6 +219,19 @@ type BatchDownloadDialogState = {
expiresAt: number; expiresAt: number;
}; };
type DetailResultLocalEditTarget = {
cardId: string;
imageUrl: string;
title: string;
};
type DetailResultLocalEditRect = {
x: number;
y: number;
width: number;
height: number;
};
const MAX_CHAT_TEXT_PART_LENGTH = 2000; const MAX_CHAT_TEXT_PART_LENGTH = 2000;
const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
@ -700,6 +714,177 @@ function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => window.setTimeout(resolve, ms));
} }
function DetailResultLocalEditDialog({
submitting,
target,
onClose,
onSubmit,
}: {
submitting: boolean;
target: DetailResultLocalEditTarget | null;
onClose: () => void;
onSubmit: (params: {
imageUrl: string;
prompt: string;
rect: DetailResultLocalEditRect;
title: string;
}) => void;
}) {
const [prompt, setPrompt] = useState("");
const [rect, setRect] = useState<DetailResultLocalEditRect | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!target) {
setPrompt("");
setRect(null);
dragStartRef.current = null;
}
}, [target]);
const getPoint = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const x = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
const y = bounds.height > 0 ? (event.clientY - bounds.top) / bounds.height : 0;
return {
x: Math.min(1, Math.max(0, x)),
y: Math.min(1, Math.max(0, y)),
};
}, []);
const updateRect = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const start = dragStartRef.current;
if (!start) return;
const point = getPoint(event);
const left = Math.min(start.x, point.x);
const top = Math.min(start.y, point.y);
setRect({
x: left,
y: top,
width: Math.max(0.01, Math.abs(point.x - start.x)),
height: Math.max(0.01, Math.abs(point.y - start.y)),
});
},
[getPoint]
);
const handleSubmit = useCallback(() => {
if (!target || !rect) return;
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt) {
toast.error("请填写局部编辑提示词");
return;
}
if (rect.width < 0.025 || rect.height < 0.025) {
toast.error("请框选更明确的编辑区域");
return;
}
onSubmit({
imageUrl: target.imageUrl,
prompt: normalizedPrompt,
rect,
title: target.title,
});
}, [onSubmit, prompt, rect, target]);
return (
<Dialog open={Boolean(target)} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<DialogContent className="max-w-4xl border-white/10 bg-[#101214] text-white">
<DialogHeader>
<DialogTitle>局部编辑详情图</DialogTitle>
<DialogDescription className="text-white/50">
在图片上拖拽框选需要编辑的位置,并填写局部修改要求。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_280px]">
<div className="flex min-h-[420px] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35 p-3">
{target?.imageUrl ? (
<div
className="relative max-h-[62vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
onPointerCancel={(event) => {
updateRect(event);
dragStartRef.current = null;
}}
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const point = getPoint(event);
dragStartRef.current = point;
setRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 });
}}
onPointerMove={(event) => {
if (!dragStartRef.current) return;
event.preventDefault();
updateRect(event);
}}
onPointerUp={(event) => {
updateRect(event);
dragStartRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}}
>
<img
alt={target.title}
className="block max-h-[62vh] max-w-full object-contain"
draggable={false}
src={buildImageProxyUrl(target.imageUrl, { width: 1200, quality: 88 })}
/>
{rect ? (
<div
className="pointer-events-none absolute border-2 border-sky-300 bg-sky-400/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.28)]"
style={{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
}}
/>
) : null}
</div>
) : null}
</div>
<div className="flex flex-col gap-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-3 text-sm text-white/58">
框选区域后,输入你想改的内容。处理完成的新图会自动加入 AI详情结果区。
</div>
<Textarea
className="min-h-[160px] border-white/10 bg-black/30 text-white placeholder:text-white/30"
disabled={submitting}
maxLength={600}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:把产品区域替换为金属质感,保持背景和文字不变"
value={prompt}
/>
<div className="mt-auto flex justify-end gap-2">
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={submitting}
onClick={onClose}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={submitting}
onClick={handleSubmit}
type="button"
>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
生成局部编辑
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
async function runAgentBoundedParallel<T>( async function runAgentBoundedParallel<T>(
items: T[], items: T[],
workerCount: number, workerCount: number,
@ -1412,6 +1597,13 @@ export function AgentGenerationWorkspaceDarkV2({
useState<AgentGenerationResultModeType>("scene_generation"); useState<AgentGenerationResultModeType>("scene_generation");
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null); const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null); const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [detailLocalEditTarget, setDetailLocalEditTarget] =
useState<DetailResultLocalEditTarget | null>(null);
const [detailLocalEditSubmittingId, setDetailLocalEditSubmittingId] =
useState<string | null>(null);
const [detailEnhancingCardIds, setDetailEnhancingCardIds] = useState<Set<string>>(
() => new Set()
);
const [isPsdTemplateDialogOpen, setIsPsdTemplateDialogOpen] = useState(false); const [isPsdTemplateDialogOpen, setIsPsdTemplateDialogOpen] = useState(false);
const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null); const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main"); const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
@ -4397,6 +4589,136 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange] [activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
); );
const handleEnhanceDetailGenerationResult = useCallback(
async (params: { cardId: string; imageUrl: string }) => {
const cardId = String(params.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
if (!cardId || !/^https?:\/\//i.test(imageUrl)) {
toast.error("详情图地址无效,无法高清处理");
return;
}
if (detailEnhancingCardIds.has(cardId)) return;
setDetailEnhancingCardIds((current) => {
const next = new Set(current);
next.add(cardId);
return next;
});
toast.info("正在高清处理详情图...");
try {
const result = await imageGenerationService.enhanceImage(imageUrl);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "高清处理失败");
}
const nextUrl = result.data.url;
if (!appendResultUrlToCollection("detail_generation", nextUrl)) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
toast.success("高清处理完成,已加入 AI详情结果区");
} catch (error) {
console.error("[AgentGenerationWorkspaceDarkV2] 详情图高清失败:", error);
toast.error(error instanceof Error ? error.message : "高清处理失败,请稍后重试");
} finally {
setDetailEnhancingCardIds((current) => {
const next = new Set(current);
next.delete(cardId);
return next;
});
}
},
[appendResultUrlToCollection, detailEnhancingCardIds]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: {
imageUrl: string;
prompt: string;
rect: DetailResultLocalEditRect;
title: string;
}) => {
const target = detailLocalEditTarget;
const cardId = String(target?.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
if (!target || !cardId || !/^https?:\/\//i.test(imageUrl)) {
toast.error("详情图地址无效,无法局部编辑");
return;
}
if (detailLocalEditSubmittingId) return;
setDetailLocalEditSubmittingId(cardId);
toast.info("正在提交局部编辑...");
try {
const submitResult = await imageGenerationService.localEdit({
aspectRatio: "auto",
imageSize: "2K",
sourceImageUrl: imageUrl,
sourceNodeId: `agent-detail-${cardId}`,
regions: [
{
id: "detail-local-edit-1",
index: 1,
color: "#1677ff",
prompt: params.prompt,
rect: params.rect,
referenceImages: [],
},
],
});
if (!submitResult.success || !submitResult.data?.taskId) {
throw new Error(submitResult.error || "局部编辑提交失败");
}
const deadline = Date.now() + AGENT_IMAGE_TASK_TIMEOUT_MS;
let lastError = "";
while (Date.now() < deadline) {
await sleep(AGENT_IMAGE_TASK_POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(
submitResult.data.taskId
);
const task = taskResult.data?.task;
const status = String(task?.status || "").toLowerCase();
if (!taskResult.success || !task) {
lastError = taskResult.error || "查询局部编辑任务失败";
continue;
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error("局部编辑完成但结果地址无效");
}
if (!appendResultUrlToCollection("detail_generation", nextUrl)) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
setDetailLocalEditTarget(null);
toast.success("局部编辑完成,已加入 AI详情结果区");
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "局部编辑生成失败");
}
}
throw new Error(lastError || "局部编辑任务等待超时,请稍后重试");
} catch (error) {
console.error("[AgentGenerationWorkspaceDarkV2] 详情图局部编辑失败:", error);
toast.error(error instanceof Error ? error.message : "局部编辑失败,请稍后重试");
} finally {
setDetailLocalEditSubmittingId(null);
}
},
[appendResultUrlToCollection, detailLocalEditSubmittingId, detailLocalEditTarget]
);
const handlePsdDerivedImageCreated = useCallback( const handlePsdDerivedImageCreated = useCallback(
(imageUrl: string) => { (imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl); appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
@ -7821,6 +8143,11 @@ export function AgentGenerationWorkspaceDarkV2({
{gridResultCards.map((card) => { {gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url; const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation"; const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard =
activeResultModeType === "detail_generation";
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditing =
detailLocalEditSubmittingId === card.id;
return ( return (
<div <div
@ -7930,7 +8257,7 @@ export function AgentGenerationWorkspaceDarkV2({
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
{activeResultModeType === "detail_generation" ? ( {isDetailGenerationCard ? (
<> <>
<button <button
type="button" type="button"
@ -7942,19 +8269,66 @@ export function AgentGenerationWorkspaceDarkV2({
> >
下载 下载
</button> </button>
<button <div className="absolute bottom-3 left-3 flex max-w-[calc(100%-6.5rem)] flex-wrap gap-2">
type="button" <button
onClick={(event) => { type="button"
event.stopPropagation(); disabled={isDetailLocalEditing}
handleSendResultToCanvasTextEdit({ onClick={(event) => {
imageUrl: card.url!, event.stopPropagation();
title: `AI详情 ${card.index + 1}`, setDetailLocalEditTarget({
}); cardId: card.id,
}} imageUrl: card.url!,
className={cn("absolute bottom-3 left-3", IMAGE_OVERLAY_ACTION_CLASS)} title: `AI详情 ${card.index + 1}`,
> });
送入画布改字 }}
</button> className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailLocalEditing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Wand2 className="h-3.5 w-3.5" />
)}
<span>局部编辑</span>
</button>
<button
type="button"
disabled={isDetailEnhancing}
onClick={(event) => {
event.stopPropagation();
void handleEnhanceDetailGenerationResult({
cardId: card.id,
imageUrl: card.url!,
});
}}
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailEnhancing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
<span>高清</span>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleSendResultToCanvasTextEdit({
imageUrl: card.url!,
title: `AI详情 ${card.index + 1}`,
});
}}
className={IMAGE_OVERLAY_ACTION_CLASS}
>
送入画布改字
</button>
</div>
</> </>
) : null} ) : null}
</> </>
@ -8309,6 +8683,19 @@ export function AgentGenerationWorkspaceDarkV2({
</ClientErrorBoundary> </ClientErrorBoundary>
) : null} ) : null}
<DetailResultLocalEditDialog
submitting={Boolean(detailLocalEditSubmittingId)}
target={detailLocalEditTarget}
onClose={() => {
if (detailLocalEditSubmittingId) {
toast.info("局部编辑正在处理中,请稍候");
return;
}
setDetailLocalEditTarget(null);
}}
onSubmit={handleSubmitDetailLocalEdit}
/>
{/* Fullscreen Preview */} {/* Fullscreen Preview */}
<AnimatePresence> <AnimatePresence>
{fullscreenPreview && ( {fullscreenPreview && (

View File

@ -3,19 +3,23 @@ module.exports = {
{ {
name: "ai-chatbot", name: "ai-chatbot",
script: "node_modules/next/dist/bin/next", script: "node_modules/next/dist/bin/next",
args: "start", args: "start -p 3125 -H 127.0.0.1",
cwd: "/var/www/ai-chatbot", cwd: "/var/www/ai-chatbot",
instances: 1, instances: 1,
exec_mode: "fork", exec_mode: "fork",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
PORT: 3001, PORT: 3125,
}, },
error_file: "/var/log/pm2/ai-chatbot-error.log", error_file: "/var/log/pm2/ai-chatbot-error.log",
out_file: "/var/log/pm2/ai-chatbot-out.log", out_file: "/var/log/pm2/ai-chatbot-out.log",
log_date_format: "YYYY-MM-DD HH:mm:ss Z", log_date_format: "YYYY-MM-DD HH:mm:ss Z",
merge_logs: true, merge_logs: true,
autorestart: true, autorestart: true,
min_uptime: "30s",
max_restarts: 10,
restart_delay: 3000,
kill_timeout: 10000,
max_memory_restart: "3G", max_memory_restart: "3G",
watch: false, watch: false,
ignore_watch: ["node_modules", ".next", ".git"], ignore_watch: ["node_modules", ".next", ".git"],