feat: refresh agent workflow dark ui
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-18 00:24:32 +08:00
parent 2e21d1b274
commit f764767ba4
11 changed files with 3517 additions and 168 deletions

View File

@ -66,6 +66,10 @@ import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGeneration
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
// 深色主题组件 V2 (新版布局)
import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2";
import { AgentWorkflowPanelDarkV2 } from "@/components/agents/AgentWorkflowPanelDarkV2";
import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
@ -992,175 +996,52 @@ export default function CanvasEditorPage({ params }: PageProps) {
) : null;
if (isAgentMode) {
// 深色主题智能体模式 V2 - 完整功能版本
// 第一步:工作流配置(品牌/店铺/步骤选择)
if (currentAgentStage === "workflow") {
return (
<AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
onNextStep={() => setCurrentAgentStage("mode")}
onSkipStep={() => setCurrentAgentStage("mode")}
onReset={() => {
setWorkflowInputs({ primarySku: "", secondarySku: "", brand: "", store: "" });
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setCurrentAgentStage("workflow");
}}
/>
);
}
// 第二步:智能体选择 + 生图工作台
return (
<TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden bg-background">
{brandEntry}
{agentDisabledNotice}
<div className="absolute left-0 right-0 top-[72px] z-10 border-t border-black/10 dark:border-white/10" />
{currentAgentStage === "workflow" ? (
<div className="absolute inset-0 pt-[72px]">
<div className="h-full bg-white dark:bg-zinc-950">
<AgentWorkflowPanelNew
embedded
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
onNextStep={() => setCurrentAgentStage("mode")}
onSkipStep={() => setCurrentAgentStage("mode")}
onReset={() => {
setWorkflowInputs({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setCurrentAgentStage("workflow");
}}
/>
</div>
</div>
) : (
<div className="absolute inset-0 flex pt-[72px]">
<div className="flex h-full w-full bg-[#f5f7fb] dark:bg-zinc-950">
<div className="w-[520px] shrink-0 border-r bg-white dark:bg-zinc-950">
<div className="flex h-full flex-col">
<div className="border-b p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Wand2 className="h-4 w-4 text-amber-500" />
生图模式
</div>
<div className="mt-1 text-xs text-muted-foreground">
先在本页选择生图模式,再按品牌选择模特图并搜索 SKU / SPU 图片,右侧全屏工作区会自动进入对应图片槽位并展示结果。
</div>
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
第 2 步 / 2 步
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentAgentStage("workflow")}
>
<ArrowLeft className="mr-1 h-4 w-4" />
返回上一步
</Button>
</div>
{!workflowProgress.isComplete ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
第一步尚未完成,你也可以在当前直接继续生图模式选择。
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-4 p-4">
<AgentWorkflowBrandModelPicker
brand={workflowInputs.brand}
selectedBrandModel={selectedBrandModel}
onSelectBrandModel={setSelectedBrandModel}
/>
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
title="SKU / SPU 获取图片"
description="第二步直接在这里搜索主 SKU / SPU 与搭配 SKU 图片,选中的图片会自动进入右侧前两个产品槽位。"
disabled={!selectedAgentPresetId}
disabledHint="请先选择一个生图模式,系统才能把你选中的图片自动送入对应生图槽位。"
/>
<AgentPresetPanelNew
embedded
layout="inline"
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
title="生图模式"
description="先选择生图模式,再在上方搜索 SKU / SPU 图片并自动进入对应槽位"
searchPlaceholder="搜索生图模式"
publicTitle="公共生图模式"
privateTitle="我的生图模式"
/>
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">执行工作区</div>
<div className="mt-1 text-xs text-muted-foreground">
这里不再是侧边对话框,而是全屏生图工作台。生成结果、槽位操作和补充指令都在这里完成。
</div>
</div>
<div className="flex items-center gap-2">
{selectedAgentPresetId ? (
<div className="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-medium text-foreground shadow-sm dark:bg-zinc-900">
当前模式已选
</div>
) : (
<div className="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs text-muted-foreground shadow-sm dark:bg-zinc-900">
请选择左侧生图模式
</div>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-tl-[24px] border-t border-l bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] dark:border-white/10 dark:bg-zinc-950">
{selectedAgentPresetId ? (
<ClientErrorBoundary
fallback={
<div className="flex h-full items-center justify-center p-8">
<div className="w-full max-w-xl rounded-[28px] border border-dashed bg-[#f8fafc] p-8 text-center dark:bg-zinc-900/50">
<div className="text-xl font-semibold">工作台加载失败</div>
<div className="mt-3 text-sm leading-6 text-muted-foreground">
当前生图工作台发生了前端异常,我已经切换到安全兜底状态。你可以先返回上一步再重新进入,或者告诉我继续修复。
</div>
</div>
</div>
}
>
<AgentGenerationWorkspaceNew
chatId={`canvas-${id}`}
projectId={id}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
/>
</ClientErrorBoundary>
) : (
<div className="flex h-full items-center justify-center p-8">
<div className="w-full max-w-xl rounded-[28px] border border-dashed bg-[#f8fafc] p-8 text-center dark:bg-zinc-900/50">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-3xl bg-[#eef2ff] text-[#4f46e5]">
<Wand2 className="h-7 w-7" />
</div>
<div className="text-2xl font-semibold">请先选择左侧生图模式</div>
<div className="mt-3 text-sm leading-6 text-muted-foreground">
选择生图模式后,这里才会加载独立的生图工作台,并显示图片槽位、补充要求和生成结果。
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</TooltipProvider>
<AgentGenerationWorkspaceDarkV2
chatId={`canvas-${id}`}
projectId={id}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
onBack={() => setCurrentAgentStage("workflow")}
onClearPreset={() => setSelectedAgentPresetId(null)}
// 嵌入的智能体选择面板
presetPanel={
<AgentPresetPanelDarkV2
embedded
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
/>
}
/>
);
}

View File

@ -0,0 +1,627 @@
"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,
Play,
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 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) => 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 (!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-b border-white/5 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="text-xl font-bold text-white">生图工作台</h1>
<p className="text-xs text-white/40">配置图片槽位并开始生成</p>
</div>
</div>
<div className="flex items-center gap-3">
{onBack && (
<Button
variant="ghost"
onClick={onBack}
className="h-10 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
返回工作流
</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 text-sm font-semibold 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
key={slot.label}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-xl",
attachments[index]
? "ring-2 ring-violet-500/30"
: "border-2 border-dashed border-white/10 hover:border-violet-500/30"
)}
>
<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.5 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-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 text-sm font-semibold text-white">
<Sparkles className="h-4 w-4 text-violet-400" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
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"
/>
</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 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-violet-400" />
生成设置
</h3>
<div className="space-y-4">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<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 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-12 w-full gap-2 rounded-xl",
"bg-gradient-to-r from-violet-600 to-purple-600",
"text-white font-medium",
"hover:from-violet-500 hover:to-purple-500",
"disabled:opacity-30"
)}
>
{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 flex-1 min-h-[500px] rounded-2xl border border-white/5 bg-white/[0.02] overflow-hidden">
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full 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 items-center justify-center p-8"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-xl object-contain"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
<button
onClick={() => setFullscreenPreview(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 h-full flex-col items-center justify-center text-center"
>
<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="text-lg font-semibold 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
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex gap-3 overflow-x-auto pb-2"
>
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
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"
)}
>
<img
src={buildPreviewImageUrl(url, 200)}
alt="缩略图"
className="h-20 w-20 object-cover"
/>
</button>
))}
</motion.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>
);
}

View File

@ -0,0 +1,733 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
X,
Wand2,
Settings2,
ArrowLeft,
LayoutGrid,
FolderOpen,
User,
Building2,
Menu
} 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";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: React.ReactNode;
onClearPreset?: () => void;
}
interface PickedAttachment extends Attachment {}
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
presetPanel,
onClearPreset,
}: 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 [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 (!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(() => {
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]);
// 导航标签配置
const navTabs = [
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid },
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen },
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User },
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 },
];
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 - 与第一步保持一致 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name & Back */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧占位 */}
<div className="w-20" />
</div>
</div>
</header>
{/* Main Content */}
<main className="pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-6">
{/* 步骤指示器 */}
<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 grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left Panel - Controls */}
<div className="space-y-4">
{/* 嵌入的智能体选择面板 */}
{!selectedAgentPreset && presetPanel && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 max-h-[400px] overflow-y-auto"
>
<h3 className="mb-3 text-sm font-semibold text-white">选择智能体</h3>
{presetPanel}
</motion.div>
)}
{/* 已选智能体信息 */}
{selectedAgentPreset && (
<motion.div
className="rounded-xl border border-white/10 bg-white/[0.03] p-4"
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-violet-500/20">
<Wand2 className="h-5 w-5 text-violet-400" />
</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-violet-400 hover:text-violet-300"
>
更换智能体 →
</button>
)}
</motion.div>
)}
{/* Image Slots */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
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-violet-400" />
图片槽位
</h3>
<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.05 }}
whileTap={{ scale: 0.95 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-lg transition-all",
attachments[index]
? "ring-2 ring-violet-500/30"
: "border-2 border-dashed border-white/10 hover:border-violet-500/30"
)}
>
<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-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
</motion.section>
{/* Prompt Input */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
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-violet-400" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
className="min-h-[100px] resize-none rounded-lg border-white/5 bg-black text-sm text-white placeholder:text-white/20 focus:border-violet-500/30"
/>
</motion.section>
{/* Settings */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
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-violet-400" />
生成参数
</h3>
<div className="space-y-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-lg border-white/5 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-violet-600 to-purple-600",
"text-white font-medium",
"hover:from-violet-500 hover:to-purple-500",
"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 flex-col">
<motion.div
className="relative flex-1 min-h-[500px] rounded-xl border border-white/5 bg-white/[0.02] overflow-hidden"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full 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 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={() => setFullscreenPreview(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 h-full 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>
</motion.div>
{/* Thumbnails */}
{allGeneratedImages.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex gap-2 overflow-x-auto pb-2"
>
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-lg transition-all",
selectedResultUrl === url
? "ring-2 ring-violet-500"
: "ring-1 ring-white/10 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 200)}
alt="缩略图"
className="h-16 w-16 object-cover"
/>
</button>
))}
</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>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AgentPresetPanelDark } from "./AgentPresetPanelDark";
import { AgentWorkflowPanelDark, type AgentWorkflowInputs } from "./AgentWorkflowPanelDark";
import { AgentGenerationWorkspaceDark } from "./AgentGenerationWorkspaceDark";
import {
createEmptyAgentWorkflowMaterialSelections,
normalizeAgentWorkflowMaterialSelections,
} from "@/lib/agents/workflow-material-config";
import type {
AgentWorkflowMaterialSelections,
AgentWorkflowStepKey,
AgentWorkflowComposerContext,
} from "@/types/agent";
interface AgentPageDarkProps {
projectId: string;
initialStage?: "preset" | "workflow" | "generate";
initialPresetId?: string | null;
workflowConfig?: any;
}
export function AgentPageDark({
projectId,
initialStage = "preset",
initialPresetId = null,
workflowConfig,
}: AgentPageDarkProps) {
const [stage, setStage] = useState<"preset" | "workflow" | "generate">(initialStage);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(initialPresetId);
// Workflow state
const [workflowInputs, setWorkflowInputs] = useState<AgentWorkflowInputs>({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
const [workflowMaterials, setWorkflowMaterials] = useState<AgentWorkflowMaterialSelections>(
createEmptyAgentWorkflowMaterialSelections()
);
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
// Handle preset selection
const handleSelectPreset = (presetId: string | null) => {
setSelectedPresetId(presetId);
if (presetId) {
setStage("generate");
}
};
// Build workflow context
const workflowContext: AgentWorkflowComposerContext | null = {
inputs: workflowInputs,
materials: workflowMaterials,
selections: Object.entries(workflowSelections).map(([stepKey, optionKey]) => {
const step = workflowConfig?.steps?.find((s: any) => s.key === stepKey);
const option = step?.options?.find((o: any) => o.key === optionKey);
return {
stepKey,
stepTitle: step?.title || "",
optionKey: optionKey || "",
optionName: option?.buttonName || "",
promptFragment: option?.promptFragment || "",
};
}),
referenceImages: [],
summaryText: "",
promptText: "",
};
return (
<div className="bg-black min-h-screen">
<AnimatePresence mode="wait">
{stage === "preset" && (
<motion.div
key="preset"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
>
<AgentPresetPanelDark
selectedPresetId={selectedPresetId}
onSelectPreset={handleSelectPreset}
/>
</motion.div>
)}
{stage === "workflow" && (
<motion.div
key="workflow"
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
>
<AgentWorkflowPanelDark
config={workflowConfig}
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
onNextStep={() => setStage("preset")}
onSkipStep={() => setStage("preset")}
/>
</motion.div>
)}
{stage === "generate" && (
<motion.div
key="generate"
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
>
<AgentGenerationWorkspaceDark
chatId={`canvas-${projectId}`}
projectId={projectId}
selectedAgentPresetId={selectedPresetId}
workflowContext={workflowContext}
onBack={() => setStage("preset")}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,260 @@
"use client";
import { motion } from "framer-motion";
import { Play, Copy, Pencil, Sparkles, Trash2, Video, Wand2, FileText, Clock, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { AgentPreset } from "@/types/agent";
interface AgentPresetCardDarkProps {
preset: AgentPreset;
selected?: boolean;
onSelect: (presetId: string) => void;
onFork?: (preset: AgentPreset) => void;
onEdit?: (preset: AgentPreset) => void;
onDelete?: (preset: AgentPreset) => void;
index?: number;
}
export function AgentPresetCardDark({
preset,
selected = false,
onSelect,
onFork,
onEdit,
onDelete,
index = 0,
}: AgentPresetCardDarkProps) {
const previewUrl =
preset.avatarUrl ||
preset.referenceAssets?.[0]?.thumbnailUrl ||
preset.referenceAssets?.[0]?.originalUrl ||
"";
const ownerName =
String(preset.owner?.name || "").trim() ||
String(preset.owner?.email || "").trim() ||
"未知";
const publishedAt = (() => {
const raw = preset.createdAt ? new Date(preset.createdAt) : null;
if (!raw || Number.isNaN(raw.getTime())) return "";
return raw.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
});
})();
const typeConfig = {
image: {
icon: Wand2,
label: "生图",
gradient: "from-violet-500 to-purple-600",
bg: "bg-violet-500/20",
text: "text-violet-300"
},
video: {
icon: Video,
label: "视频",
gradient: "from-amber-500 to-orange-600",
bg: "bg-amber-500/20",
text: "text-amber-300"
},
text: {
icon: FileText,
label: "生文",
gradient: "from-emerald-500 to-teal-600",
bg: "bg-emerald-500/20",
text: "text-emerald-300"
},
};
const typeInfo = typeConfig[preset.type] || typeConfig.image;
const TypeIcon = typeInfo.icon;
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
delay: index * 0.08,
ease: [0.25, 0.46, 0.45, 0.94]
}}
whileHover={{ y: -8, scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative cursor-pointer",
"w-full aspect-[4/3]",
"rounded-2xl overflow-hidden",
"bg-zinc-900",
"transition-all duration-500",
selected
? "ring-2 ring-white/30 shadow-[0_0_40px_rgba(255,255,255,0.1)]"
: "ring-1 ring-white/5 hover:ring-white/20"
)}
onClick={() => onSelect(preset.id)}
>
{/* 背景图片 */}
<div className="absolute inset-0">
{previewUrl ? (
<>
<img
src={previewUrl}
alt={preset.name}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-black/10" />
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-transparent" />
</>
) : (
<div className="h-full w-full bg-gradient-to-br from-zinc-800 to-zinc-900">
<div className="flex h-full items-center justify-center">
<div className={cn(
"flex h-20 w-20 items-center justify-center rounded-3xl",
"bg-gradient-to-br",
typeInfo.gradient,
"opacity-60"
)}>
<TypeIcon className="h-10 w-10 text-white" />
</div>
</div>
</div>
)}
</div>
{/* 顶部信息栏 */}
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
{/* 类型标签 */}
<div className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium backdrop-blur-md",
"bg-black/40",
typeInfo.text
)}>
<TypeIcon className="h-3.5 w-3.5" />
{typeInfo.label}
</div>
{/* 可见性标签 */}
<div className={cn(
"rounded-full px-2.5 py-1 text-[10px] font-medium backdrop-blur-md",
preset.visibility === "public"
? "bg-emerald-500/20 text-emerald-300"
: "bg-zinc-500/20 text-zinc-400"
)}>
{preset.visibility === "public" ? "公共" : "私有"}
</div>
</div>
{/* 中央播放/使用按钮 */}
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.8 }}
whileHover={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<div className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
"bg-white/20 backdrop-blur-md",
"ring-1 ring-white/30",
"transition-all duration-300",
"group-hover:bg-white/30 group-hover:scale-110",
selected && "bg-white/40 scale-110"
)}>
<Play className={cn(
"h-7 w-7 text-white fill-white",
selected && "scale-110"
)} />
</div>
</motion.div>
{/* 悬浮操作按钮 */}
<motion.div
className="absolute right-4 top-16 flex flex-col gap-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
>
<div className="flex flex-col gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{preset.visibility === "private" && onEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Pencil className="h-4 w-4" />
</button>
)}
{preset.visibility === "private" && onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-red-500/80 hover:text-white"
>
<Trash2 className="h-4 w-4" />
</button>
)}
{preset.visibility === "public" && onFork && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFork(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Copy className="h-4 w-4" />
</button>
)}
</div>
</motion.div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* 标题 */}
<h3 className="mb-2 line-clamp-1 text-base font-semibold text-white drop-shadow-lg">
{preset.name}
</h3>
{/* 元信息 */}
<div className="flex items-center justify-between text-xs text-white/60">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{ownerName}
</span>
{preset.slotCount > 0 && (
<span className="flex items-center gap-1">
<Sparkles className="h-3 w-3" />
{preset.slotCount}槽
</span>
)}
</div>
{publishedAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{publishedAt}
</span>
)}
</div>
</div>
{/* 选中指示器 */}
{selected && (
<motion.div
className="absolute inset-0 ring-2 ring-inset ring-white/30 rounded-2xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)}
</motion.div>
);
}

View File

@ -0,0 +1,278 @@
"use client";
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Loader2, Plus, Search, Sparkles, User, Grid3X3, Wand2 } from "lucide-react";
import useSWR from "swr";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
interface AgentPresetPanelDarkProps {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
title?: string;
description?: string;
searchPlaceholder?: string;
publicTitle?: string;
privateTitle?: string;
}
export function AgentPresetPanelDark({
selectedPresetId,
onSelectPreset,
title = "智能体",
description = "选择生图模式开始创作",
searchPlaceholder = "搜索智能体...",
publicTitle = "公共智能体",
privateTitle = "我的智能体",
}: AgentPresetPanelDarkProps) {
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data, isLoading, mutate } = useSWR(
["agent-presets"],
async () => {
const result = await agentService.getPresets({
scope: "all",
sort: "latest",
});
if (!result.success || !result.data) {
throw new Error(result.error || "获取智能体失败");
}
return result.data;
}
);
const filteredPublicPresets = useMemo(() => {
return (data?.public || []).filter((preset) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return true;
return [preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
}, [data?.public, keyword]);
const filteredPrivatePresets = useMemo(() => {
return (data?.private || []).filter((preset) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return true;
return [preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
}, [data?.private, keyword]);
const handleForkPreset = async (preset: AgentPreset) => {
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
if (!result.success || !result.data) {
toast.error(result.error || "复制智能体失败");
return;
}
toast.success("已另存为我的智能体");
await mutate();
onSelectPreset(result.data.id);
};
const handleDeletePreset = async (preset: AgentPreset) => {
if (!window.confirm(`确定删除智能体「${preset.name}」吗?`)) {
return;
}
const result = await agentService.deletePreset(preset.id);
if (!result.success) {
toast.error(result.error || "删除智能体失败");
return;
}
toast.success("智能体已删除");
if (selectedPresetId === preset.id) {
onSelectPreset(null);
}
await mutate();
};
return (
<>
<div className="min-h-screen bg-black">
{/* 顶部导航栏 */}
<header className="sticky top-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-8">
<div className="flex h-20 items-center justify-between">
{/* Logo & Title */}
<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="text-xl font-bold text-white">{title}</h1>
<p className="text-xs text-white/40">{description}</p>
</div>
</div>
{/* Search */}
<div className="flex-1 max-w-xl mx-8">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder={searchPlaceholder}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-11 rounded-full border-0 pl-11 pr-4",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20",
"transition-all duration-300"
)}
/>
</div>
</div>
{/* Actions */}
<Button
onClick={() => setShowCreateDialog(true)}
className={cn(
"h-11 gap-2 rounded-full px-6",
"bg-white text-black hover:bg-white/90",
"font-medium transition-all duration-300",
"hover:shadow-[0_0_30px_rgba(255,255,255,0.3)]"
)}
>
<Plus className="h-4 w-4" />
新建智能体
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-[1800px] px-8 py-8">
<ScrollArea className="h-[calc(100vh-120px)]">
<div className="space-y-10 pb-20">
{/* 公共智能体区域 */}
<section>
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/20">
<Grid3X3 className="h-4 w-4 text-violet-400" />
</div>
<h2 className="text-lg font-semibold text-white">{publicTitle}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPublicPresets.length}
</span>
</motion.div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-violet-500" />
</div>
) : filteredPublicPresets.length > 0 ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{filteredPublicPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index}
/>
))}
</AnimatePresence>
</div>
) : (
<EmptyState message="暂无公共智能体" />
)}
</section>
{/* 私有智能体区域 */}
<section>
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20">
<User className="h-4 w-4 text-emerald-400" />
</div>
<h2 className="text-lg font-semibold text-white">{privateTitle}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPrivatePresets.length}
</span>
</motion.div>
{isLoading ? null : filteredPrivatePresets.length > 0 ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{filteredPrivatePresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index + filteredPublicPresets.length}
/>
))}
</AnimatePresence>
</div>
) : (
<EmptyState message="还没有私有智能体,点击右上角新建一个吧" />
)}
</section>
</div>
</ScrollArea>
</main>
</div>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</>
);
}
function EmptyState({ message }: { message: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center rounded-3xl border border-white/5 bg-white/[0.02] py-20"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Sparkles className="h-7 w-7 text-white/30" />
</div>
<p className="text-sm text-white/40">{message}</p>
</motion.div>
);
}

View File

@ -0,0 +1,467 @@
"use client";
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Loader2,
Plus,
Search,
Sparkles,
User,
Grid3X3,
Wand2,
ArrowRight,
LayoutGrid,
FolderOpen,
Building2,
Image as ImageIcon,
Menu
} from "lucide-react";
import useSWR from "swr";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentPresetPanelDarkV2Props {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
onCreateClick?: () => void;
embedded?: boolean;
}
export function AgentPresetPanelDarkV2({
selectedPresetId,
onSelectPreset,
onCreateClick,
embedded = false,
}: AgentPresetPanelDarkV2Props) {
const [activeTab, setActiveTab] = useState<NavTab>("cases");
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data, isLoading, mutate } = useSWR(
["agent-presets"],
async () => {
const result = await agentService.getPresets({
scope: "all",
sort: "latest",
});
if (!result.success || !result.data) {
throw new Error(result.error || "获取智能体失败");
}
return result.data;
}
);
// 根据当前标签过滤数据
const filteredPresets = useMemo(() => {
let presets: AgentPreset[] = [];
switch (activeTab) {
case "cases":
presets = data?.public || [];
break;
case "my-works":
presets = data?.private || [];
break;
case "personal-assets":
// 这里可以接入个人资产库数据
presets = data?.private || [];
break;
case "company-assets":
// 这里可以接入公司资产库数据
presets = data?.public || [];
break;
}
// 搜索过滤
if (keyword.trim()) {
const normalizedKeyword = keyword.trim().toLowerCase();
presets = presets.filter((preset) =>
[preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
);
}
return presets;
}, [data, activeTab, keyword]);
const handleForkPreset = async (preset: AgentPreset) => {
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
if (!result.success || !result.data) {
toast.error(result.error || "复制智能体失败");
return;
}
toast.success("已另存为我的智能体");
await mutate();
onSelectPreset(result.data.id);
};
const handleDeletePreset = async (preset: AgentPreset) => {
if (!window.confirm(`确定删除智能体「${preset.name}」吗?`)) {
return;
}
const result = await agentService.deletePreset(preset.id);
if (!result.success) {
toast.error(result.error || "删除智能体失败");
return;
}
toast.success("智能体已删除");
if (selectedPresetId === preset.id) {
onSelectPreset(null);
}
await mutate();
};
// 导航标签配置
const navTabs = [
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid },
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen },
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User },
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 },
];
// 获取当前标签标题
const getCurrentTitle = () => {
switch (activeTab) {
case "cases": return "公共智能体";
case "my-works": return "我的智能体";
case "personal-assets": return "个人资产库";
case "company-assets": return "公司资产库";
}
};
// 嵌入模式只渲染卡片网格
if (embedded) {
return (
<>
{/* 搜索栏 */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder="搜索智能体..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-9 rounded-lg border-0 pl-9 pr-3 text-sm",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20"
)}
/>
</div>
</div>
{/* 导航标签(简化版) */}
<div className="mb-4 flex gap-1">
{navTabs.slice(0, 2).map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as NavTab)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
activeTab === tab.id
? "bg-white text-black"
: "text-white/60 hover:text-white hover:bg-white/5"
)}
>
{tab.label}
</button>
))}
</div>
{/* 卡片网格 */}
<div className="grid grid-cols-1 gap-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
</div>
) : filteredPresets.length > 0 ? (
<AnimatePresence mode="popLayout">
{filteredPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index}
/>
))}
</AnimatePresence>
) : (
<div className="text-center py-8 text-white/40 text-sm">
暂无智能体
</div>
)}
</div>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</>
);
}
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */}
<div className="flex items-center gap-3">
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-300",
isActive
? "bg-white text-black"
: "text-white/60 hover:text-white hover:bg-white/5"
)}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧操作 */}
<div className="flex items-center gap-3">
<Button
onClick={() => onCreateClick ? onCreateClick() : setShowCreateDialog(true)}
className={cn(
"h-9 gap-2 rounded-full px-5",
"bg-white text-black hover:bg-white/90",
"font-medium text-sm transition-all",
"hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]"
)}
>
<Plus className="h-4 w-4" />
新建
</Button>
</div>
</div>
</div>
</header>
{/* Hero Section - 视频/功能展示区 */}
<section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8">
{/* 标题区域 */}
<motion.div
className="mb-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
第一步:风格选择 &nbsp;&nbsp; 第二步:智能体生图
</p>
<Button
className={cn(
"h-12 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium text-base",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"transition-all duration-300"
)}
>
我要创作
<ArrowRight className="h-5 w-5" />
</Button>
</motion.div>
{/* 视频/功能卡片展示区域 - 预留空位 */}
<motion.div
className="relative mb-12 aspect-[21/9] w-full overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/50"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{/* 这里将来放视频或其他功能卡片 */}
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-white/5 mx-auto">
<ImageIcon className="h-10 w-10 text-white/20" />
</div>
<p className="text-white/40">视频展示区(预留)</p>
<p className="mt-2 text-sm text-white/20">未来可放置宣传片或功能介绍</p>
</div>
</div>
{/* 装饰性渐变 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent pointer-events-none" />
</motion.div>
</div>
</section>
{/* 智能体卡片网格区域 */}
<section className="pb-20">
<div className="mx-auto max-w-[1800px] px-6">
{/* 搜索栏 */}
<motion.div
className="mb-8 flex items-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder="搜索智能体..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-11 rounded-full border-0 pl-11 pr-4",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20",
"transition-all duration-300"
)}
/>
</div>
<div className="flex items-center gap-2 text-sm text-white/50">
<Grid3X3 className="h-4 w-4" />
<span>共 {filteredPresets.length} 个</span>
</div>
</motion.div>
{/* 区域标题 */}
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<h2 className="text-lg font-semibold text-white">{getCurrentTitle()}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPresets.length}
</span>
</motion.div>
{/* 卡片网格 */}
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-violet-500" />
</div>
) : filteredPresets.length > 0 ? (
filteredPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index}
/>
))
) : (
<EmptyState message={`暂无${getCurrentTitle()}`} />
)}
</AnimatePresence>
</div>
</div>
</section>
{/* 页脚 */}
<footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
<span>© 2024 AI创作平台</span>
</div>
</div>
</footer>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="col-span-full flex flex-col items-center justify-center rounded-2xl border border-white/5 bg-white/[0.02] py-20"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Sparkles className="h-7 w-7 text-white/30" />
</div>
<p className="text-sm text-white/40">{message}</p>
</motion.div>
);
}

View File

@ -0,0 +1,440 @@
"use client";
import { useMemo } from "react";
import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
export interface AgentWorkflowInputs {
primarySku: string;
secondarySku: string;
brand: string;
store: string;
}
interface AgentWorkflowPanelDarkProps {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
onReset?: () => void;
onNextStep?: () => void;
onSkipStep?: () => void;
}
function buildSelectionSummary(params: {
config: AgentWorkflowUiConfig;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const step of params.config.steps) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
if (!option) continue;
result.push({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || "",
});
}
return result;
}
function StepOptionCard({
option,
step,
selected,
onClick,
index,
}: {
option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null };
step: { title: string };
selected: boolean;
onClick: () => void;
index: number;
}) {
return (
<motion.button
type="button"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.05 }}
whileHover={{ y: -4, scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative overflow-hidden rounded-2xl text-left",
"aspect-[16/10] w-full",
"transition-all duration-500",
selected
? "ring-2 ring-white/30 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
: "ring-1 ring-white/5 hover:ring-white/20"
)}
>
{/* 背景 */}
<div className="absolute inset-0 bg-zinc-900">
{option.previewImageUrl ? (
<>
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
<Sparkles className="h-10 w-10 text-white/20" />
</div>
)}
</div>
{/* 选中标记 */}
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black"
>
<Check className="h-4 w-4" />
</motion.div>
)}
{/* 步骤标签 */}
<div className="absolute left-3 top-3">
<span className="rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
{step.title}
</span>
</div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<h4 className="text-sm font-semibold text-white drop-shadow-lg">
{option.buttonName}
</h4>
{option.description && (
<p className="mt-1 line-clamp-1 text-xs text-white/60">
{option.description}
</p>
)}
</div>
{/* 选中边框 */}
{selected && (
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
)}
</motion.button>
);
}
export function AgentWorkflowPanelDark({
config,
inputs,
onInputsChange,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onReset,
onNextStep,
onSkipStep,
}: AgentWorkflowPanelDarkProps) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
);
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
return {
steps: [...source.steps]
.filter((step) => step.enabled)
.map((step) => ({
...step,
options: [...step.options]
.filter((option) => option.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder),
}))
.filter((step) => step.options.length > 0)
.sort((left, right) => left.sortOrder - right.sortOrder),
};
}, [config]);
const summary = useMemo(
() => buildSelectionSummary({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const composerContext = useMemo(
() =>
buildAgentWorkflowComposerContext({
config: resolvedConfig,
inputs,
materials,
selections,
}),
[inputs, materials, resolvedConfig, selections]
);
const progress = useMemo(
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const handleCopyPrompt = async () => {
if (!composerContext?.promptText) {
toast.error("当前还没有可复制的 Prompt");
return;
}
try {
await navigator.clipboard.writeText(composerContext.promptText);
toast.success("Prompt 已复制");
} catch {
toast.error("复制失败");
}
};
return (
<div className="min-h-screen bg-black">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-white/5 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">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">智能体工作流</h1>
<p className="text-xs text-white/40">配置工作流参数后进入生图模式</p>
</div>
</div>
{/* 进度指示 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-white/60">
<span>进度</span>
<span className="font-semibold text-white">{progress.completed}/{progress.total}</span>
</div>
<div className="h-2 w-32 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-violet-500 to-purple-500"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-[1800px] px-8 py-8">
<ScrollArea className="h-[calc(100vh-120px)]">
<div className="space-y-8 pb-20">
{/* 基础配置 */}
<section>
<motion.h2
className="mb-4 flex items-center gap-2 text-lg font-semibold text-white"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-500/20 text-xs font-bold text-violet-400">
1
</span>
基础配置
</motion.h2>
<motion.div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<SearchableSelect
value={inputs.brand}
options={brandOptions}
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</motion.div>
</section>
{/* 工作流步骤 */}
{resolvedConfig.steps.map((step, stepIndex) => (
<section key={step.key}>
<motion.h2
className="mb-4 flex items-center gap-2 text-lg font-semibold text-white"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + stepIndex * 0.05 }}
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-500/20 text-xs font-bold text-violet-400">
{stepIndex + 2}
</span>
{step.title}
</motion.h2>
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{step.options.map((option, optionIndex) => (
<StepOptionCard
key={option.key}
option={option}
step={step}
selected={selections[step.key] === option.key}
onClick={() =>
onSelectionsChange({
...selections,
[step.key]: selections[step.key] === option.key ? undefined : option.key,
})
}
index={optionIndex}
/>
))}
</div>
</section>
))}
{/* 当前选择摘要 */}
{summary.length > 0 && (
<motion.section
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<h3 className="mb-4 text-sm font-semibold text-white/80">当前选择</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={`${item.stepKey}-${item.optionKey}`}
className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
>
<span className="text-sm text-white/50">{item.stepTitle}</span>
<span className="text-sm font-medium text-white">{item.optionName}</span>
</div>
))}
</div>
</motion.section>
)}
{/* Prompt 预览 */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">Prompt 预览</h3>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPrompt}
className="h-8 gap-1.5 text-xs text-white/60 hover:bg-white/10 hover:text-white"
>
<Copy className="h-3.5 w-3.5" />
复制
</Button>
</div>
<Textarea
value={composerContext?.promptText || ""}
readOnly
rows={6}
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
/>
</section>
{/* 底部操作 */}
{onNextStep && (
<motion.div
className="flex justify-end gap-3 pt-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{onSkipStep && (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
跳过
<ChevronRight className="h-4 w-4" />
</Button>
)}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-white text-black hover:bg-white/90",
"font-medium transition-all",
"disabled:opacity-30"
)}
>
下一步:生图模式
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)}
</div>
</ScrollArea>
</main>
</div>
);
}

View File

@ -0,0 +1,511 @@
"use client";
import { useMemo } from "react";
import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight, Wand2, LayoutGrid, FolderOpen, User, Building2, Menu } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
export interface AgentWorkflowInputs {
primarySku: string;
secondarySku: string;
brand: string;
store: string;
}
interface AgentWorkflowPanelDarkV2Props {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
onReset?: () => void;
onNextStep?: () => void;
onSkipStep?: () => void;
}
function buildSelectionSummary(params: {
config: AgentWorkflowUiConfig;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const step of params.config.steps) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
if (!option) continue;
result.push({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || "",
});
}
return result;
}
function StepOptionCard({
option,
step,
selected,
onClick,
index,
}: {
option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null };
step: { title: string };
selected: boolean;
onClick: () => void;
index: number;
}) {
return (
<motion.button
type="button"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.05 }}
whileHover={{ y: -4, scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative overflow-hidden rounded-2xl text-left",
"aspect-[16/10] w-full",
"transition-all duration-500",
selected
? "ring-2 ring-white/30 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
: "ring-1 ring-white/5 hover:ring-white/20"
)}
>
{/* 背景 */}
<div className="absolute inset-0 bg-zinc-900">
{option.previewImageUrl ? (
<>
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
<Sparkles className="h-10 w-10 text-white/20" />
</div>
)}
</div>
{/* 选中标记 */}
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black"
>
<Check className="h-4 w-4" />
</motion.div>
)}
{/* 步骤标签 */}
<div className="absolute left-3 top-3">
<span className="rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
{step.title}
</span>
</div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<h4 className="text-sm font-semibold text-white drop-shadow-lg">
{option.buttonName}
</h4>
{option.description && (
<p className="mt-1 line-clamp-1 text-xs text-white/60">
{option.description}
</p>
)}
</div>
{/* 选中边框 */}
{selected && (
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
)}
</motion.button>
);
}
export function AgentWorkflowPanelDarkV2({
config,
inputs,
onInputsChange,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onReset,
onNextStep,
onSkipStep,
}: AgentWorkflowPanelDarkV2Props) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
);
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
return {
steps: [...source.steps]
.filter((step) => step.enabled)
.map((step) => ({
...step,
options: [...step.options]
.filter((option) => option.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder),
}))
.filter((step) => step.options.length > 0)
.sort((left, right) => left.sortOrder - right.sortOrder),
};
}, [config]);
const summary = useMemo(
() => buildSelectionSummary({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const composerContext = useMemo(
() =>
buildAgentWorkflowComposerContext({
config: resolvedConfig,
inputs,
materials,
selections,
}),
[inputs, materials, resolvedConfig, selections]
);
const progress = useMemo(
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const handleCopyPrompt = async () => {
if (!composerContext?.promptText) {
toast.error("当前还没有可复制的 Prompt");
return;
}
try {
await navigator.clipboard.writeText(composerContext.promptText);
toast.success("Prompt 已复制");
} catch {
toast.error("复制失败");
}
};
// 导航标签配置
const navTabs = [
{ id: "cases", label: "案例广场", icon: LayoutGrid },
{ id: "my-works", label: "我的作品", icon: FolderOpen },
{ id: "personal-assets", label: "个人资产库", icon: User },
{ id: "company-assets", label: "公司资产库", icon: Building2 },
];
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */}
<div className="flex items-center gap-3">
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧操作 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8">
{/* 标题区域 */}
<motion.div
className="mb-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
<span className="text-emerald-400 font-medium">第一步:风格选择</span>
<span className="mx-2 text-white/20">→</span>
<span className="text-white/40">第二步:智能体生图</span>
</p>
</motion.div>
{/* 进度指示 */}
<motion.div
className="mx-auto mb-8 max-w-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between text-sm text-white/50 mb-2">
<span>完成进度</span>
<span className="font-medium text-white">{progress.completed}/{progress.total}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-500"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</motion.div>
</div>
</section>
{/* Main Content */}
<main className="pb-20">
<div className="mx-auto max-w-[1800px] px-6">
<ScrollArea className="h-[calc(100vh-300px)]">
<div className="space-y-8 pr-4">
{/* 基础配置 */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
1
</span>
基础配置
</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 max-w-2xl">
<SearchableSelect
value={inputs.brand}
options={brandOptions}
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>
</motion.section>
{/* 工作流步骤 */}
{resolvedConfig.steps.map((step, stepIndex) => (
<motion.section
key={step.key}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + stepIndex * 0.05 }}
>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
{stepIndex + 2}
</span>
{step.title}
</h2>
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{step.options.map((option, optionIndex) => (
<StepOptionCard
key={option.key}
option={option}
step={step}
selected={selections[step.key] === option.key}
onClick={() =>
onSelectionsChange({
...selections,
[step.key]: selections[step.key] === option.key ? undefined : option.key,
})
}
index={optionIndex}
/>
))}
</div>
</motion.section>
))}
{/* 当前选择摘要 */}
{summary.length > 0 && (
<motion.section
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<h3 className="mb-4 text-sm font-semibold text-white/80">当前选择</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={`${item.stepKey}-${item.optionKey}`}
className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
>
<span className="text-sm text-white/50">{item.stepTitle}</span>
<span className="text-sm font-medium text-white">{item.optionName}</span>
</div>
))}
</div>
</motion.section>
)}
{/* Prompt 预览 */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">Prompt 预览</h3>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPrompt}
className="h-8 gap-1.5 text-xs text-white/60 hover:bg-white/10 hover:text-white"
>
<Copy className="h-3.5 w-3.5" />
复制
</Button>
</div>
<Textarea
value={composerContext?.promptText || ""}
readOnly
rows={6}
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
/>
</motion.section>
{/* 底部操作 */}
{onNextStep && (
<motion.div
className="flex justify-end gap-3 pt-4 pb-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{onSkipStep && (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
跳过
<ChevronRight className="h-4 w-4" />
</Button>
)}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"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_30px_rgba(16,185,129,0.3)]",
"disabled:opacity-30"
)}
>
下一步:选择智能体
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)}
</div>
</ScrollArea>
</div>
</main>
{/* 页脚 */}
<footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
<span>© 2024 AI创作平台</span>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,7 @@
// 智能体深色主题 V2 组件统一导出
export { AgentPresetCardDark } from "./AgentPresetCardDark";
export { AgentPresetPanelDarkV2 } from "./AgentPresetPanelDarkV2";
export { AgentGenerationWorkspaceDarkV2 } from "./AgentGenerationWorkspaceDarkV2";
// 类型导出
export type { AgentWorkflowInputs } from "./AgentWorkflowPanelDark";

View File

@ -0,0 +1,9 @@
// 智能体深色主题组件统一导出
export { AgentPresetCardDark } from "./AgentPresetCardDark";
export { AgentPresetPanelDark } from "./AgentPresetPanelDark";
export { AgentWorkflowPanelDark } from "./AgentWorkflowPanelDark";
export { AgentGenerationWorkspaceDark } from "./AgentGenerationWorkspaceDark";
export { AgentPageDark } from "./AgentPageDark";
// 类型导出
export type { AgentWorkflowInputs } from "./AgentWorkflowPanelDark";