feat(agents): 添加 Agent 预设管理功能

- 创建 Server Agents API 路由 (CRUD + presets + defaults)
- 添加 UI Agent 类型定义和 API 客户端函数
- 实现 AgentsPanel 组件 (预设/自定义 Agent 列表)
- 实现 AgentEditor 组件 (创建/编辑 Agent)
- 实现 AgentDefaultsEditor 组件 (全局默认配置)
- 集成 AgentsPanel 到 Web 和 Desktop 应用
This commit is contained in:
2025-12-12 21:23:01 +08:00
parent 9365e07df1
commit a225e66ad7
13 changed files with 2447 additions and 5 deletions
+604
View File
@@ -0,0 +1,604 @@
/**
* AgentsPanel Component
*
* Agent 预设管理面板:列出所有 Agent、查看详情、创建/编辑/删除
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
Bot,
Plus,
Settings,
ChevronDown,
ChevronRight,
Eye,
Copy,
Trash2,
Edit3,
Sparkles,
Cpu,
Layers,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Skeleton } from './Skeleton';
import { AgentEditor } from './AgentEditor';
import { AgentDefaultsEditor } from './AgentDefaultsEditor';
import {
listAgents,
getAgent,
deleteAgent,
type AgentListItem,
type AgentDetail,
} from '../api/client.js';
interface AgentsPanelProps {
onClose: () => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
// 模式颜色映射
function getModeColor(mode: AgentListItem['mode']) {
switch (mode) {
case 'primary':
return 'bg-blue-500/20 text-blue-400';
case 'subagent':
return 'bg-purple-500/20 text-purple-400';
case 'all':
return 'bg-green-500/20 text-green-400';
default:
return 'bg-gray-500/20 text-gray-400';
}
}
// 模式文字
function getModeText(mode: AgentListItem['mode']) {
switch (mode) {
case 'primary':
return 'Primary';
case 'subagent':
return 'Subagent';
case 'all':
return 'Both';
default:
return mode;
}
}
export function AgentsPanel({ onClose, responsive = false }: AgentsPanelProps) {
// 数据状态
const [agents, setAgents] = useState<AgentListItem[]>([]);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
const [agentDetails, setAgentDetails] = useState<Record<string, AgentDetail>>({});
// UI 状态
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
// 编辑器状态
const [editingAgent, setEditingAgent] = useState<{ name: string; isNew: boolean } | null>(null);
const [showDefaultsEditor, setShowDefaultsEditor] = useState(false);
// 加载 Agent 列表
const loadAgents = useCallback(async (showToast = false) => {
try {
const result = await listAgents();
if (result.success) {
setAgents(result.data);
if (showToast) {
toast.success('Agents refreshed');
}
} else {
toast.error(result.error || 'Failed to load agents');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load agents');
}
}, []);
// 初始加载
useEffect(() => {
setLoading(true);
loadAgents().finally(() => setLoading(false));
}, [loadAgents]);
// 刷新
const handleRefresh = async () => {
setRefreshing(true);
await loadAgents(true);
setRefreshing(false);
};
// 加载 Agent 详情
const loadAgentDetail = async (name: string) => {
if (agentDetails[name]) return agentDetails[name];
try {
const result = await getAgent(name);
if (result.success && result.data) {
setAgentDetails((prev) => ({ ...prev, [name]: result.data! }));
return result.data;
}
} catch (err) {
toast.error(`Failed to load agent details: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return null;
};
// 切换展开
const toggleExpanded = async (name: string) => {
const newExpanded = new Set(expandedAgents);
if (newExpanded.has(name)) {
newExpanded.delete(name);
} else {
newExpanded.add(name);
// 懒加载详情
await loadAgentDetail(name);
}
setExpandedAgents(newExpanded);
};
// 删除 Agent
const handleDelete = async (name: string) => {
if (!confirm(`Are you sure you want to delete agent "${name}"?`)) {
return;
}
setActionLoading(name);
try {
const result = await deleteAgent(name);
if (result.success) {
toast.success(`Agent "${name}" deleted`);
await loadAgents();
// 清除详情缓存
setAgentDetails((prev) => {
const newDetails = { ...prev };
delete newDetails[name];
return newDetails;
});
} else {
toast.error(result.error || 'Delete failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
} finally {
setActionLoading(null);
}
};
// 复制 Agent(打开编辑器,以新名称创建)
const handleCopy = async (name: string) => {
const detail = await loadAgentDetail(name);
if (detail) {
setEditingAgent({ name: `${name}-copy`, isNew: true });
}
};
// 编辑器保存成功回调
const handleEditorSave = () => {
setEditingAgent(null);
loadAgents();
// 清除所有详情缓存以获取最新数据
setAgentDetails({});
};
// 统计
const presetAgents = agents.filter((a) => a.isPreset);
const customAgents = agents.filter((a) => !a.isPreset);
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
<Skeleton className="h-4 w-4" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
))}
</div>
);
// Agent 项组件
const AgentItem = ({ agent }: { agent: AgentListItem }) => {
const isExpanded = expandedAgents.has(agent.name);
const isLoading = actionLoading === agent.name;
const detail = agentDetails[agent.name];
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gray-900/50 rounded-lg overflow-hidden"
>
{/* Agent Header */}
<div
className={cn(
'flex items-center gap-3 p-3',
'hover:bg-gray-900/80 transition-colors cursor-pointer'
)}
onClick={() => toggleExpanded(agent.name)}
>
{/* Expand Icon */}
<button className="text-gray-500 hover:text-gray-300">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{/* Icon */}
{agent.isPreset ? (
<Sparkles size={16} className="text-yellow-400" />
) : (
<Bot size={16} className="text-primary-400" />
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-200">{agent.name}</span>
<span className={cn('text-xs px-2 py-0.5 rounded-full', getModeColor(agent.mode))}>
{getModeText(agent.mode)}
</span>
{agent.isCustomized && (
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400">
Customized
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate">{agent.description}</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{isLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
) : (
<>
{/* View/Edit */}
{agent.isPreset && !agent.isCustomized ? (
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpanded(agent.name)}
className="text-gray-400 hover:text-gray-300"
title="View"
>
<Eye size={14} />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditingAgent({ name: agent.name, isNew: false })}
className="text-blue-400 hover:text-blue-300"
title="Edit"
>
<Edit3 size={14} />
</Button>
)}
{/* Copy */}
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(agent.name)}
className="text-gray-400 hover:text-gray-300"
title="Copy"
>
<Copy size={14} />
</Button>
{/* Delete (only for custom agents) */}
{!agent.isPreset && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(agent.name)}
className="text-red-400 hover:text-red-300"
title="Delete"
>
<Trash2 size={14} />
</Button>
)}
</>
)}
</div>
</div>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50 space-y-3">
{detail ? (
<>
{/* Model Info */}
{detail.model && (
<div className="flex items-start gap-2">
<Cpu size={14} className="text-gray-500 mt-0.5" />
<div className="text-xs">
<span className="text-gray-400">Model:</span>{' '}
<span className="text-gray-300">
{detail.model.provider && `${detail.model.provider}/`}
{detail.model.model || 'default'}
</span>
{detail.model.temperature !== undefined && (
<span className="text-gray-500 ml-2">
temp: {detail.model.temperature}
</span>
)}
</div>
</div>
)}
{/* Tools Config */}
{detail.tools && (
<div className="flex items-start gap-2">
<Layers size={14} className="text-gray-500 mt-0.5" />
<div className="text-xs">
<span className="text-gray-400">Tools:</span>{' '}
{detail.tools.enabled ? (
<span className="text-green-400">
Only: {detail.tools.enabled.join(', ')}
</span>
) : detail.tools.disabled ? (
<span className="text-yellow-400">
Disabled: {detail.tools.disabled.join(', ')}
</span>
) : (
<span className="text-gray-300">All enabled</span>
)}
{detail.tools.noTask && (
<span className="text-red-400 ml-2">(No nested tasks)</span>
)}
</div>
</div>
)}
{/* Max Steps */}
{detail.maxSteps && (
<div className="text-xs">
<span className="text-gray-400">Max Steps:</span>{' '}
<span className="text-gray-300">{detail.maxSteps}</span>
</div>
)}
{/* Prompt Preview */}
{detail.prompt && (
<div className="text-xs">
<span className="text-gray-400">System Prompt:</span>
<pre className="mt-1 p-2 bg-gray-800/50 rounded text-gray-300 overflow-x-auto max-h-32 text-[11px] leading-relaxed">
{detail.prompt.slice(0, 500)}
{detail.prompt.length > 500 && '...'}
</pre>
</div>
)}
</>
) : (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
// 如果正在编辑 Agent
if (editingAgent) {
return (
<AgentEditor
agentName={editingAgent.isNew ? undefined : editingAgent.name}
defaultName={editingAgent.isNew ? editingAgent.name : undefined}
copyFrom={editingAgent.isNew ? editingAgent.name.replace(/-copy$/, '') : undefined}
onClose={() => setEditingAgent(null)}
onSave={handleEditorSave}
responsive={responsive}
/>
);
}
// 如果正在编辑全局默认配置
if (showDefaultsEditor) {
return (
<AgentDefaultsEditor
onClose={() => setShowDefaultsEditor(false)}
onSave={() => {
setShowDefaultsEditor(false);
loadAgents();
}}
responsive={responsive}
/>
);
}
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-2xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Bot size={20} className="text-primary-400" />
Agent Presets
</h2>
<p className="text-xs text-gray-500">
{agents.length} agents ({presetAgents.length} preset, {customAgents.length} custom)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setShowDefaultsEditor(true)}
title="Global Defaults"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<Settings size={18} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingAgent({ name: '', isNew: true })}
title="New Agent"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<Plus size={18} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Agent List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<Bot size={48} className="mb-4 opacity-50" />
<p className="text-center">No agents available</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setEditingAgent({ name: '', isNew: true })}
>
<Plus size={16} className="mr-2" />
Create Agent
</Button>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
>
{/* Preset Agents */}
{presetAgents.length > 0 && (
<div>
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-2">
<Sparkles size={12} />
Preset Agents
</h3>
<div className="space-y-2">
{presetAgents.map((agent) => (
<AgentItem key={agent.name} agent={agent} />
))}
</div>
</div>
)}
{/* Custom Agents */}
<div>
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-2">
<Bot size={12} />
Custom Agents
</h3>
{customAgents.length > 0 ? (
<div className="space-y-2">
{customAgents.map((agent) => (
<AgentItem key={agent.name} agent={agent} />
))}
</div>
) : (
<div className="text-center py-6 text-gray-500 text-sm">
<p>No custom agents yet</p>
<Button
variant="ghost"
size="sm"
className="mt-2"
onClick={() => setEditingAgent({ name: '', isNew: true })}
>
<Plus size={14} className="mr-1" />
Create one
</Button>
</div>
)}
</div>
</motion.div>
)}
</div>
{/* Footer Info */}
<div
className={cn(
'border-t border-gray-700 text-xs text-gray-500 text-center',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
Config stored in{' '}
<code className="font-mono bg-gray-900 px-1 rounded">.ai-assist/agents.json</code>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}