ai-chatbot/components/agents/AgentGenerationWorkspaceNew.tsx
Your Name f18f9733df
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: route image presets through direct image mode
2026-03-24 16:42:22 +08:00

916 lines
32 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 } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
X,
ZoomIn,
Wand2,
} 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 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
type="button"
onClick={onClick}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-2xl transition-all duration-300",
attachment
? "ring-2 ring-indigo-500/20"
: "border-2 border-dashed border-slate-200 hover:border-indigo-400/50"
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileChange}
/>
{attachment ? (
<>
<img
src={buildPreviewImageUrl(attachment.url, 256)}
alt={attachment.name || slot.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{/* 遮罩层 */}
<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
type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-slate-600 shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
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="text-[11px] font-medium">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
);
}
// 结果缩略图组件
function ResultThumbnail({
url,
isSelected,
onClick,
}: {
url: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<motion.button
type="button"
onClick={onClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
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"
)}
>
<img
src={buildPreviewImageUrl(url, 220)}
alt="结果缩略图"
className="h-20 w-20 object-cover"
loading="lazy"
decoding="async"
/>
</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) => 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-b border-slate-200/60 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="text-sm font-semibold text-slate-900">生图工作台</h3>
</div>
<p className="mt-1 text-xs text-slate-500">
补齐图片槽位后补充要求并生成
</p>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* 左侧控制面板 */}
<div className="w-[340px] shrink-0 overflow-y-auto border-r border-slate-200/60 p-5">
{/* 图片槽位 */}
<section className="mb-6">
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-800">
<ImagePlus className="h-4 w-4 text-indigo-500" />
图片槽位
</h4>
<div className="grid grid-cols-3 gap-3">
{agentUploadSlots.map((slot, index) => (
<ImageSlot
key={slot.label}
slot={slot}
index={index}
attachment={attachments[index]}
isUploading={slotUploading === index}
onClick={() => fileInputRefs.current[index]?.click()}
onRemove={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
fileInputRef={(el) => (fileInputRefs.current[index] = el)}
onFileChange={(e) => handleSlotFileChange(index, e)}
/>
))}
</div>
</section>
{/* 补充要求 */}
<section className="mb-6">
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-800">
<Sparkles className="h-4 w-4 text-indigo-500" />
补充要求
</h4>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="min-h-[120px] resize-none rounded-xl border-slate-200/60 text-sm"
placeholder="例如:高级家居电商质感,暖灰色空间,近景主图,生成 3 张。"
/>
</section>
{/* 参数设置 */}
<section className="mb-6">
<h4 className="mb-3 text-sm font-semibold text-slate-800">生成参数</h4>
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
hideAdvancedOptions={false}
/>
</section>
{/* 模型选择与生成按钮 */}
<section className="flex items-center gap-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<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
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
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"
>
{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
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex 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 h-full w-full items-center justify-center p-6"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-xl object-contain shadow-lg"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
{/* 下载按钮 */}
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
onClick={() => handleDownloadOriginalImage(displayedResultUrl)}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-xl bg-white/90 px-4 text-sm font-medium text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white"
>
<Download className="h-4 w-4" />
下载
</motion.button>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center"
>
<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="text-lg font-semibold text-slate-800">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</h4>
<p className="mt-2 max-w-sm text-sm text-slate-500">
{selectedAgentPresetId
? "补齐图片槽位后点击开始生成,结果会显示在这里。"
: "选择生图模式后,再从左侧获取图片并开始生成。"}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 结果缩略图 */}
{allGeneratedImages.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 rounded-xl bg-white p-4 shadow-sm dark:bg-slate-950"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-800">历史结果</span>
<span className="text-xs text-slate-500">共 {allGeneratedImages.length} 张</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-2">
{allGeneratedImages.map((url) => (
<ResultThumbnail
key={url}
url={url}
isSelected={displayedResultUrl === url}
onClick={() => setSelectedResultUrl(url)}
/>
))}
</div>
</motion.div>
)}
</div>
</div>
{/* 全屏预览 */}
<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-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-sm transition-colors hover:bg-white/20"
>
<X className="h-5 w-5" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}