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

751 lines
26 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,
Play,
Settings2,
Sparkles,
Upload,
Wand2,
X,
} 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 AgentGenerationWorkspaceDarkProps {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
}
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" });
}
export function AgentGenerationWorkspaceDark({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
}: AgentGenerationWorkspaceDarkProps) {
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 [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 || undefined,
selectedChatModel: transportState.selectedModelId,
selectedVisibilityType: "private",
imageParams: transportState.imageParams,
},
};
},
}),
});
// 初始化附件
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]);
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: textParts
.join("\n\n")
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
.trim(),
};
})
.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]);
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,
]);
return (
<div className="min-h-screen bg-black">
{/* Header */}
<header className="sticky top-0 z-50 border-white/5 border-b bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-8">
<div className="flex h-20 items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="font-bold text-white text-xl">生图工作台</h1>
<p className="text-white/40 text-xs">配置图片槽位并开始生成</p>
</div>
</div>
<div className="flex items-center gap-3">
{onBack && (
<Button
className="h-10 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
onClick={onBack}
variant="ghost"
>
返回工作流
</Button>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-[1800px] px-8 py-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[380px_1fr]">
{/* Left Panel - Controls */}
<div className="space-y-6">
{/* Image Slots */}
<section className="rounded-2xl border border-white/5 bg-white/[0.02] p-6">
<h3 className="mb-4 flex items-center gap-2 font-semibold text-sm text-white">
<ImagePlus className="h-4 w-4 text-violet-400" />
图片槽位
</h3>
<div className="grid grid-cols-3 gap-3">
{agentUploadSlots.map((slot, index) => (
<motion.button
animate={{ opacity: 1, scale: 1 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-xl",
attachments[index]
? "ring-2 ring-violet-500/30"
: "border-2 border-white/10 border-dashed hover:border-violet-500/30"
)}
initial={{ opacity: 0, scale: 0.9 }}
key={slot.label}
onClick={() => fileInputRefs.current[index]?.click()}
transition={{ delay: index * 0.1 }}
type="button"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<input
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
/>
{attachments[index] ? (
<>
<img
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
src={buildPreviewImageUrl(
attachments[index]!.url,
256
)}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1.5 text-[10px] text-white">
{slot.label}
</div>
<button
className="absolute top-1 right-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);
}}
type="button"
>
<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-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
</section>
{/* Prompt Input */}
<section className="rounded-2xl border border-white/5 bg-white/[0.02] p-6">
<h3 className="mb-4 flex items-center gap-2 font-semibold text-sm text-white">
<Sparkles className="h-4 w-4 text-violet-400" />
补充要求
</h3>
<Textarea
className="min-h-[120px] resize-none rounded-xl border-white/5 bg-black text-sm text-white placeholder:text-white/20 focus:border-violet-500/30"
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
value={input}
/>
</section>
{/* Model & Generate */}
<section className="rounded-2xl border border-white/5 bg-white/[0.02] p-6">
<h3 className="mb-4 flex items-center gap-2 font-semibold text-sm text-white">
<Settings2 className="h-4 w-4 text-violet-400" />
生成设置
</h3>
<div className="space-y-4">
<Select
onValueChange={setSelectedModelId}
value={selectedModelId}
>
<SelectTrigger className="h-11 rounded-xl border-white/5 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900">
{selectableModels.map((model) => (
<SelectItem
className="text-white"
key={model.id}
value={model.id}
>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<ImageParamsBar
imageGenConfig={currentModel?.imageGenConfig || undefined}
initialParams={imageParams}
inline
modelName={currentModel?.displayName}
onParamsChange={setImageParams}
providerType={currentModel?.providerType}
/>
<Button
className={cn(
"h-12 w-full gap-2 rounded-xl",
"bg-gradient-to-r from-violet-600 to-purple-600",
"font-medium text-white",
"hover:from-violet-500 hover:to-purple-500",
"disabled:opacity-30"
)}
disabled={isGenerating || !selectedAgentPresetId}
onClick={handleSend}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成中...
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
开始生成
</>
)}
</Button>
</div>
</section>
</div>
{/* Right Panel - Results */}
<div className="flex flex-col">
{/* Main Display */}
<div className="relative min-h-[500px] flex-1 overflow-hidden rounded-2xl border border-white/5 bg-white/[0.02]">
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
animate={{ opacity: 1 }}
className="flex h-full 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 items-center justify-center p-8"
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"
onClick={() => setFullscreenPreview(displayedResultUrl)}
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
/>
<button
className="absolute right-6 bottom-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"
onClick={() => setFullscreenPreview(displayedResultUrl)}
>
<Download className="h-4 w-4" />
下载
</button>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1 }}
className="flex h-full flex-col items-center justify-center text-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="empty"
>
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-3xl bg-white/5">
<Wand2 className="h-10 w-10 text-white/20" />
</div>
<p className="font-semibold text-lg text-white/60">
{selectedAgentPresetId
? "准备开始生成"
: "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? "配置图片槽位后点击开始生成"
: "从左侧选择或创建一个风格化"}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Thumbnails */}
{allGeneratedImages.length > 0 && (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex gap-3 overflow-x-auto pb-2"
initial={{ opacity: 0, y: 20 }}
>
{allGeneratedImages.map((url) => (
<button
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-xl transition-all",
selectedResultUrl === url
? "ring-2 ring-violet-500"
: "ring-1 ring-white/10 hover:ring-white/30"
)}
key={url}
onClick={() => setSelectedResultUrl(url)}
>
<img
alt="缩略图"
className="h-20 w-20 object-cover"
src={buildPreviewImageUrl(url, 200)}
/>
</button>
))}
</motion.div>
)}
</div>
</div>
</main>
{/* Fullscreen Preview */}
<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-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition-colors hover:bg-white/20"
onClick={() => setFullscreenPreview(null)}
>
<X className="h-6 w-6" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}