feat: refresh agent workflow frontend
This commit is contained in:
parent
ea5a189ae0
commit
2e21d1b274
@ -59,13 +59,13 @@ import { toast } from "sonner";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||||
import {
|
||||
AgentWorkflowPanel,
|
||||
AgentWorkflowPanelNew,
|
||||
type AgentWorkflowInputs,
|
||||
} from "@/components/agents/AgentWorkflowPanel";
|
||||
import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace";
|
||||
} from "@/components/agents/AgentWorkflowPanelNew";
|
||||
import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew";
|
||||
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
|
||||
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
|
||||
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
|
||||
import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||
@ -1002,7 +1002,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
{currentAgentStage === "workflow" ? (
|
||||
<div className="absolute inset-0 pt-[72px]">
|
||||
<div className="h-full bg-white dark:bg-zinc-950">
|
||||
<AgentWorkflowPanel
|
||||
<AgentWorkflowPanelNew
|
||||
embedded
|
||||
config={resolvedWorkflowUiConfig}
|
||||
inputs={workflowInputs}
|
||||
@ -1081,7 +1081,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
disabled={!selectedAgentPresetId}
|
||||
disabledHint="请先选择一个生图模式,系统才能把你选中的图片自动送入对应生图槽位。"
|
||||
/>
|
||||
<AgentPresetPanel
|
||||
<AgentPresetPanelNew
|
||||
embedded
|
||||
layout="inline"
|
||||
selectedPresetId={selectedAgentPresetId}
|
||||
@ -1132,7 +1132,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AgentGenerationWorkspace
|
||||
<AgentGenerationWorkspaceNew
|
||||
chatId={`canvas-${id}`}
|
||||
projectId={id}
|
||||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||||
|
||||
886
components/agents/AgentGenerationWorkspaceNew.tsx
Normal file
886
components/agents/AgentGenerationWorkspaceNew.tsx
Normal file
@ -0,0 +1,886 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Upload,
|
||||
X,
|
||||
ZoomIn,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
|
||||
import type {
|
||||
AgentPreset,
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowSelectedBrandModel,
|
||||
} from "@/types/agent";
|
||||
|
||||
interface AgentGenerationWorkspaceNewProps {
|
||||
chatId: string;
|
||||
projectId: string;
|
||||
selectedAgentPresetId?: string | null;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
workflowContext?: AgentWorkflowComposerContext | null;
|
||||
}
|
||||
|
||||
interface PickedAttachment extends Attachment {}
|
||||
|
||||
interface GeneratedImageGroup {
|
||||
messageId: string;
|
||||
imageUrls: string[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
function buildPreviewImageUrl(url: string, width: number) {
|
||||
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
|
||||
}
|
||||
|
||||
function buildDownloadFilename(url: string): string {
|
||||
const fallback = `canvas-image-${Date.now()}.png`;
|
||||
const raw = String(url || "").trim();
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const pathname = new URL(raw).pathname || "";
|
||||
const candidate = pathname.split("/").pop() || "";
|
||||
return candidate || fallback;
|
||||
} catch {
|
||||
const candidate = raw.split("/").pop()?.split("?")[0] || "";
|
||||
return candidate || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getImageDimensions(url: string): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxSize = 500;
|
||||
const originalWidth = img.naturalWidth;
|
||||
const originalHeight = img.naturalHeight;
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
if (width > maxSize || height > maxSize) {
|
||||
const scale = Math.min(maxSize / width, maxSize / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const minSize = 150;
|
||||
if (width < minSize && height < minSize) {
|
||||
const scale = minSize / Math.min(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
resolve({ width, height, originalWidth, originalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAssistantText(value: string) {
|
||||
return String(value || "")
|
||||
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
||||
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
||||
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
||||
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 图片槽位组件
|
||||
function ImageSlot({
|
||||
slot,
|
||||
index,
|
||||
attachment,
|
||||
isUploading,
|
||||
onClick,
|
||||
onRemove,
|
||||
fileInputRef,
|
||||
onFileChange,
|
||||
}: {
|
||||
slot: { slotIndex: number; label: string };
|
||||
index: number;
|
||||
attachment: PickedAttachment | null;
|
||||
isUploading: boolean;
|
||||
onClick: () => void;
|
||||
onRemove: (e: React.MouseEvent) => void;
|
||||
fileInputRef: (el: HTMLInputElement | null) => void;
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={cn(
|
||||
"group relative aspect-square overflow-hidden rounded-2xl transition-all duration-300",
|
||||
attachment
|
||||
? "ring-2 ring-indigo-500/20"
|
||||
: "border-2 border-dashed border-slate-200 hover:border-indigo-400/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
{attachment ? (
|
||||
<>
|
||||
<img
|
||||
src={buildPreviewImageUrl(attachment.url, 256)}
|
||||
alt={attachment.name || slot.label}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/* 遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
{/* 标签 */}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1.5 text-[11px] text-white">
|
||||
{slot.label}
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
<motion.button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-slate-600 shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</motion.button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-400">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-500">
|
||||
<Upload className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium">{slot.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
// 结果缩略图组件
|
||||
function ResultThumbnail({
|
||||
url,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
url: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl transition-all duration-200",
|
||||
isSelected
|
||||
? "ring-2 ring-indigo-500 ring-offset-2"
|
||||
: "ring-1 ring-slate-200 hover:ring-indigo-400/50"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={buildPreviewImageUrl(url, 220)}
|
||||
alt="结果缩略图"
|
||||
className="h-20 w-20 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentGenerationWorkspaceNew({
|
||||
chatId,
|
||||
projectId,
|
||||
selectedAgentPresetId,
|
||||
selectedBrandModel = null,
|
||||
workflowContext = null,
|
||||
}: AgentGenerationWorkspaceNewProps) {
|
||||
const rawChatId = useMemo(
|
||||
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
|
||||
[chatId]
|
||||
);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
const generatedImageKeysRef = useRef<Set<string>>(new Set());
|
||||
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
|
||||
const [slotUploading, setSlotUploading] = useState<number | null>(null);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
|
||||
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
|
||||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
imageCount: 1,
|
||||
});
|
||||
|
||||
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
|
||||
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const { data: modelsData } = useSWR<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
providerType: string;
|
||||
supportsImageGen?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
imageGenConfig?: any;
|
||||
}>;
|
||||
defaultModelId?: string | null;
|
||||
agentModel?: any;
|
||||
}>("/api/models", fetcher);
|
||||
|
||||
const { data: agentPresetResponse } = useSWR<{
|
||||
success?: boolean;
|
||||
data?: AgentPreset;
|
||||
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
|
||||
|
||||
const selectedAgentPreset = useMemo(
|
||||
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
|
||||
[agentPresetResponse]
|
||||
);
|
||||
|
||||
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
|
||||
const referenceImages = workflowContext?.referenceImages || [];
|
||||
const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text");
|
||||
const agentUploadSlots = useMemo(
|
||||
() =>
|
||||
hasAgentUploadSlots
|
||||
? [
|
||||
{ slotIndex: 0, label: "产品图1" },
|
||||
{ slotIndex: 1, label: "产品图2" },
|
||||
{ slotIndex: 2, label: "模特图" },
|
||||
]
|
||||
: [],
|
||||
[hasAgentUploadSlots]
|
||||
);
|
||||
const requiredUploadCount = hasAgentUploadSlots ? 3 : 0;
|
||||
|
||||
const selectableModels = useMemo(() => {
|
||||
const imageModels = (modelsData?.models || []).filter(
|
||||
(model) => model.supportsImageGen || model.isMultimodal
|
||||
);
|
||||
const autoOption = {
|
||||
id: "auto",
|
||||
displayName: "自动选择",
|
||||
providerType: "auto",
|
||||
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
|
||||
};
|
||||
return [autoOption, ...imageModels];
|
||||
}, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0],
|
||||
[selectableModels, selectedModelId]
|
||||
);
|
||||
|
||||
const { 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 (!messages.length && historyMessages.length) {
|
||||
setMessages(historyMessages);
|
||||
}
|
||||
}, [historyMessages, messages.length, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgentPreset?.defaultModelId) {
|
||||
setSelectedModelId(selectedAgentPreset.defaultModelId);
|
||||
return;
|
||||
}
|
||||
if (modelsData?.defaultModelId) {
|
||||
setSelectedModelId(modelsData.defaultModelId);
|
||||
}
|
||||
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAgentUploadSlots) {
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
const nextSlots: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: agentUploadSlots.length },
|
||||
() => null
|
||||
);
|
||||
const primaryImages = referenceImages.filter((image) => image.source === "primary");
|
||||
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
|
||||
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
|
||||
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
|
||||
image
|
||||
? {
|
||||
url: image.url,
|
||||
name: image.name || image.label,
|
||||
contentType: image.mimeType || "image/png",
|
||||
assetId: image.assetId || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]);
|
||||
nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]);
|
||||
nextSlots[2] = toAttachment(brandModelImages[0]);
|
||||
|
||||
setAttachments((current) => {
|
||||
const result: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: agentUploadSlots.length },
|
||||
(_, index) => current[index] || null
|
||||
);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (!result[index] && nextSlots[index]) {
|
||||
result[index] = nextSlots[index] as PickedAttachment;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAgentUploadSlots) {
|
||||
lastSyncedBrandModelUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
const nextAttachment = selectedBrandModel?.imageUrl
|
||||
? {
|
||||
url: selectedBrandModel.imageUrl,
|
||||
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
|
||||
contentType: selectedBrandModel.mimeType || "image/png",
|
||||
assetId: selectedBrandModel.assetId || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
setAttachments((current) => {
|
||||
const slots: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: Math.max(agentUploadSlots.length, 3) },
|
||||
(_, index) => current[index] ?? null
|
||||
);
|
||||
const currentSlot = slots[2] ?? null;
|
||||
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
|
||||
|
||||
if (nextAttachment) {
|
||||
slots[2] = nextAttachment;
|
||||
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
|
||||
slots[2] = null;
|
||||
}
|
||||
return slots;
|
||||
});
|
||||
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
|
||||
}, [
|
||||
agentUploadSlots.length,
|
||||
hasAgentUploadSlots,
|
||||
selectedBrandModel?.assetId,
|
||||
selectedBrandModel?.brandName,
|
||||
selectedBrandModel?.displayName,
|
||||
selectedBrandModel?.id,
|
||||
selectedBrandModel?.imageUrl,
|
||||
selectedBrandModel?.mimeType,
|
||||
]);
|
||||
|
||||
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
|
||||
return messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => {
|
||||
const textParts = (message.parts || [])
|
||||
.filter((part: any) => part.type === "text" && part.text)
|
||||
.map((part: any) => String(part.text));
|
||||
const imageUrls = Array.from(
|
||||
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
|
||||
);
|
||||
return {
|
||||
messageId: message.id,
|
||||
imageUrls,
|
||||
text: normalizeAssistantText(textParts.join("\n\n")),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.imageUrls.length > 0);
|
||||
}, [messages]);
|
||||
|
||||
const allGeneratedImages = useMemo(
|
||||
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
|
||||
[generatedGroups]
|
||||
);
|
||||
const latestGeneratedGroup = generatedGroups[generatedGroups.length - 1] || null;
|
||||
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
|
||||
const isGenerating = status === "streaming" || status === "submitted";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
|
||||
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
|
||||
}
|
||||
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const latestUrls = latestGeneratedGroup?.imageUrls || [];
|
||||
if (latestUrls.length === 0) return;
|
||||
latestUrls.forEach(async (url, index) => {
|
||||
if (!url || generatedImageKeysRef.current.has(url)) return;
|
||||
generatedImageKeysRef.current.add(url);
|
||||
try {
|
||||
const dimensions = await getImageDimensions(url);
|
||||
const nodeIndex = nodes.length + index;
|
||||
const spacing = 20;
|
||||
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||||
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||||
addNode({
|
||||
type: "image",
|
||||
position: { x, y },
|
||||
size: { width: dimensions.width, height: dimensions.height },
|
||||
data: {
|
||||
src: url,
|
||||
status: "completed",
|
||||
title: "生成结果",
|
||||
prompt: latestGeneratedGroup.text || "生成结果",
|
||||
metadata: {
|
||||
originalWidth: dimensions.originalWidth,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[AgentGenerationWorkspace] Failed to add generated image to canvas:", error);
|
||||
}
|
||||
});
|
||||
}, [addNode, latestGeneratedGroup, nodes.length]);
|
||||
|
||||
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
|
||||
const slotCount = Math.max(agentUploadSlots.length, 1);
|
||||
setAttachments((current) => {
|
||||
const slots: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: slotCount },
|
||||
(_, index) => current[index] ?? null
|
||||
);
|
||||
slots[slotIndex] = attachment;
|
||||
return slots;
|
||||
});
|
||||
}, [agentUploadSlots.length]);
|
||||
|
||||
const handleSlotFileChange = useCallback(
|
||||
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
event.currentTarget.value = "";
|
||||
return;
|
||||
}
|
||||
setSlotUploading(slotIndex);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.url) {
|
||||
throw new Error(data?.error || "上传失败");
|
||||
}
|
||||
updateAttachmentAtSlot(slotIndex, {
|
||||
url: data.url,
|
||||
name: file.name,
|
||||
contentType: file.type || "image/jpeg",
|
||||
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
|
||||
});
|
||||
toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "上传失败");
|
||||
} finally {
|
||||
setSlotUploading(null);
|
||||
event.currentTarget.value = "";
|
||||
}
|
||||
},
|
||||
[agentUploadSlots, updateAttachmentAtSlot]
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!selectedAgentPresetId) {
|
||||
toast.error("请先选择生图模式");
|
||||
return;
|
||||
}
|
||||
const uploadedAttachmentCount = attachments.filter(Boolean).length;
|
||||
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
|
||||
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
|
||||
return;
|
||||
}
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "file"; mediaType: string; name: string; url: string; assetId?: string }
|
||||
> = [];
|
||||
attachments.forEach((attachment) => {
|
||||
if (!attachment) return;
|
||||
parts.push({
|
||||
type: "file",
|
||||
mediaType: attachment.contentType || "image/jpeg",
|
||||
name: attachment.name || "参考图",
|
||||
url: attachment.url,
|
||||
assetId: attachment.assetId,
|
||||
});
|
||||
});
|
||||
let textContent = input.trim();
|
||||
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
|
||||
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
|
||||
}
|
||||
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
|
||||
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
|
||||
}
|
||||
if (textContent) {
|
||||
parts.push({ type: "text", text: textContent });
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
toast.error("请至少补充要求或上传图片");
|
||||
return;
|
||||
}
|
||||
hiddenPromptRef.current = workflowContext?.promptText
|
||||
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
|
||||
: null;
|
||||
sendMessage({ role: "user", parts });
|
||||
setInput("");
|
||||
}, [
|
||||
attachments,
|
||||
hasAgentUploadSlots,
|
||||
input,
|
||||
requiredUploadCount,
|
||||
selectedAgentPreset,
|
||||
selectedAgentPresetId,
|
||||
sendMessage,
|
||||
workflowContext?.promptText,
|
||||
workflowContext?.summaryText,
|
||||
]);
|
||||
|
||||
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
|
||||
const raw = String(imageUrl || "").trim();
|
||||
if (!raw) {
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-slate-950">
|
||||
{/* 头部 */}
|
||||
<div className="border-b border-slate-200/60 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-white">
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">生图工作台</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
补齐图片槽位后补充要求并生成
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* 左侧控制面板 */}
|
||||
<div className="w-[340px] shrink-0 overflow-y-auto border-r border-slate-200/60 p-5">
|
||||
{/* 图片槽位 */}
|
||||
<section className="mb-6">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<ImagePlus className="h-4 w-4 text-indigo-500" />
|
||||
图片槽位
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{agentUploadSlots.map((slot, index) => (
|
||||
<ImageSlot
|
||||
key={slot.label}
|
||||
slot={slot}
|
||||
index={index}
|
||||
attachment={attachments[index]}
|
||||
isUploading={slotUploading === index}
|
||||
onClick={() => fileInputRefs.current[index]?.click()}
|
||||
onRemove={(e) => {
|
||||
e.stopPropagation();
|
||||
updateAttachmentAtSlot(index, null);
|
||||
}}
|
||||
fileInputRef={(el) => (fileInputRefs.current[index] = el)}
|
||||
onFileChange={(e) => handleSlotFileChange(index, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 补充要求 */}
|
||||
<section className="mb-6">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<Sparkles className="h-4 w-4 text-indigo-500" />
|
||||
补充要求
|
||||
</h4>
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="min-h-[120px] resize-none rounded-xl border-slate-200/60 text-sm"
|
||||
placeholder="例如:高级家居电商质感,暖灰色空间,近景主图,生成 3 张。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 参数设置 */}
|
||||
<section className="mb-6">
|
||||
<h4 className="mb-3 text-sm font-semibold text-slate-800">生成参数</h4>
|
||||
<ImageParamsBar
|
||||
modelName={currentModel?.displayName}
|
||||
initialParams={imageParams}
|
||||
onParamsChange={setImageParams}
|
||||
inline
|
||||
providerType={currentModel?.providerType}
|
||||
imageGenConfig={currentModel?.imageGenConfig || undefined}
|
||||
hideAdvancedOptions={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 模型选择与生成按钮 */}
|
||||
<section className="flex items-center gap-3">
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger className="h-10 flex-1 rounded-xl border-slate-200/60">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isGenerating || !selectedAgentPresetId}
|
||||
className="h-10 gap-1.5 rounded-xl bg-slate-900 px-5 text-white shadow-lg shadow-slate-900/20 hover:bg-slate-800"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
生成
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右侧结果区域 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-slate-50/50 p-5 dark:bg-slate-900/30">
|
||||
{/* 主结果展示 */}
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-2xl bg-white shadow-sm dark:bg-slate-950">
|
||||
<AnimatePresence mode="wait">
|
||||
{isGenerating ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<ImageGenerationLoading
|
||||
imageCount={imageParams.imageCount || 1}
|
||||
maxImages={6}
|
||||
providerType={currentModel?.providerType}
|
||||
modelName={currentModel?.displayName}
|
||||
phase="processing"
|
||||
/>
|
||||
</motion.div>
|
||||
) : displayedResultUrl ? (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative flex h-full w-full items-center justify-center p-6"
|
||||
>
|
||||
<img
|
||||
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
|
||||
alt="生成结果"
|
||||
className="max-h-full max-w-full cursor-zoom-in rounded-xl object-contain shadow-lg"
|
||||
onClick={() => setFullscreenPreview(displayedResultUrl)}
|
||||
/>
|
||||
{/* 下载按钮 */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
onClick={() => handleDownloadOriginalImage(displayedResultUrl)}
|
||||
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-xl bg-white/90 px-4 text-sm font-medium text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-500">
|
||||
<Sparkles className="h-7 w-7" />
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-slate-800">
|
||||
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
|
||||
</h4>
|
||||
<p className="mt-2 max-w-sm text-sm text-slate-500">
|
||||
{selectedAgentPresetId
|
||||
? "补齐图片槽位后点击开始生成,结果会显示在这里。"
|
||||
: "选择生图模式后,再从左侧获取图片并开始生成。"}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 结果缩略图 */}
|
||||
{allGeneratedImages.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 rounded-xl bg-white p-4 shadow-sm dark:bg-slate-950"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-800">历史结果</span>
|
||||
<span className="text-xs text-slate-500">共 {allGeneratedImages.length} 张</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{allGeneratedImages.map((url) => (
|
||||
<ResultThumbnail
|
||||
key={url}
|
||||
url={url}
|
||||
isSelected={displayedResultUrl === url}
|
||||
onClick={() => setSelectedResultUrl(url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全屏预览 */}
|
||||
<AnimatePresence>
|
||||
{fullscreenPreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
|
||||
onClick={() => setFullscreenPreview(null)}
|
||||
>
|
||||
<img
|
||||
src={fullscreenPreview}
|
||||
alt="预览大图"
|
||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFullscreenPreview(null)}
|
||||
className="absolute right-6 top-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-sm transition-colors hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
components/agents/AgentPanelHeader.tsx
Normal file
97
components/agents/AgentPanelHeader.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Search, Sparkles, Wand2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentPanelHeaderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
onCreateClick?: () => void;
|
||||
createLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentPanelHeader({
|
||||
title = "智能体",
|
||||
description = "选择模式后,工作台会自动加载对应槽位和生成能力",
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "搜索智能体...",
|
||||
onCreateClick,
|
||||
createLabel = "新建智能体",
|
||||
className,
|
||||
}: AgentPanelHeaderProps) {
|
||||
return (
|
||||
<div className={cn("border-b border-slate-200/60 bg-white/50 p-5 dark:border-slate-800/60 dark:bg-slate-950/50", className)}>
|
||||
{/* 标题区域 */}
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
className="mt-1.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
</div>
|
||||
{onCreateClick && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCreateClick}
|
||||
className="h-9 gap-1.5 rounded-xl bg-slate-900 px-4 text-xs font-medium text-white shadow-md shadow-slate-900/10 transition-all hover:bg-slate-800 hover:shadow-lg hover:shadow-slate-900/20 dark:bg-indigo-600 dark:hover:bg-indigo-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{createLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
className={cn(
|
||||
"h-10 rounded-xl border-slate-200/60 bg-white pl-10 text-sm",
|
||||
"placeholder:text-slate-400",
|
||||
"focus:border-indigo-500/50 focus:ring-indigo-500/20",
|
||||
"dark:border-slate-800/60 dark:bg-slate-900/50"
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
components/agents/AgentPresetCardNew.tsx
Normal file
249
components/agents/AgentPresetCardNew.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Copy, Pencil, Sparkles, Trash2, Video, Wand2, FileText } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentPreset } from "@/types/agent";
|
||||
|
||||
interface AgentPresetCardNewProps {
|
||||
preset: AgentPreset;
|
||||
selected?: boolean;
|
||||
onSelect: (presetId: string) => void;
|
||||
onFork?: (preset: AgentPreset) => void;
|
||||
onEdit?: (preset: AgentPreset) => void;
|
||||
onDelete?: (preset: AgentPreset) => void;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function AgentPresetCardNew({
|
||||
preset,
|
||||
selected = false,
|
||||
onSelect,
|
||||
onFork,
|
||||
onEdit,
|
||||
onDelete,
|
||||
index = 0,
|
||||
}: AgentPresetCardNewProps) {
|
||||
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", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
})();
|
||||
|
||||
const typeConfig = {
|
||||
image: { icon: Wand2, label: "生图", color: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" },
|
||||
video: { icon: Video, label: "视频", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
||||
text: { icon: FileText, label: "生文", color: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" },
|
||||
};
|
||||
|
||||
const typeInfo = typeConfig[preset.type] || typeConfig.image;
|
||||
const TypeIcon = typeInfo.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: index * 0.05,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
whileHover={{ y: -4 }}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl bg-white transition-all duration-300",
|
||||
"border border-slate-200/60 dark:border-slate-800/60",
|
||||
"shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_2px_8px_rgba(0,0,0,0.04)]",
|
||||
"hover:shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_32px_rgba(0,0,0,0.08)]",
|
||||
"hover:border-indigo-200/60 dark:hover:border-indigo-800/40",
|
||||
selected && [
|
||||
"border-indigo-500/50 dark:border-indigo-400/50",
|
||||
"shadow-[0_0_0_1px_rgba(99,102,241,0.2),0_8px_24px_rgba(99,102,241,0.12)]",
|
||||
"ring-1 ring-indigo-500/20",
|
||||
]
|
||||
)}
|
||||
>
|
||||
{/* 顶部选择指示条 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 top-0 h-[3px] transition-all duration-300",
|
||||
selected
|
||||
? "bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500"
|
||||
: "bg-transparent group-hover:bg-gradient-to-r group-hover:from-indigo-500/30 group-hover:via-violet-500/30 group-hover:to-indigo-500/30"
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left"
|
||||
onClick={() => onSelect(preset.id)}
|
||||
>
|
||||
{/* 预览图区域 */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-50 dark:bg-slate-900/50">
|
||||
{previewUrl ? (
|
||||
<motion.img
|
||||
src={previewUrl}
|
||||
alt={preset.name}
|
||||
className="h-full w-full object-cover"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div className={cn(
|
||||
"flex h-16 w-16 items-center justify-center rounded-2xl",
|
||||
typeInfo.color
|
||||
)}>
|
||||
<TypeIcon className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型标签 */}
|
||||
<div className="absolute left-3 top-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"border-0 px-2.5 py-1 text-[11px] font-medium backdrop-blur-md",
|
||||
"bg-white/85 text-slate-700 shadow-sm",
|
||||
"dark:bg-slate-950/80 dark:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<TypeIcon className="mr-1 h-3 w-3" />
|
||||
{typeInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 可见性标签 */}
|
||||
<div className="absolute right-3 top-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"border-0 px-2 py-0.5 text-[10px] font-medium backdrop-blur-md",
|
||||
preset.visibility === "public"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-slate-500/10 text-slate-700 dark:text-slate-400"
|
||||
)}
|
||||
>
|
||||
{preset.visibility === "public" ? "公共" : "私有"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 悬浮操作按钮 */}
|
||||
<motion.div
|
||||
className="absolute inset-x-3 bottom-3 flex items-center justify-end gap-1.5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 + 0.2 }}
|
||||
>
|
||||
<div className="flex gap-1.5 opacity-0 transition-all duration-200 group-hover:opacity-100">
|
||||
{preset.visibility === "private" && onEdit && (
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-indigo-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-indigo-400"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(preset);
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
{preset.visibility === "private" && onDelete && (
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-rose-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-rose-400"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(preset);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
{preset.visibility === "public" && onFork && (
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-slate-700 shadow-lg backdrop-blur-sm transition-colors hover:bg-white hover:text-indigo-600 dark:bg-slate-900/95 dark:text-slate-300 dark:hover:text-indigo-400"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFork(preset);
|
||||
}}
|
||||
title="另存为我的智能体"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className="space-y-2.5 p-4">
|
||||
<div>
|
||||
<h3 className="line-clamp-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{preset.name}
|
||||
</h3>
|
||||
{preset.description && (
|
||||
<p className="mt-1 line-clamp-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{preset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-100 text-[10px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{ownerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="max-w-[80px] truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{ownerName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{publishedAt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 槽位信息 */}
|
||||
{preset.slotCount > 0 && (
|
||||
<div className="flex items-center gap-3 border-t border-slate-100 pt-2.5 dark:border-slate-800">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>{preset.slotCount} 个槽位</span>
|
||||
</div>
|
||||
{preset.referenceAssetCount && preset.referenceAssetCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>{preset.referenceAssetCount} 张参考图</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
238
components/agents/AgentPresetPanelNew.tsx
Normal file
238
components/agents/AgentPresetPanelNew.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Loader2, Sparkles, User } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { agentService } from "@/services/agents";
|
||||
import type { AgentPreset } from "@/types/agent";
|
||||
import { AgentPresetCardNew } from "./AgentPresetCardNew";
|
||||
import { AgentPresetDialog } from "./AgentPresetDialog";
|
||||
import { AgentPanelHeader } from "./AgentPanelHeader";
|
||||
import { AgentSectionHeader } from "./AgentSectionHeader";
|
||||
|
||||
interface AgentPresetPanelNewProps {
|
||||
selectedPresetId?: string | null;
|
||||
onSelectPreset: (presetId: string | null) => void;
|
||||
embedded?: boolean;
|
||||
layout?: "fill" | "inline";
|
||||
title?: string;
|
||||
description?: string;
|
||||
searchPlaceholder?: string;
|
||||
publicTitle?: string;
|
||||
privateTitle?: string;
|
||||
}
|
||||
|
||||
export function AgentPresetPanelNew({
|
||||
selectedPresetId,
|
||||
onSelectPreset,
|
||||
embedded = false,
|
||||
layout = "fill",
|
||||
title = "智能体",
|
||||
description = "选择模式后,工作台会自动加载对应槽位和生成能力",
|
||||
searchPlaceholder = "搜索智能体...",
|
||||
publicTitle = "公共智能体",
|
||||
privateTitle = "我的智能体",
|
||||
}: AgentPresetPanelNewProps) {
|
||||
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();
|
||||
};
|
||||
|
||||
const renderEmptyState = (message: string) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 p-8 text-center dark:border-slate-800 dark:bg-slate-900/30"
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
||||
<Sparkles className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{message}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderLoadingState = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
|
||||
<span className="text-xs text-slate-400">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="space-y-6 p-5">
|
||||
{/* 公共智能体区域 */}
|
||||
<section className="space-y-3">
|
||||
<AgentSectionHeader
|
||||
title={publicTitle}
|
||||
count={filteredPublicPresets.length}
|
||||
icon={<Sparkles className="h-3 w-3" />}
|
||||
delay={0.1}
|
||||
/>
|
||||
{isLoading ? (
|
||||
renderLoadingState()
|
||||
) : filteredPublicPresets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredPublicPresets.map((preset, index) => (
|
||||
<AgentPresetCardNew
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
selected={selectedPresetId === preset.id}
|
||||
onSelect={onSelectPreset}
|
||||
onFork={handleForkPreset}
|
||||
onEdit={setEditingPreset}
|
||||
onDelete={handleDeletePreset}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
renderEmptyState("暂无公共智能体")
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 私有智能体区域 */}
|
||||
<section className="space-y-3">
|
||||
<AgentSectionHeader
|
||||
title={privateTitle}
|
||||
count={filteredPrivatePresets.length}
|
||||
icon={<User className="h-3 w-3" />}
|
||||
delay={0.2}
|
||||
/>
|
||||
{isLoading ? null : filteredPrivatePresets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredPrivatePresets.map((preset, index) => (
|
||||
<AgentPresetCardNew
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
selected={selectedPresetId === preset.id}
|
||||
onSelect={onSelectPreset}
|
||||
onEdit={setEditingPreset}
|
||||
onDelete={handleDeletePreset}
|
||||
index={index + filteredPublicPresets.length}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
renderEmptyState("还没有私有智能体,点击右上角新建一个吧")
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-white dark:bg-slate-950",
|
||||
layout === "fill" ? "h-full" : "h-auto",
|
||||
embedded ? "" : "border-r border-slate-200/60 dark:border-slate-800/60"
|
||||
)}
|
||||
>
|
||||
<AgentPanelHeader
|
||||
title={title}
|
||||
description={description}
|
||||
searchValue={keyword}
|
||||
onSearchChange={setKeyword}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onCreateClick={() => setShowCreateDialog(true)}
|
||||
createLabel="新建"
|
||||
/>
|
||||
|
||||
{layout === "fill" ? (
|
||||
<ScrollArea className="flex-1">{content}</ScrollArea>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
components/agents/AgentSectionHeader.tsx
Normal file
43
components/agents/AgentSectionHeader.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSectionHeaderProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function AgentSectionHeader({
|
||||
title,
|
||||
count,
|
||||
icon,
|
||||
className,
|
||||
delay = 0,
|
||||
}: AgentSectionHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay }}
|
||||
>
|
||||
{icon && (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||
{title}
|
||||
</h3>
|
||||
{count !== undefined && (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
490
components/agents/AgentWorkflowPanelNew.tsx
Normal file
490
components/agents/AgentWorkflowPanelNew.tsx
Normal file
@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, Wand2 } 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 AgentWorkflowPanelNewProps {
|
||||
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;
|
||||
embedded?: boolean;
|
||||
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 StepCard({
|
||||
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.3, delay: index * 0.05 }}
|
||||
whileHover={{ y: -2, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl text-left transition-all duration-300",
|
||||
"border bg-white",
|
||||
selected
|
||||
? [
|
||||
"border-indigo-500/50 shadow-[0_0_0_1px_rgba(99,102,241,0.2),0_8px_24px_rgba(99,102,241,0.12)]",
|
||||
"ring-1 ring-indigo-500/20",
|
||||
]
|
||||
: [
|
||||
"border-slate-200/60 shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_2px_8px_rgba(0,0,0,0.04)]",
|
||||
"hover:border-indigo-300/50 hover:shadow-[0_0_0_1px_rgba(99,102,241,0.1),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
]
|
||||
)}
|
||||
>
|
||||
{/* 选中指示器 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full w-[3px] transition-all duration-300",
|
||||
selected
|
||||
? "bg-gradient-to-b from-indigo-500 to-violet-500"
|
||||
: "bg-transparent group-hover:bg-indigo-500/20"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 预览图 */}
|
||||
<div className="relative aspect-[16/10] w-full overflow-hidden bg-slate-50">
|
||||
{option.previewImageUrl ? (
|
||||
<motion.img
|
||||
src={option.previewImageUrl}
|
||||
alt={option.buttonName}
|
||||
className="h-full w-full object-cover"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 text-slate-400">
|
||||
<Wand2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中标记 */}
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-500 text-white shadow-lg"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 步骤标签 */}
|
||||
<div className="absolute left-2 top-2">
|
||||
<span className="rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-medium text-slate-600 shadow-sm backdrop-blur-sm">
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="space-y-1 p-3">
|
||||
<h4 className="text-sm font-semibold text-slate-900">{option.buttonName}</h4>
|
||||
{option.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-slate-500">{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkflowPanelNew({
|
||||
config,
|
||||
inputs,
|
||||
onInputsChange,
|
||||
materials,
|
||||
onMaterialsChange,
|
||||
selections,
|
||||
onSelectionsChange,
|
||||
onReset,
|
||||
embedded = false,
|
||||
onNextStep,
|
||||
onSkipStep,
|
||||
}: AgentWorkflowPanelNewProps) {
|
||||
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={cn(
|
||||
"flex h-full flex-col bg-white dark:bg-slate-950",
|
||||
embedded ? "" : "border-r border-slate-200/60 dark:border-slate-800/60"
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="border-b border-slate-200/60 bg-white/50 p-5 dark:border-slate-800/60 dark:bg-slate-950/50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
智能体工作流
|
||||
</h2>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
className="mt-1.5 max-w-md text-xs leading-relaxed text-slate-500 dark:text-slate-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
先完成工作流设定,下一步再进入生图模式执行
|
||||
</motion.p>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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-xl border-slate-200 text-xs text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
清空
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示器 */}
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>完成进度</span>
|
||||
<span className="font-medium text-indigo-600">{progress.completed}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* 基础配置 */}
|
||||
<motion.section
|
||||
className="space-y-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-indigo-100 text-[10px] font-bold text-indigo-600">
|
||||
1
|
||||
</span>
|
||||
基础配置
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<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.length > 0 ? (
|
||||
resolvedConfig.steps.map((step, stepIndex) => (
|
||||
<motion.section
|
||||
key={step.key}
|
||||
className="space-y-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 + stepIndex * 0.05 }}
|
||||
>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-indigo-100 text-[10px] font-bold text-indigo-600">
|
||||
{stepIndex + 2}
|
||||
</span>
|
||||
{step.title}
|
||||
</h3>
|
||||
{step.description && (
|
||||
<p className="text-xs text-slate-500">{step.description}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{step.options.map((option, optionIndex) => (
|
||||
<StepCard
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 p-8 text-center text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/30"
|
||||
>
|
||||
当前还没有启用的工作流步骤
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 摘要区域 */}
|
||||
{summary.length > 0 && (
|
||||
<motion.section
|
||||
className="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-4 dark:border-slate-800/60 dark:bg-slate-900/30"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-800">当前选择</h3>
|
||||
<div className="space-y-2">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={`${item.stepKey}-${item.optionKey}`}
|
||||
className="flex items-center justify-between rounded-xl bg-white px-3 py-2 text-sm shadow-sm dark:bg-slate-900"
|
||||
>
|
||||
<span className="text-slate-500">{item.stepTitle}</span>
|
||||
<span className="font-medium text-slate-900">{item.optionName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{/* Prompt 预览 */}
|
||||
<motion.section
|
||||
className="space-y-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.45 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Prompt 预览</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyPrompt}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={composerContext?.promptText || ""}
|
||||
readOnly
|
||||
rows={8}
|
||||
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
|
||||
className="resize-none rounded-xl border-slate-200/60 bg-slate-50 text-xs leading-relaxed dark:border-slate-800/60 dark:bg-slate-900/50"
|
||||
/>
|
||||
</motion.section>
|
||||
|
||||
{/* 下一步按钮 */}
|
||||
{onNextStep && (
|
||||
<motion.div
|
||||
className="flex flex-wrap justify-end gap-2 pt-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 }}
|
||||
>
|
||||
{onSkipStep && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSkipStep}
|
||||
className="h-10 gap-1.5 rounded-xl border-slate-200 text-slate-600"
|
||||
>
|
||||
跳过选择
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNextStep}
|
||||
disabled={progress.total > 0 && !progress.isComplete}
|
||||
className="h-10 gap-1.5 rounded-xl bg-slate-900 px-6 text-white shadow-lg shadow-slate-900/20 transition-all hover:bg-slate-800 hover:shadow-xl hover:shadow-slate-900/30 disabled:opacity-50 dark:bg-indigo-600 dark:hover:bg-indigo-500"
|
||||
>
|
||||
下一步:选择生图模式
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
components/agents/index-new.ts
Normal file
10
components/agents/index-new.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// 智能体新UI组件统一导出
|
||||
export { AgentPresetCardNew } from "./AgentPresetCardNew";
|
||||
export { AgentPresetPanelNew } from "./AgentPresetPanelNew";
|
||||
export { AgentWorkflowPanelNew } from "./AgentWorkflowPanelNew";
|
||||
export { AgentGenerationWorkspaceNew } from "./AgentGenerationWorkspaceNew";
|
||||
export { AgentPanelHeader } from "./AgentPanelHeader";
|
||||
export { AgentSectionHeader } from "./AgentSectionHeader";
|
||||
|
||||
// 类型导出
|
||||
export type { AgentWorkflowInputs } from "./AgentWorkflowPanelNew";
|
||||
Loading…
x
Reference in New Issue
Block a user