Files
deerflow-code/web-code/components/angentchat/AgentCardList.tsx
T
2026-05-27 19:43:15 +08:00

595 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Bot, Edit, Eye, Plus, Search, Sparkles, Trash2, X } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
interface AgentItem {
key: string;
type: 'builtin' | 'custom' | string;
deletable: boolean;
name: string;
description: string;
default_prompt: string;
schema_hint: string;
bound_skills: string[];
order: number;
}
interface SkillItem {
key: string;
name: string;
description?: string;
}
interface AgentCardListProps {
onEdit?: (key: string) => void;
}
type TypeFilter = 'all' | 'builtin' | 'custom';
interface CreateAgentForm {
name: string;
description: string;
bound_skills: string[];
}
const INITIAL_FORM: CreateAgentForm = {
name: '',
description: '',
bound_skills: [],
};
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
export default function AgentCardList({ onEdit }: AgentCardListProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [agents, setAgents] = useState<AgentItem[]>([]);
const [skills, setSkills] = useState<SkillItem[]>([]);
const [keyword, setKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState<CreateAgentForm>(INITIAL_FORM);
const [skillKeyword, setSkillKeyword] = useState('');
const showNotice = (message: string) => {
setNotice(message);
window.setTimeout(() => {
setNotice((current) => (current === message ? null : current));
}, 2400);
};
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [agentRes, skillRes] = await Promise.all([fetch(`${backUrl}/api/agents`), fetch(`${backUrl}/api/skills`)]);
if (!agentRes.ok) {
throw new Error(`加载 MCP 失败:${agentRes.status}`);
}
const agentData = (await agentRes.json()) as AgentItem[];
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillItem[]) : [];
setAgents(agentData.sort((a, b) => a.order - b.order));
setSkills(skillData);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
const filteredAgents = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return agents.filter((agent) => {
const matchesKeyword =
!kw ||
agent.name.toLowerCase().includes(kw) ||
agent.key.toLowerCase().includes(kw) ||
agent.description.toLowerCase().includes(kw);
const matchesType = typeFilter === 'all' || agent.type === typeFilter;
return matchesKeyword && matchesType;
});
}, [agents, keyword, typeFilter]);
const filteredSkills = useMemo(() => {
const kw = skillKeyword.trim().toLowerCase();
if (!kw) {
return skills;
}
return skills.filter((skill) =>
`${skill.name} ${skill.key} ${skill.description || ''}`.toLowerCase().includes(kw),
);
}, [skillKeyword, skills]);
const resetCreateForm = () => {
setCreateForm(INITIAL_FORM);
setSkillKeyword('');
};
const openCreateModal = () => {
resetCreateForm();
setCreateOpen(true);
};
const closeCreateModal = () => {
setCreateOpen(false);
resetCreateForm();
};
const toggleSkill = (skillKey: string) => {
setCreateForm((prev) => ({
...prev,
bound_skills: prev.bound_skills.includes(skillKey)
? prev.bound_skills.filter((item) => item !== skillKey)
: [...prev.bound_skills, skillKey],
}));
};
const handleCreate = async () => {
const name = createForm.name.trim();
const description = createForm.description.trim();
if (!name) {
setError('请先输入 MCP 名称');
return;
}
setCreating(true);
setError(null);
try {
const res = await fetch(`${backUrl}/api/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description,
bound_skills: createForm.bound_skills,
}),
});
if (!res.ok) {
let detail = `新增失败:${res.status}`;
try {
const body = (await res.json()) as { detail?: string };
if (body.detail) {
detail = body.detail;
}
} catch {
// ignore json parse errors
}
throw new Error(detail);
}
await res.json();
await loadData();
closeCreateModal();
showNotice('MCP 已新增');
} catch (e) {
setError(e instanceof Error ? e.message : '新增失败');
} finally {
setCreating(false);
}
};
const handleDelete = async (agent: AgentItem) => {
if (!agent.deletable) return;
const ok = window.confirm(`确认删除 MCP “${agent.name}”吗?`);
if (!ok) return;
const res = await fetch(`${backUrl}/api/agents/${agent.key}`, { method: 'DELETE' });
if (!res.ok) {
setError(`删除失败:${res.status}`);
return;
}
await loadData();
showNotice('MCP 已删除');
};
const selectedSkillCount = createForm.bound_skills.length;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className={`mb-2 text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
MCP 智能体配置
</h1>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
管理内置与用户新增的 MCP,并支持绑定技能
</p>
</div>
<button
type="button"
onClick={openCreateModal}
className={`inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Plus className="h-4 w-4" />
新增 MCP
</button>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative max-w-xl flex-1">
<Search
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
isDark ? 'text-slate-500' : 'text-red-400'
}`}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索 MCP 名称、Key 或描述"
className={`w-full rounded-xl border py-2.5 pl-10 pr-4 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-blue-800/30 bg-slate-800/50 text-white placeholder-slate-500'
: 'border-red-200 bg-white text-gray-900 placeholder-red-300'
}`}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{[
{ key: 'all', label: '全部' },
{ key: 'builtin', label: '内置 MCP' },
{ key: 'custom', label: '用户新增' },
].map((item) => {
const active = typeFilter === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setTypeFilter(item.key as TypeFilter)}
className={`rounded-full px-3 py-1.5 text-sm transition ${
active
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-600 text-white'
: isDark
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'bg-white text-gray-600 ring-1 ring-gray-200 hover:bg-gray-50'
}`}
>
{item.label}
</button>
);
})}
</div>
</div>
{loading ? <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div> : null}
{error ? <div className="text-sm text-red-500">{error}</div> : null}
{notice ? <div className="text-sm text-emerald-500">{notice}</div> : null}
{!loading && !error ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredAgents.map((agent) => (
<div
key={agent.key}
className={`group rounded-xl border p-5 transition-all duration-300 ${
isDark
? 'border-blue-800/30 bg-slate-800/30 backdrop-blur-xl hover:border-blue-500/50'
: 'border-red-100 bg-white hover:border-red-300 hover:shadow-md'
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isDark ? 'bg-blue-500/20' : 'bg-red-100'
}`}
>
<Bot className={`h-6 w-6 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</h3>
<p className={`text-xs font-mono ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{agent.key}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
agent.type === 'builtin'
? isDark
? 'bg-cyan-500/15 text-cyan-200'
: 'bg-cyan-50 text-cyan-700'
: isDark
? 'bg-amber-500/15 text-amber-200'
: 'bg-amber-50 text-amber-700'
}`}
>
{agent.type === 'builtin' ? '内置' : '自定义'}
</span>
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
isDark ? 'bg-slate-700/60 text-slate-300' : 'bg-gray-100 text-gray-600'
}`}
>
#{agent.order + 1}
</span>
</div>
</div>
<p className={`mb-4 min-h-[40px] text-sm ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
{agent.description || '暂无描述'}
</p>
<div className="mb-4">
<div className={`mb-2 flex items-center gap-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<Sparkles className="h-3 w-3" />
已绑定技能
</div>
<div className="flex min-h-[28px] flex-wrap gap-1.5">
{agent.bound_skills.length === 0 ? (
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>未绑定</span>
) : (
agent.bound_skills.map((skillKey) => {
const skill = skills.find((item) => item.key === skillKey);
return (
<span
key={skillKey}
className={`rounded-full border px-2 py-0.5 text-xs ${
isDark
? 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
}`}
>
{skill?.name || skillKey}
</span>
);
})
)}
</div>
</div>
<div
className={`flex items-center justify-end gap-2 border-t pt-3 ${
isDark ? 'border-blue-800/30' : 'border-red-100'
}`}
>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-slate-700/60 text-slate-200 hover:bg-slate-700'
: 'border border-red-200 bg-white text-red-600 hover:bg-red-50'
}`}
>
<Eye className="h-3.5 w-3.5" />
详情
</button>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Edit className="h-3.5 w-3.5" />
编辑
</button>
{agent.deletable ? (
<button
type="button"
onClick={() => void handleDelete(agent)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'
: 'bg-rose-50 text-rose-600 hover:bg-rose-100'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
) : null}
</div>
</div>
))}
</div>
) : null}
{!loading && !error && filteredAgents.length === 0 ? (
<div className={`py-12 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的 MCP
</div>
) : null}
{createOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeCreateModal();
}
}}
>
<div
className={`max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-3xl border shadow-2xl ${
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-red-100 bg-white text-gray-900'
}`}
>
<div className="flex items-center justify-between border-b px-6 py-5 dark:border-slate-700">
<div>
<h2 className="text-lg font-semibold">新增 MCP</h2>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
输入 MCP 名称、详情并选择要绑定的技能
</p>
</div>
<button
type="button"
onClick={closeCreateModal}
className={`flex h-9 w-9 items-center justify-center rounded-xl ${
isDark ? 'hover:bg-slate-800' : 'hover:bg-gray-100'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-5 overflow-y-auto px-6 py-5">
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
MCP 名称
</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:游戏分析助手"
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
详情
</label>
<textarea
value={createForm.description}
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="一句话说明这个 MCP 的职责"
rows={4}
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label className={`block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
绑定技能
</label>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
已选 {selectedSkillCount} 项
</span>
</div>
<div className="mb-3">
<input
type="text"
value={skillKeyword}
onChange={(e) => setSkillKeyword(e.target.value)}
placeholder="搜索技能名称或描述"
className={`w-full rounded-xl border px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div
className={`max-h-72 space-y-2 overflow-y-auto rounded-2xl border p-3 ${
isDark ? 'border-slate-700 bg-slate-950/60' : 'border-gray-200 bg-gray-50/70'
}`}
>
{filteredSkills.length === 0 ? (
<div className={`py-8 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的技能
</div>
) : (
filteredSkills.map((skill) => {
const checked = createForm.bound_skills.includes(skill.key);
return (
<button
key={skill.key}
type="button"
onClick={() => toggleSkill(skill.key)}
className={`flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition ${
checked
? isDark
? 'border-emerald-500/50 bg-emerald-500/10'
: 'border-emerald-200 bg-emerald-50'
: isDark
? 'border-slate-700 bg-slate-900/50 hover:bg-slate-800/70'
: 'border-white bg-white hover:bg-gray-50'
}`}
>
<div
className={`mt-1 h-4 w-4 shrink-0 rounded border ${
checked
? 'border-emerald-500 bg-emerald-500'
: isDark
? 'border-slate-600'
: 'border-gray-300'
}`}
/>
<div className="min-w-0">
<div className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{skill.name}
<span className={`ml-2 font-mono text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{skill.key}
</span>
</div>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{skill.description || '暂无描述'}
</p>
</div>
</button>
);
})
)}
</div>
</div>
</div>
<div
className={`flex items-center justify-end gap-3 border-t px-6 py-4 ${
isDark ? 'border-slate-700' : 'border-gray-200'
}`}
>
<button
type="button"
onClick={closeCreateModal}
className={`rounded-xl px-4 py-2 text-sm ${
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
取消
</button>
<button
type="button"
onClick={() => void handleCreate()}
disabled={creating}
className={`rounded-xl px-4 py-2 text-sm font-medium text-white ${
isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'
} disabled:cursor-not-allowed disabled:opacity-60`}
>
{creating ? '创建中...' : '确认新增'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}