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

This commit is contained in:
Your Name 2026-03-17 23:40:42 +08:00
parent ea5a189ae0
commit 2e21d1b274
8 changed files with 2020 additions and 7 deletions

View File

@ -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}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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";