ai-chatbot/components/agents/AgentGenerationWorkspaceDarkV2.tsx
Your Name 281593ff29
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: align agent workflow dark theme accents
2026-03-18 01:37:44 +08:00

946 lines
37 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 { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
X,
Wand2,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { useCanvasStore } from "@/store/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: ReactNode;
materialSelectionPanel?: ReactNode;
brandModelPanel?: ReactNode;
onClearPreset?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
}
interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
presetPanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset,
siteName,
siteLogo,
siteLogoDark,
}: AgentGenerationWorkspaceDarkV2Props) {
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
[chatId]
);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
imageGenConfig?: any;
}>;
defaultModelId?: string | null;
agentModel?: any;
}>("/api/models", fetcher);
const { data: agentPresetResponse } = useSWR<{
success?: boolean;
data?: AgentPreset;
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
const referenceImages = workflowContext?.referenceImages || [];
const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text");
const agentUploadSlots = useMemo(
() =>
hasAgentUploadSlots
? [
{ slotIndex: 0, label: "产品图1" },
{ slotIndex: 1, label: "产品图2" },
{ slotIndex: 2, label: "模特图" },
]
: [],
[hasAgentUploadSlots]
);
const requiredUploadCount = hasAgentUploadSlots ? 3 : 0;
const selectableModels = useMemo(() => {
const imageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen || model.isMultimodal
);
const autoOption = {
id: "auto",
displayName: "自动选择",
providerType: "auto",
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
return [autoOption, ...imageModels];
}, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]);
const currentModel = useMemo(
() => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0],
[selectableModels, selectedModelId]
);
const { messages, setMessages, sendMessage, status } = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt: hiddenPromptRef.current || undefined,
agentPresetId: selectedAgentPresetId || undefined,
selectedChatModel: selectedModelId,
selectedVisibilityType: "private",
imageParams,
},
};
},
}),
});
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
}
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
// 初始化附件
useEffect(() => {
if (!hasAgentUploadSlots) {
setAttachments([]);
return;
}
const nextSlots: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
() => null
);
const primaryImages = referenceImages.filter((image) => image.source === "primary");
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
image
? { url: image.url, name: image.name || image.label, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined }
: null;
nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]);
nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]);
nextSlots[2] = toAttachment(brandModelImages[0]);
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < 3; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return result;
});
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, 3) },
(_, index) => current[index] ?? null
);
const currentSlot = slots[2] ?? null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (nextAttachment) {
slots[2] = nextAttachment;
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[2] = null;
}
return slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
.map((message) => {
const textParts = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text));
const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
);
return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const latestGeneratedGroup = generatedGroups[generatedGroups.length - 1] || null;
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
return slots;
});
}, [agentUploadSlots.length]);
const handleSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
setSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
updateAttachmentAtSlot(slotIndex, {
url: data.url,
name: file.name,
contentType: file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setSlotUploading(null);
event.currentTarget.value = "";
}
},
[agentUploadSlots, updateAttachmentAtSlot]
);
const handleSend = useCallback(() => {
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
return;
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
attachments.forEach((attachment) => {
if (!attachment) return;
parts.push({ type: "file", mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, assetId: attachment.assetId });
});
let textContent = input.trim();
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
if (textContent) {
parts.push({ type: "text", text: textContent });
}
if (parts.length === 0) {
toast.error("请至少补充要求或上传图片");
return;
}
hiddenPromptRef.current = workflowContext?.promptText
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
: null;
sendMessage({ role: "user", parts });
setInput("");
}, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-black">
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={<div className="w-10" />}
/>
{/* Main Content */}
<main className="flex-1 overflow-y-auto overscroll-contain pt-16 lg:overflow-hidden">
<div className="mx-auto flex min-h-full max-w-[1480px] flex-col px-6 py-6 lg:h-full lg:px-8 xl:px-10">
{/* 步骤指示器 */}
<motion.div
className="mb-6 flex items-center justify-center gap-2 text-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<span className="text-white/40">第一步:风格选择</span>
<span className="text-white/20">→</span>
<span className="text-emerald-400 font-medium">第二步:生图模式</span>
</motion.div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:grid-cols-[400px_minmax(0,1fr)]">
{/* Left Panel - Controls */}
<div className="min-h-0 space-y-4 lg:h-full lg:overflow-y-auto lg:pr-2">
{/* 嵌入的智能体选择面板 */}
{!selectedAgentPreset && presetPanel && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
>
<h3 className="mb-3 text-sm font-semibold text-white">选择生图模式</h3>
<div className="max-h-[480px] overflow-y-auto pr-1">
{presetPanel}
</div>
</motion.div>
)}
{/* 已选智能体信息 */}
{selectedAgentPreset && (
<motion.div
className="rounded-2xl border border-white/12 bg-white/[0.05] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<div className="mb-2 text-xs text-white/40">当前生图模式</div>
<div className="flex items-center gap-3">
{selectedAgentPreset.avatarUrl ? (
<img
src={selectedAgentPreset.avatarUrl}
alt={selectedAgentPreset.name}
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/18">
<Wand2 className="h-5 w-5 text-emerald-300" />
</div>
)}
<div>
<div className="font-medium text-white">{selectedAgentPreset.name}</div>
<div className="text-xs text-white/40">{selectedAgentPreset.type === "image" ? "生图" : "视频"}</div>
</div>
</div>
{presetPanel && onClearPreset && (
<button
onClick={onClearPreset}
className="mt-3 text-xs text-emerald-300 hover:text-emerald-200"
>
更换生图模式 →
</button>
)}
</motion.div>
)}
{selectedAgentPresetId && brandModelPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
{brandModelPanel}
</motion.div>
) : null}
{materialSelectionPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08 }}
>
{materialSelectionPanel}
</motion.div>
) : null}
{/* Image Slots */}
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
图片槽位
</h3>
{agentUploadSlots.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{agentUploadSlots.map((slot, index) => (
<motion.button
key={slot.label}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-xl transition-all",
attachments[index]
? "ring-2 ring-emerald-400/45"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会自动展示产品图1、产品图2和模特图槽位。
</div>
)}
</motion.section>
{/* Prompt Input */}
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Sparkles className="h-4 w-4 text-emerald-300" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
className="min-h-[100px] resize-none rounded-lg border-white/12 bg-black text-sm text-white placeholder:text-white/20 focus:border-emerald-400/30"
/>
</motion.section>
{/* Settings */}
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-emerald-300" />
生成参数
</h3>
<div className="space-y-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-lg border-white/12 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900">
{selectableModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-white">
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
<Button
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
className={cn(
"h-11 w-full gap-2 rounded-lg",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_24px_rgba(16,185,129,0.24)]",
"disabled:opacity-30"
)}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成中...
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
开始生成
</>
)}
</Button>
</div>
</motion.section>
</div>
{/* Right Panel - Results */}
<div className="flex min-h-0 flex-col">
<motion.div
className="relative flex min-h-[500px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white">结果预览</div>
<div className="text-xs text-white/40">
生成完成后会同步回到画布,并在这里保留预览与切换。
</div>
</div>
{allGeneratedImages.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
</div>
) : null}
</div>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 items-center justify-center"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
phase="processing"
/>
</motion.div>
) : displayedResultUrl ? (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative flex flex-1 items-center justify-center p-6"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-lg object-contain"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
<button
onClick={() => {
void handleDownloadOriginalImage(displayedResultUrl);
}}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<Download className="h-4 w-4" />
下载
</button>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 flex-col items-center justify-center text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
</div>
<p className="text-base font-medium text-white/60">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? "配置图片槽位后点击开始生成"
: "从左侧选择一个生图模式后继续"}
</p>
</motion.div>
)}
</AnimatePresence>
{allGeneratedImages.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="border-t border-white/12 bg-black/20 px-4 py-3"
>
<div className="flex gap-2 overflow-x-auto pb-1">
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-xl transition-all",
displayedResultUrl === url
? "ring-2 ring-emerald-400"
: "ring-1 ring-white/12 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 240)}
alt="缩略图"
className="h-20 w-20 object-cover"
/>
</button>
))}
</div>
</motion.div>
) : null}
</motion.div>
</div>
</div>
</div>
</main>
{/* Fullscreen Preview */}
<AnimatePresence>
{fullscreenPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={() => setFullscreenPreview(null)}
>
<img
src={fullscreenPreview}
alt="预览"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => setFullscreenPreview(null)}
className="absolute right-6 top-6 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<X className="h-6 w-6" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}