feat: enhance detail result image actions
This commit is contained in:
parent
fe18aa0000
commit
801d2c4644
@ -67,6 +67,7 @@ import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
@ -218,6 +219,19 @@ type BatchDownloadDialogState = {
|
||||
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_HIDDEN_PROMPT_LENGTH = 4000;
|
||||
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
||||
@ -700,6 +714,177 @@ function sleep(ms: number) {
|
||||
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>(
|
||||
items: T[],
|
||||
workerCount: number,
|
||||
@ -1412,6 +1597,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [fullscreenPreview, setFullscreenPreview] = 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 [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
|
||||
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
|
||||
@ -4397,6 +4589,136 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[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(
|
||||
(imageUrl: string) => {
|
||||
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
|
||||
@ -7821,6 +8143,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
{gridResultCards.map((card) => {
|
||||
const isActive = card.url && displayedResultUrl === card.url;
|
||||
const isSceneResultCard = activeResultModeType === "scene_generation";
|
||||
const isDetailGenerationCard =
|
||||
activeResultModeType === "detail_generation";
|
||||
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
|
||||
const isDetailLocalEditing =
|
||||
detailLocalEditSubmittingId === card.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -7930,7 +8257,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{activeResultModeType === "detail_generation" ? (
|
||||
{isDetailGenerationCard ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@ -7942,19 +8269,66 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSendResultToCanvasTextEdit({
|
||||
imageUrl: card.url!,
|
||||
title: `AI详情 ${card.index + 1}`,
|
||||
});
|
||||
}}
|
||||
className={cn("absolute bottom-3 left-3", IMAGE_OVERLAY_ACTION_CLASS)}
|
||||
>
|
||||
送入画布改字
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-3 flex max-w-[calc(100%-6.5rem)] flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDetailLocalEditing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDetailLocalEditTarget({
|
||||
cardId: card.id,
|
||||
imageUrl: card.url!,
|
||||
title: `AI详情 ${card.index + 1}`,
|
||||
});
|
||||
}}
|
||||
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}
|
||||
</>
|
||||
@ -8309,6 +8683,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</ClientErrorBoundary>
|
||||
) : null}
|
||||
|
||||
<DetailResultLocalEditDialog
|
||||
submitting={Boolean(detailLocalEditSubmittingId)}
|
||||
target={detailLocalEditTarget}
|
||||
onClose={() => {
|
||||
if (detailLocalEditSubmittingId) {
|
||||
toast.info("局部编辑正在处理中,请稍候");
|
||||
return;
|
||||
}
|
||||
setDetailLocalEditTarget(null);
|
||||
}}
|
||||
onSubmit={handleSubmitDetailLocalEdit}
|
||||
/>
|
||||
|
||||
{/* Fullscreen Preview */}
|
||||
<AnimatePresence>
|
||||
{fullscreenPreview && (
|
||||
|
||||
@ -3,19 +3,23 @@ module.exports = {
|
||||
{
|
||||
name: "ai-chatbot",
|
||||
script: "node_modules/next/dist/bin/next",
|
||||
args: "start",
|
||||
args: "start -p 3125 -H 127.0.0.1",
|
||||
cwd: "/var/www/ai-chatbot",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 3001,
|
||||
PORT: 3125,
|
||||
},
|
||||
error_file: "/var/log/pm2/ai-chatbot-error.log",
|
||||
out_file: "/var/log/pm2/ai-chatbot-out.log",
|
||||
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
|
||||
merge_logs: true,
|
||||
autorestart: true,
|
||||
min_uptime: "30s",
|
||||
max_restarts: 10,
|
||||
restart_delay: 3000,
|
||||
kill_timeout: 10000,
|
||||
max_memory_restart: "3G",
|
||||
watch: false,
|
||||
ignore_watch: ["node_modules", ".next", ".git"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user