ai-chatbot/components/agents/AgentPresetPanel.tsx
Your Name 64cb485dfa
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine agent workflow flow
2026-03-17 18:23:32 +08:00

359 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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