ai-chatbot/components/agents/AgentGenerationWorkspaceNew.tsx
2026-05-26 19:32:19 +08:00

996 lines
33 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 { AnimatePresence, motion } from "framer-motion";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
Wand2,
X,
ZoomIn,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import {
type ImageGenerationParams,
ImageParamsBar,
} from "@/components/image-params-bar";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
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, fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { useCanvasStore } from "@/store/canvas";
import type {
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
interface AgentGenerationWorkspaceNewProps {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | 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 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;
});
}
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 ImageSlot({
slot,
index,
attachment,
isUploading,
onClick,
onRemove,
fileInputRef,
onFileChange,
}: {
slot: { slotIndex: number; label: string };
index: number;
attachment: PickedAttachment | null;
isUploading: boolean;
onClick: () => void;
onRemove: (e: React.MouseEvent) => void;
fileInputRef: (el: HTMLInputElement | null) => void;
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<motion.button
animate={{ opacity: 1, scale: 1 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-2xl transition-all duration-300",
attachment
? "ring-2 ring-indigo-500/20"
: "border-2 border-slate-200 border-dashed hover:border-indigo-400/50"
)}
initial={{ opacity: 0, scale: 0.9 }}
onClick={onClick}
transition={{ duration: 0.3, delay: index * 0.05 }}
type="button"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
accept="image/*"
className="hidden"
onChange={onFileChange}
ref={fileInputRef}
type="file"
/>
{attachment ? (
<>
<img
alt={attachment.name || slot.label}
className="h-full w-full object-cover"
decoding="async"
loading="lazy"
src={buildPreviewImageUrl(attachment.url, 256)}
/>
{/* 遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
{/* 标签 */}
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1.5 text-[11px] text-white">
{slot.label}
</div>
{/* 删除按钮 */}
<motion.button
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-slate-600 opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
onClick={onRemove}
type="button"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<X className="h-3 w-3" />
</motion.button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-400">
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
) : (
<>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-500">
<Upload className="h-5 w-5" />
</div>
<span className="font-medium text-[11px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
);
}
// 结果缩略图组件
function ResultThumbnail({
url,
isSelected,
onClick,
}: {
url: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<motion.button
className={cn(
"relative overflow-hidden rounded-xl transition-all duration-200",
isSelected
? "ring-2 ring-indigo-500 ring-offset-2"
: "ring-1 ring-slate-200 hover:ring-indigo-400/50"
)}
onClick={onClick}
type="button"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<img
alt="结果缩略图"
className="h-20 w-20 object-cover"
decoding="async"
loading="lazy"
src={buildPreviewImageUrl(url, 220)}
/>
</motion.button>
);
}
export function AgentGenerationWorkspaceNew({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
}: AgentGenerationWorkspaceNewProps) {
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: NonNullable<typeof modelsData>["models"][number]) =>
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 transportStateRef = useRef<{
selectedAgentPresetId: string | null;
selectedModelId: string;
imageParams: ImageGenerationParams;
}>({
selectedAgentPresetId: null,
selectedModelId: "auto",
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
});
transportStateRef.current = {
selectedAgentPresetId: selectedAgentPresetId || null,
selectedModelId,
imageParams,
};
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) {
const transportState = transportStateRef.current;
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt: hiddenPromptRef.current || undefined,
agentPresetId:
transportState.selectedAgentPresetId ||
selectedAgentPresetId ||
undefined,
selectedChatModel:
transportState.selectedModelId !== "auto"
? transportState.selectedModelId
: selectedAgentPreset?.defaultModelId ||
transportState.selectedModelId,
composerMode:
selectedAgentPreset?.type === "image"
? ("image" as const)
: ("agent" as const),
selectedVisibilityType: "private",
imageParams: transportState.imageParams,
contextPayload: {
source: "chat" as const,
projectId,
},
},
};
},
}),
});
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(
"[AgentGenerationWorkspace] 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-full min-h-0 flex-col bg-white dark:bg-slate-950">
{/* 头部 */}
<div className="border-slate-200/60 border-b px-6 py-4">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-white">
<Wand2 className="h-3.5 w-3.5" />
</div>
<h3 className="font-semibold text-slate-900 text-sm">生图工作台</h3>
</div>
<p className="mt-1 text-slate-500 text-xs">
补齐图片槽位后补充要求并生成
</p>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* 左侧控制面板 */}
<div className="w-[340px] shrink-0 overflow-y-auto border-slate-200/60 border-r p-5">
{/* 图片槽位 */}
<section className="mb-6">
<h4 className="mb-3 flex items-center gap-2 font-semibold text-slate-800 text-sm">
<ImagePlus className="h-4 w-4 text-indigo-500" />
图片槽位
</h4>
<div className="grid grid-cols-3 gap-3">
{agentUploadSlots.map((slot, index) => (
<ImageSlot
attachment={attachments[index]}
fileInputRef={(el) => {
fileInputRefs.current[index] = el;
}}
index={index}
isUploading={slotUploading === index}
key={slot.label}
onClick={() => fileInputRefs.current[index]?.click()}
onFileChange={(e) => handleSlotFileChange(index, e)}
onRemove={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
slot={slot}
/>
))}
</div>
</section>
{/* 补充要求 */}
<section className="mb-6">
<h4 className="mb-3 flex items-center gap-2 font-semibold text-slate-800 text-sm">
<Sparkles className="h-4 w-4 text-indigo-500" />
补充要求
</h4>
<Textarea
className="min-h-[120px] resize-none rounded-xl border-slate-200/60 text-sm"
onChange={(e) => setInput(e.target.value)}
placeholder="例如:高级家居电商质感,暖灰色空间,近景主图,生成 3 张。"
value={input}
/>
</section>
{/* 参数设置 */}
<section className="mb-6">
<h4 className="mb-3 font-semibold text-slate-800 text-sm">
生成参数
</h4>
<ImageParamsBar
hideAdvancedOptions={false}
imageGenConfig={currentModel?.imageGenConfig || undefined}
initialParams={imageParams}
inline
modelName={currentModel?.displayName}
onParamsChange={setImageParams}
providerType={currentModel?.providerType}
/>
</section>
{/* 模型选择与生成按钮 */}
<section className="flex items-center gap-3">
<Select onValueChange={setSelectedModelId} value={selectedModelId}>
<SelectTrigger className="h-10 flex-1 rounded-xl border-slate-200/60">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{selectableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="h-10 gap-1.5 rounded-xl bg-slate-900 px-5 text-white shadow-lg shadow-slate-900/20 hover:bg-slate-800"
disabled={isGenerating || !selectedAgentPresetId}
onClick={handleSend}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成中
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
生成
</>
)}
</Button>
</section>
</div>
{/* 右侧结果区域 */}
<div className="flex min-w-0 flex-1 flex-col bg-slate-50/50 p-5 dark:bg-slate-900/30">
{/* 主结果展示 */}
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-2xl bg-white shadow-sm dark:bg-slate-950">
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
animate={{ opacity: 1 }}
className="flex items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="loading"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
modelName={currentModel?.displayName}
phase="processing"
providerType={currentModel?.providerType}
/>
</motion.div>
) : displayedResultUrl ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="relative flex h-full w-full items-center justify-center p-6"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="result"
>
<img
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-xl object-contain shadow-lg"
onClick={() => setFullscreenPreview(displayedResultUrl)}
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
/>
{/* 下载按钮 */}
<motion.button
animate={{ opacity: 1, y: 0 }}
className="absolute right-6 bottom-6 flex h-10 items-center gap-2 rounded-xl bg-white/90 px-4 font-medium text-slate-700 text-sm shadow-lg backdrop-blur-sm transition-colors hover:bg-white"
initial={{ opacity: 0, y: 10 }}
onClick={() =>
handleDownloadOriginalImage(displayedResultUrl)
}
transition={{ delay: 0.3 }}
>
<Download className="h-4 w-4" />
下载
</motion.button>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1 }}
className="text-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="empty"
>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-500">
<Sparkles className="h-7 w-7" />
</div>
<h4 className="font-semibold text-lg text-slate-800">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</h4>
<p className="mt-2 max-w-sm text-slate-500 text-sm">
{selectedAgentPresetId
? "补齐图片槽位后点击开始生成,结果会显示在这里。"
: "选择生图模式后,再从左侧获取图片并开始生成。"}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 结果缩略图 */}
{allGeneratedImages.length > 0 && (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="mt-4 rounded-xl bg-white p-4 shadow-sm dark:bg-slate-950"
initial={{ opacity: 0, y: 20 }}
>
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-slate-800 text-sm">
历史结果
</span>
<span className="text-slate-500 text-xs">
共 {allGeneratedImages.length} 张
</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-2">
{allGeneratedImages.map((url) => (
<ResultThumbnail
isSelected={displayedResultUrl === url}
key={url}
onClick={() => setSelectedResultUrl(url)}
url={url}
/>
))}
</div>
</motion.div>
)}
</div>
</div>
{/* 全屏预览 */}
<AnimatePresence>
{fullscreenPreview && (
<motion.div
animate={{ opacity: 1 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onClick={() => setFullscreenPreview(null)}
>
<img
alt="预览大图"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(e) => e.stopPropagation()}
src={fullscreenPreview}
/>
<button
className="absolute top-6 right-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-sm transition-colors hover:bg-white/20"
onClick={() => setFullscreenPreview(null)}
>
<X className="h-5 w-5" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}