595 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|