359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import useSWR from "swr";
|
||
import { Loader2, Plus, Sparkles } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { fetcher } from "@/lib/utils";
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { agentService } from "@/services/agents";
|
||
import type { AgentPreset } from "@/types/agent";
|
||
import { AgentPresetCard } from "@/components/agents/AgentPresetCard";
|
||
import { AgentPresetDialog } from "@/components/agents/AgentPresetDialog";
|
||
|
||
interface AgentPresetPanelProps {
|
||
selectedPresetId?: string | null;
|
||
onSelectPreset: (presetId: string | null) => void;
|
||
embedded?: boolean;
|
||
title?: string;
|
||
description?: string;
|
||
searchPlaceholder?: string;
|
||
publicTitle?: string;
|
||
privateTitle?: string;
|
||
}
|
||
|
||
export function AgentPresetPanel({
|
||
selectedPresetId,
|
||
onSelectPreset,
|
||
embedded = false,
|
||
title = "智能体",
|
||
description = "选择智能体后,右侧对话框会按其要求显示上传槽位",
|
||
searchPlaceholder = "搜索智能体",
|
||
publicTitle = "公共智能体",
|
||
privateTitle = "我的智能体",
|
||
}: AgentPresetPanelProps) {
|
||
const [keyword, setKeyword] = useState("");
|
||
const [categoryFilters, setCategoryFilters] = useState({
|
||
productCategory: "",
|
||
audienceTag: "",
|
||
usageScenario: "",
|
||
brand: "",
|
||
});
|
||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
|
||
const { data: siteSettingsData } = useSWR<{
|
||
agentCategoryTreeConfig?: {
|
||
productCategories?: string[];
|
||
audienceTags?: string[];
|
||
usageScenarios?: string[];
|
||
brands?: string[];
|
||
};
|
||
agentSecondaryTypeConfig?: {
|
||
image?: string[];
|
||
video?: string[];
|
||
text?: string[];
|
||
};
|
||
}>("/api/site-settings", fetcher);
|
||
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 categoryTreeConfig = useMemo(
|
||
() => ({
|
||
productCategories: siteSettingsData?.agentCategoryTreeConfig?.productCategories || [],
|
||
audienceTags: siteSettingsData?.agentCategoryTreeConfig?.audienceTags || [],
|
||
usageScenarios: siteSettingsData?.agentCategoryTreeConfig?.usageScenarios || [],
|
||
brands: siteSettingsData?.agentCategoryTreeConfig?.brands || [],
|
||
}),
|
||
[siteSettingsData?.agentCategoryTreeConfig]
|
||
);
|
||
const secondaryTypeConfig = useMemo(
|
||
() => ({
|
||
image: siteSettingsData?.agentSecondaryTypeConfig?.image || [],
|
||
video: siteSettingsData?.agentSecondaryTypeConfig?.video || [],
|
||
text: siteSettingsData?.agentSecondaryTypeConfig?.text || [],
|
||
}),
|
||
[siteSettingsData?.agentSecondaryTypeConfig]
|
||
);
|
||
const filteredPublicPresets = useMemo(() => {
|
||
return (data?.public || []).filter((preset) => {
|
||
const path = preset.categoryPath || {};
|
||
const matchedKeyword =
|
||
!keyword.trim() ||
|
||
preset.name.toLowerCase().includes(keyword.trim().toLowerCase()) ||
|
||
[
|
||
path.productCategory,
|
||
path.audienceTag,
|
||
path.usageScenario,
|
||
path.brand,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(keyword.trim().toLowerCase());
|
||
const matchedCategory =
|
||
(!categoryFilters.productCategory || path.productCategory === categoryFilters.productCategory) &&
|
||
(!categoryFilters.audienceTag || path.audienceTag === categoryFilters.audienceTag) &&
|
||
(!categoryFilters.usageScenario || path.usageScenario === categoryFilters.usageScenario) &&
|
||
(!categoryFilters.brand || path.brand === categoryFilters.brand);
|
||
return matchedCategory && matchedKeyword;
|
||
});
|
||
}, [categoryFilters, data?.public, keyword]);
|
||
const filteredPrivatePresets = useMemo(() => {
|
||
return (data?.private || []).filter((preset) => {
|
||
const matchedKeyword =
|
||
!keyword.trim() ||
|
||
preset.name.toLowerCase().includes(keyword.trim().toLowerCase()) ||
|
||
String(preset.category?.name || "")
|
||
.toLowerCase()
|
||
.includes(keyword.trim().toLowerCase());
|
||
return matchedKeyword;
|
||
});
|
||
}, [data?.private, keyword]);
|
||
|
||
const handleForkPreset = async (preset: AgentPreset) => {
|
||
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
|
||
if (!result.success || !result.data) {
|
||
toast.error(result.error || "复制智能体失败");
|
||
return;
|
||
}
|
||
toast.success("已另存为我的智能体");
|
||
await mutate();
|
||
onSelectPreset(result.data.id);
|
||
};
|
||
|
||
const handleDeletePreset = async (preset: AgentPreset) => {
|
||
if (!window.confirm(`确定删除智能体「${preset.name}」吗?`)) {
|
||
return;
|
||
}
|
||
|
||
const result = await agentService.deletePreset(preset.id);
|
||
if (!result.success) {
|
||
toast.error(result.error || "删除智能体失败");
|
||
return;
|
||
}
|
||
|
||
toast.success("智能体已删除");
|
||
if (selectedPresetId === preset.id) {
|
||
onSelectPreset(null);
|
||
}
|
||
await mutate();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className={cn(
|
||
"flex h-full flex-col bg-white dark:bg-zinc-950",
|
||
embedded ? "" : "border-r"
|
||
)}
|
||
>
|
||
<div className="border-b p-4">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||
{title}
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{description}
|
||
</div>
|
||
</div>
|
||
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||
<Plus className="mr-1 h-4 w-4" />
|
||
新建
|
||
</Button>
|
||
</div>
|
||
<Input
|
||
placeholder={searchPlaceholder}
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<ScrollArea className="flex-1">
|
||
<div className="space-y-5 p-4">
|
||
<section className="space-y-3">
|
||
<div className="space-y-3">
|
||
<div className="text-sm font-medium">{publicTitle}</div>
|
||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||
<Select
|
||
value={categoryFilters.productCategory || "__none__"}
|
||
onValueChange={(value) =>
|
||
setCategoryFilters((prev) => ({
|
||
...prev,
|
||
productCategory: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="产品品类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">全部产品品类</SelectItem>
|
||
{categoryTreeConfig.productCategories.map((item) => (
|
||
<SelectItem key={item} value={item}>
|
||
{item}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={categoryFilters.audienceTag || "__none__"}
|
||
onValueChange={(value) =>
|
||
setCategoryFilters((prev) => ({
|
||
...prev,
|
||
audienceTag: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="人群标签" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">全部人群标签</SelectItem>
|
||
{categoryTreeConfig.audienceTags.map((item) => (
|
||
<SelectItem key={item} value={item}>
|
||
{item}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={categoryFilters.usageScenario || "__none__"}
|
||
onValueChange={(value) =>
|
||
setCategoryFilters((prev) => ({
|
||
...prev,
|
||
usageScenario: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="使用场景" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">全部使用场景</SelectItem>
|
||
{categoryTreeConfig.usageScenarios.map((item) => (
|
||
<SelectItem key={item} value={item}>
|
||
{item}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={categoryFilters.brand || "__none__"}
|
||
onValueChange={(value) =>
|
||
setCategoryFilters((prev) => ({
|
||
...prev,
|
||
brand: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="品牌" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">全部品牌</SelectItem>
|
||
{categoryTreeConfig.brands.map((item) => (
|
||
<SelectItem key={item} value={item}>
|
||
{item}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : filteredPublicPresets.length > 0 ? (
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
{filteredPublicPresets.map((preset) => (
|
||
<AgentPresetCard
|
||
key={preset.id}
|
||
preset={preset}
|
||
selected={selectedPresetId === preset.id}
|
||
onSelect={onSelectPreset}
|
||
onFork={handleForkPreset}
|
||
onEdit={setEditingPreset}
|
||
onDelete={handleDeletePreset}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||
暂无公共智能体
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<div className="text-sm font-medium">{privateTitle}</div>
|
||
{isLoading ? null : filteredPrivatePresets.length > 0 ? (
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
{filteredPrivatePresets.map((preset) => (
|
||
<AgentPresetCard
|
||
key={preset.id}
|
||
preset={preset}
|
||
selected={selectedPresetId === preset.id}
|
||
onSelect={onSelectPreset}
|
||
onEdit={setEditingPreset}
|
||
onDelete={handleDeletePreset}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||
还没有私有智能体,先新建一个吧
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
<AgentPresetDialog
|
||
open={showCreateDialog || Boolean(editingPreset)}
|
||
onOpenChange={(nextOpen) => {
|
||
if (!nextOpen) {
|
||
setShowCreateDialog(false);
|
||
setEditingPreset(null);
|
||
return;
|
||
}
|
||
setShowCreateDialog(true);
|
||
}}
|
||
categoryTreeConfig={categoryTreeConfig}
|
||
secondaryTypeConfig={secondaryTypeConfig}
|
||
initialPreset={editingPreset}
|
||
onSaved={async (preset) => {
|
||
await mutate();
|
||
onSelectPreset(preset.id);
|
||
setEditingPreset(null);
|
||
setShowCreateDialog(false);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|