316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useRef, useState } from "react";
|
||
import { LayoutTemplate, Loader2, Upload } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import { imageGenerationService } from "@/services/canvas";
|
||
|
||
const POLL_INTERVAL_MS = 2500;
|
||
const POLL_LIMIT = 240;
|
||
|
||
export type AgentAiTemplateResult = {
|
||
imageUrl: string;
|
||
sourceImageUrl: string;
|
||
referenceImageUrl: string;
|
||
};
|
||
|
||
type AgentAiTemplateDialogProps = {
|
||
open: boolean;
|
||
sourceImageUrl?: string | null;
|
||
model?: string | null;
|
||
imageSize?: string | null;
|
||
projectId: string;
|
||
onOpenChange: (open: boolean) => void;
|
||
onProcessingChange?: (processing: boolean, sourceImageUrl: string) => void;
|
||
onResultCreated: (result: AgentAiTemplateResult) => void | Promise<void>;
|
||
};
|
||
|
||
function isHttpImageUrl(value?: string | null) {
|
||
return /^https?:\/\//i.test(String(value || "").trim());
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function pollAiTemplateTask(taskId: string) {
|
||
for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) {
|
||
await sleep(POLL_INTERVAL_MS);
|
||
const taskResult = await imageGenerationService.getImageTask(taskId);
|
||
const task = taskResult.data?.task;
|
||
if (!taskResult.success || !task) {
|
||
continue;
|
||
}
|
||
|
||
const status = String(task.status || "").toLowerCase();
|
||
if (status === "succeeded" || status === "completed") {
|
||
const image = task.result?.images?.[0];
|
||
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
|
||
if (!isHttpImageUrl(imageUrl)) {
|
||
throw new Error("AI套模版已完成但未返回结果图片");
|
||
}
|
||
return imageUrl;
|
||
}
|
||
|
||
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||
throw new Error(task.error || "AI套模版生成失败");
|
||
}
|
||
}
|
||
|
||
throw new Error("AI套模版任务等待超时,请稍后在结果区查看");
|
||
}
|
||
|
||
export function AgentAiTemplateDialog({
|
||
open,
|
||
sourceImageUrl,
|
||
model,
|
||
imageSize,
|
||
projectId,
|
||
onOpenChange,
|
||
onProcessingChange,
|
||
onResultCreated,
|
||
}: AgentAiTemplateDialogProps) {
|
||
const referenceUploadId = useId();
|
||
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const normalizedSourceUrl = String(sourceImageUrl || "").trim();
|
||
const onProcessingChangeRef = useRef(onProcessingChange);
|
||
const onResultCreatedRef = useRef(onResultCreated);
|
||
|
||
useEffect(() => {
|
||
onProcessingChangeRef.current = onProcessingChange;
|
||
onResultCreatedRef.current = onResultCreated;
|
||
}, [onProcessingChange, onResultCreated]);
|
||
|
||
useEffect(() => {
|
||
if (!open || isSubmitting) return;
|
||
setReferenceImageUrl("");
|
||
setSupplementalPrompt("");
|
||
setIsUploading(false);
|
||
}, [isSubmitting, open, normalizedSourceUrl]);
|
||
|
||
const handleReferenceUpload = async (file?: File) => {
|
||
if (!file) return;
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const payload = await response.json().catch(() => null);
|
||
const uploadedUrl = String(payload?.url || "").trim();
|
||
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
|
||
throw new Error(payload?.error || "参考图上传失败");
|
||
}
|
||
setReferenceImageUrl(uploadedUrl);
|
||
toast.success("版式参考图已上传");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "参考图上传失败");
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
const normalizedReferenceUrl = referenceImageUrl.trim();
|
||
if (!isHttpImageUrl(normalizedSourceUrl)) {
|
||
toast.error("当前主图结果地址不可用");
|
||
return;
|
||
}
|
||
if (!isHttpImageUrl(normalizedReferenceUrl)) {
|
||
toast.error("请先上传或填写版式参考图");
|
||
return;
|
||
}
|
||
if (isSubmitting) return;
|
||
|
||
setIsSubmitting(true);
|
||
onProcessingChangeRef.current?.(true, normalizedSourceUrl);
|
||
try {
|
||
const submitResult = await imageGenerationService.layoutReplace({
|
||
sourceImageUrl: normalizedSourceUrl,
|
||
referenceImageUrl: normalizedReferenceUrl,
|
||
supplementalPrompt: supplementalPrompt.trim() || undefined,
|
||
model: model && model !== "auto" ? model : undefined,
|
||
imageSize: imageSize || "2K",
|
||
agentProjectId: projectId,
|
||
referenceLabel: "风格化主图 AI 套模版参考图",
|
||
debugMeta: {
|
||
operation: "agent-main-ai-template",
|
||
source: "agent-main-result-card",
|
||
},
|
||
});
|
||
const taskId = String(submitResult.data?.taskId || "").trim();
|
||
if (!submitResult.success || !taskId) {
|
||
throw new Error(submitResult.error || "AI套模版提交失败");
|
||
}
|
||
|
||
onOpenChange(false);
|
||
toast.success("AI套模版已进入生图队列");
|
||
const resultImageUrl = await pollAiTemplateTask(taskId);
|
||
await onResultCreatedRef.current({
|
||
imageUrl: resultImageUrl,
|
||
sourceImageUrl: normalizedSourceUrl,
|
||
referenceImageUrl: normalizedReferenceUrl,
|
||
});
|
||
toast.success("AI套模版结果已加入主图结果区");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "AI套模版生成失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
onProcessingChangeRef.current?.(false, normalizedSourceUrl);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(nextOpen) => {
|
||
if (!isUploading) {
|
||
onOpenChange(nextOpen);
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[780px] flex-col gap-0 overflow-hidden border-white/10 bg-[#09090b] p-0 text-white sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
||
<DialogHeader className="shrink-0 border-b border-white/10 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
||
<DialogTitle className="flex items-center gap-2 text-base">
|
||
<LayoutTemplate className="h-4 w-4" />
|
||
AI套模版
|
||
</DialogTitle>
|
||
<DialogDescription className="text-white/55">
|
||
保留参考图的版式、文字和图标,仅将产品主体替换为当前主图。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="min-w-0 space-y-2">
|
||
<Label>当前主图</Label>
|
||
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-zinc-900">
|
||
{isHttpImageUrl(normalizedSourceUrl) ? (
|
||
<AgentPreviewImage
|
||
alt="当前主图结果"
|
||
className="max-h-full max-w-full object-contain"
|
||
eager
|
||
previewQuality={86}
|
||
previewWidth={720}
|
||
src={normalizedSourceUrl}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 space-y-2">
|
||
<Label htmlFor={referenceUploadId}>版式参考图</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="min-w-0 border-white/10 bg-white/[0.04]"
|
||
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||
placeholder="填写图片链接或上传"
|
||
value={referenceImageUrl}
|
||
/>
|
||
<Button
|
||
asChild
|
||
className="shrink-0 border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||
variant="outline"
|
||
>
|
||
<label htmlFor={referenceUploadId}>
|
||
{isUploading ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
)}
|
||
上传
|
||
</label>
|
||
</Button>
|
||
<input
|
||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="sr-only"
|
||
disabled={isUploading || isSubmitting}
|
||
id={referenceUploadId}
|
||
onChange={(event) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
event.currentTarget.value = "";
|
||
void handleReferenceUpload(file);
|
||
}}
|
||
type="file"
|
||
/>
|
||
</div>
|
||
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/10 bg-zinc-900">
|
||
{isUploading ? (
|
||
<Loader2 className="h-5 w-5 animate-spin text-white/45" />
|
||
) : isHttpImageUrl(referenceImageUrl) ? (
|
||
<AgentPreviewImage
|
||
alt="AI套模版参考图"
|
||
className="max-h-full max-w-full object-contain"
|
||
eager
|
||
previewQuality={86}
|
||
previewWidth={720}
|
||
src={referenceImageUrl}
|
||
/>
|
||
) : (
|
||
<LayoutTemplate className="h-7 w-7 text-white/20" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-2">
|
||
<Label htmlFor={`${referenceUploadId}-prompt`}>补充要求</Label>
|
||
<Textarea
|
||
className="min-h-[84px] max-h-[180px] border-white/10 bg-white/[0.04]"
|
||
id={`${referenceUploadId}-prompt`}
|
||
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||
placeholder="可选:补充产品材质、颜色或替换细节"
|
||
value={supplementalPrompt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="shrink-0 gap-2 border-t border-white/10 bg-black/25 px-4 py-3 sm:px-6">
|
||
<Button
|
||
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.10] hover:text-white"
|
||
disabled={isUploading}
|
||
onClick={() => onOpenChange(false)}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
disabled={isSubmitting || isUploading || !isHttpImageUrl(referenceImageUrl)}
|
||
onClick={() => void handleSubmit()}
|
||
type="button"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSubmitting ? "提交中" : "开始AI套模版"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|