feat: refresh agent workflow dark ui
This commit is contained in:
parent
2e21d1b274
commit
f764767ba4
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
627
components/agents/AgentGenerationWorkspaceDark.tsx
Normal file
627
components/agents/AgentGenerationWorkspaceDark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
733
components/agents/AgentGenerationWorkspaceDarkV2.tsx
Normal file
733
components/agents/AgentGenerationWorkspaceDarkV2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
components/agents/AgentPageDark.tsx
Normal file
136
components/agents/AgentPageDark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
components/agents/AgentPresetCardDark.tsx
Normal file
260
components/agents/AgentPresetCardDark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
components/agents/AgentPresetPanelDark.tsx
Normal file
278
components/agents/AgentPresetPanelDark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
467
components/agents/AgentPresetPanelDarkV2.tsx
Normal file
467
components/agents/AgentPresetPanelDarkV2.tsx
Normal 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">
|
||||
第一步:风格选择 第二步:智能体生图
|
||||
</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>
|
||||
);
|
||||
}
|
||||
440
components/agents/AgentWorkflowPanelDark.tsx
Normal file
440
components/agents/AgentWorkflowPanelDark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
511
components/agents/AgentWorkflowPanelDarkV2.tsx
Normal file
511
components/agents/AgentWorkflowPanelDarkV2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
components/agents/index-dark-v2.ts
Normal file
7
components/agents/index-dark-v2.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// 智能体深色主题 V2 组件统一导出
|
||||
export { AgentPresetCardDark } from "./AgentPresetCardDark";
|
||||
export { AgentPresetPanelDarkV2 } from "./AgentPresetPanelDarkV2";
|
||||
export { AgentGenerationWorkspaceDarkV2 } from "./AgentGenerationWorkspaceDarkV2";
|
||||
|
||||
// 类型导出
|
||||
export type { AgentWorkflowInputs } from "./AgentWorkflowPanelDark";
|
||||
9
components/agents/index-dark.ts
Normal file
9
components/agents/index-dark.ts
Normal 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";
|
||||
Loading…
x
Reference in New Issue
Block a user