ai-chatbot/components/agents/AgentPresetPanelDarkV2.tsx
Your Name 043bbab84d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: streamline agent mode selection flow
2026-03-21 21:03:55 +08:00

728 lines
25 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSession } from "next-auth/react";
import {
Loader2,
Plus,
Search,
Sparkles,
Grid3X3,
Wand2,
LayoutGrid,
FolderOpen,
Menu
} from "lucide-react";
import useSWR from "swr";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset, AgentPresetLibraryCategoryNode, AgentPresetLibraryConfig } from "@/types/agent";
import { normalizeAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import {
AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID,
AGENT_PRESET_LIBRARY_MY_WORKS_ID,
getEnabledAgentPresetLibraryNodes,
getEnabledAgentPresetLibrarySections,
normalizeAgentPresetLibraryConfig,
resolveAgentPresetCategoryPath,
} from "@/lib/agents/preset-library-config";
import { AgentWorkflowShowcaseBanner } from "./AgentWorkflowShowcaseBanner";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
interface AgentPresetPanelDarkV2Props {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
onCreateClick?: () => void;
embedded?: boolean;
embeddedEntityLabel?: string;
}
const AGENT_PRESET_LIBRARY_ALL_MODES_ID = "__all_modes__";
export function AgentPresetPanelDarkV2({
selectedPresetId,
onSelectPreset,
onCreateClick,
embedded = false,
embeddedEntityLabel = "智能体",
}: AgentPresetPanelDarkV2Props) {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<string>(AGENT_PRESET_LIBRARY_ALL_MODES_ID);
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const [selectedLevel1Id, setSelectedLevel1Id] = useState("");
const [selectedLevel2Id, setSelectedLevel2Id] = useState("");
const [selectedLevel3Id, setSelectedLevel3Id] = useState("");
const { data: siteSettingsData } = useSWR<{
agentWorkflowShowcaseConfig?: unknown;
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
}>(
"/api/site-settings",
(url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json())
);
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 presetLibraryConfig = useMemo(
() => normalizeAgentPresetLibraryConfig(siteSettingsData?.agentPresetLibraryConfig),
[siteSettingsData?.agentPresetLibraryConfig]
);
const presetLibrarySections = useMemo(
() => getEnabledAgentPresetLibrarySections(presetLibraryConfig),
[presetLibraryConfig]
);
const navTabs = useMemo(() => {
const allModesTab = {
id: AGENT_PRESET_LIBRARY_ALL_MODES_ID,
label: "所有模式",
icon: Grid3X3,
isMyWorks: false,
isAllModes: true,
};
const dynamicTabs = presetLibrarySections.map((section) => ({
id: section.id,
label: section.label,
icon: LayoutGrid,
isMyWorks: false,
isAllModes: false,
}));
const myWorksTab = {
id: AGENT_PRESET_LIBRARY_MY_WORKS_ID,
label: "我的模式",
icon: FolderOpen,
isMyWorks: true,
isAllModes: false,
};
if (dynamicTabs.length === 0) {
return [allModesTab, myWorksTab];
}
return [allModesTab, ...dynamicTabs, myWorksTab];
}, [presetLibrarySections]);
const activeSection = useMemo(
() => presetLibrarySections.find((section) => section.id === activeTab) || null,
[activeTab, presetLibrarySections]
);
const level1Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(activeSection?.categories || []),
[activeSection]
);
const selectedLevel1 = useMemo(
() => level1Options.find((node) => node.id === selectedLevel1Id) || null,
[level1Options, selectedLevel1Id]
);
const level2Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedLevel1?.children || []),
[selectedLevel1]
);
const selectedLevel2 = useMemo(
() => level2Options.find((node) => node.id === selectedLevel2Id) || null,
[level2Options, selectedLevel2Id]
);
const level3Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedLevel2?.children || []),
[selectedLevel2]
);
const isAllModesTab = activeTab === AGENT_PRESET_LIBRARY_ALL_MODES_ID;
const isMyWorksTab = activeTab === AGENT_PRESET_LIBRARY_MY_WORKS_ID;
const canManagePublicPresets =
session?.user?.type === "admin" || session?.user?.type === "agent_admin";
useEffect(() => {
if (navTabs.some((tab) => tab.id === activeTab)) return;
const nextTabId = navTabs[0]?.id || AGENT_PRESET_LIBRARY_MY_WORKS_ID;
setActiveTab(nextTabId);
}, [activeTab, navTabs]);
useEffect(() => {
setSelectedLevel1Id("");
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [activeTab]);
useEffect(() => {
if (!selectedLevel1Id || level1Options.some((node) => node.id === selectedLevel1Id)) return;
setSelectedLevel1Id("");
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [level1Options, selectedLevel1Id]);
useEffect(() => {
if (!selectedLevel2Id || level2Options.some((node) => node.id === selectedLevel2Id)) return;
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [level2Options, selectedLevel2Id]);
useEffect(() => {
if (!selectedLevel3Id || level3Options.some((node) => node.id === selectedLevel3Id)) return;
setSelectedLevel3Id("");
}, [level3Options, selectedLevel3Id]);
// 根据当前标签过滤数据
const filteredPresets = useMemo(() => {
let presets: AgentPreset[] = isMyWorksTab ? data?.private || [] : data?.public || [];
if (!isMyWorksTab && !isAllModesTab) {
const fallbackSectionId =
presetLibrarySections.find((section) => section.id === AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID)?.id ||
presetLibrarySections[0]?.id ||
null;
presets = presets.filter((preset) => {
const resolvedPath = resolveAgentPresetCategoryPath({
categoryPath: preset.categoryPath || null,
config: presetLibraryConfig,
});
const presetSectionId = resolvedPath.sectionId || fallbackSectionId;
if (!presetSectionId || presetSectionId !== activeTab) {
return false;
}
if (selectedLevel1Id && resolvedPath.level1Id !== selectedLevel1Id) {
return false;
}
if (selectedLevel2Id && resolvedPath.level2Id !== selectedLevel2Id) {
return false;
}
if (selectedLevel3Id && resolvedPath.level3Id !== selectedLevel3Id) {
return false;
}
return true;
});
}
// 搜索过滤
if (keyword.trim()) {
const normalizedKeyword = keyword.trim().toLowerCase();
presets = presets.filter((preset) =>
[preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
);
}
return presets;
}, [
activeTab,
data,
isAllModesTab,
isMyWorksTab,
keyword,
presetLibraryConfig,
presetLibrarySections,
selectedLevel1Id,
selectedLevel2Id,
selectedLevel3Id,
]);
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 getCurrentTitle = () => {
if (isAllModesTab) return "所有模式";
if (isMyWorksTab) return "我的模式";
return activeSection?.label || "生图模式";
};
// 嵌入模式只渲染卡片网格
if (embedded) {
return (
<>
{/* 搜索栏 */}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder={`搜索${embeddedEntityLabel}...`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-9 rounded-lg border-0 pl-9 pr-3 text-sm",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20"
)}
/>
</div>
<Button
type="button"
onClick={() => (onCreateClick ? onCreateClick() : setShowCreateDialog(true))}
className={cn(
"h-9 shrink-0 gap-1.5 rounded-lg px-3 sm:w-auto",
"bg-gradient-to-r from-emerald-500 to-teal-500 text-white",
"hover:from-emerald-400 hover:to-teal-400"
)}
>
<Plus className="h-4 w-4" />
新建
</Button>
</div>
{/* 导航标签(简化版) */}
<div className="mb-4 flex flex-wrap gap-1">
{navTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
activeTab === tab.id
? "bg-white text-black"
: "text-white/60 hover:text-white hover:bg-white/5"
)}
>
{tab.label}
</button>
))}
</div>
{!isMyWorksTab && !isAllModesTab ? (
<div className="mb-4 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-3">
<CategoryFilterRow
label="一级"
options={level1Options}
selectedId={selectedLevel1Id}
onSelect={(value) => {
setSelectedLevel1Id(value);
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}}
/>
{selectedLevel1 ? (
<CategoryFilterRow
label="二级"
options={level2Options}
selectedId={selectedLevel2Id}
onSelect={(value) => {
setSelectedLevel2Id(value);
setSelectedLevel3Id("");
}}
/>
) : null}
{selectedLevel2 ? (
<CategoryFilterRow
label="三级"
options={level3Options}
selectedId={selectedLevel3Id}
onSelect={setSelectedLevel3Id}
/>
) : null}
</div>
) : null}
{/* 卡片网格 */}
<div className="grid grid-cols-1 gap-2.5 md:grid-cols-2 lg:grid-cols-3">
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
</div>
) : filteredPresets.length > 0 ? (
<AnimatePresence mode="popLayout">
{filteredPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={preset.visibility === "public" && !canManagePublicPresets ? handleForkPreset : undefined}
onEdit={
preset.visibility === "private" || canManagePublicPresets
? setEditingPreset
: undefined
}
onDelete={
preset.visibility === "private" || canManagePublicPresets
? handleDeletePreset
: undefined
}
index={index}
compact
/>
))}
</AnimatePresence>
) : (
<div className="col-span-full rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-8 text-center">
<div className="text-sm text-white/40">暂无{embeddedEntityLabel}</div>
<Button
type="button"
onClick={() => (onCreateClick ? onCreateClick() : setShowCreateDialog(true))}
className={cn(
"mt-4 h-9 gap-1.5 rounded-lg px-4",
"bg-gradient-to-r from-emerald-500 to-teal-500 text-white",
"hover:from-emerald-400 hover:to-teal-400"
)}
>
<Plus className="h-4 w-4" />
新建{embeddedEntityLabel}
</Button>
</div>
)}
</div>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
entityLabel={embeddedEntityLabel}
theme="dark"
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</>
);
}
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */}
<div className="flex items-center gap-3">
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-300",
isActive
? "bg-white text-black"
: "text-white/60 hover:text-white hover:bg-white/5"
)}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧操作 */}
<div className="flex items-center gap-3">
<Button
onClick={() => onCreateClick ? onCreateClick() : setShowCreateDialog(true)}
className={cn(
"h-9 gap-2 rounded-full px-5",
"bg-white text-black hover:bg-white/90",
"font-medium text-sm transition-all",
"hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]"
)}
>
<Plus className="h-4 w-4" />
新建
</Button>
</div>
</div>
</div>
</header>
{/* Hero Section - 视频/功能展示区 */}
<section className="relative pt-16">
<div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
<AgentWorkflowShowcaseBanner
config={normalizeAgentWorkflowShowcaseConfig(siteSettingsData?.agentWorkflowShowcaseConfig)}
onActionClick={() => {
const target = document.getElementById("agent-preset-grid");
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
}}
/>
</div>
</section>
{/* 智能体卡片网格区域 */}
<section className="pb-20">
<div id="agent-preset-grid" className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
{/* 搜索栏 */}
<motion.div
className="mb-8 flex items-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder="搜索生图模式..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-11 rounded-full border-0 pl-11 pr-4",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20",
"transition-all duration-300"
)}
/>
</div>
<div className="flex items-center gap-2 text-sm text-white/50">
<Grid3X3 className="h-4 w-4" />
<span>共 {filteredPresets.length} 个</span>
</div>
</motion.div>
{!isMyWorksTab && !isAllModesTab ? (
<motion.div
className="mb-6 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.35 }}
>
<CategoryFilterRow
label="一级分类"
options={level1Options}
selectedId={selectedLevel1Id}
onSelect={(value) => {
setSelectedLevel1Id(value);
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}}
/>
{selectedLevel1 ? (
<CategoryFilterRow
label="二级分类"
options={level2Options}
selectedId={selectedLevel2Id}
onSelect={(value) => {
setSelectedLevel2Id(value);
setSelectedLevel3Id("");
}}
/>
) : null}
{selectedLevel2 ? (
<CategoryFilterRow
label="三级分类"
options={level3Options}
selectedId={selectedLevel3Id}
onSelect={setSelectedLevel3Id}
/>
) : null}
</motion.div>
) : null}
{/* 区域标题 */}
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<h2 className="text-lg font-semibold text-white">{getCurrentTitle()}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPresets.length}
</span>
</motion.div>
{/* 卡片网格 */}
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-violet-500" />
</div>
) : filteredPresets.length > 0 ? (
filteredPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={preset.visibility === "public" && !canManagePublicPresets ? handleForkPreset : undefined}
onEdit={
preset.visibility === "private" || canManagePublicPresets
? setEditingPreset
: undefined
}
onDelete={
preset.visibility === "private" || canManagePublicPresets
? handleDeletePreset
: undefined
}
index={index}
/>
))
) : (
<EmptyState message={`暂无${getCurrentTitle()}`} />
)}
</AnimatePresence>
</div>
</div>
</section>
{/* 页脚 */}
<footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
<span>© 2024 AI创作平台</span>
</div>
</div>
</footer>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
entityLabel="生图模式"
theme="dark"
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="col-span-full flex flex-col items-center justify-center rounded-2xl border border-white/5 bg-white/[0.02] py-20"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Sparkles className="h-7 w-7 text-white/30" />
</div>
<p className="text-sm text-white/40">{message}</p>
</motion.div>
);
}
function CategoryFilterRow({
label,
options,
selectedId,
onSelect,
dark = true,
}: {
label: string;
options: AgentPresetLibraryCategoryNode[];
selectedId?: string;
onSelect: (value: string) => void;
dark?: boolean;
}) {
if (!options.length) return null;
return (
<div className="flex flex-wrap items-center gap-2">
<span className={cn("text-xs", dark ? "text-white/38" : "text-muted-foreground")}>
{label}
</span>
<button
type="button"
onClick={() => onSelect("")}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
!selectedId
? dark
? "border-white/30 bg-white text-black"
: "border-primary bg-primary/10 text-primary"
: dark
? "border-white/10 bg-white/[0.03] text-white/62 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground"
)}
>
全部
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onSelect(option.id)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
selectedId === option.id
? dark
? "border-emerald-300/35 bg-emerald-300/90 text-black"
: "border-primary bg-primary/10 text-primary"
: dark
? "border-white/10 bg-white/[0.03] text-white/62 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground"
)}
>
{option.name}
</button>
))}
</div>
);
}