906 lines
43 KiB
TypeScript
906 lines
43 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { adminAPI } from '@/api';
|
||
|
||
interface Agent {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
source: string;
|
||
publisher_id: string;
|
||
publisher_name?: string;
|
||
description?: string;
|
||
icon?: string;
|
||
tutorial?: string;
|
||
price: number;
|
||
is_member_free: boolean;
|
||
join_promotion: boolean;
|
||
status: string;
|
||
rating_avg: number;
|
||
review_count: number;
|
||
install_count: number;
|
||
skill_content?: string;
|
||
agent_endpoint?: string;
|
||
config?: AgentConfig;
|
||
linked_skills?: LinkedSkill[];
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
}
|
||
|
||
interface AgentConfig {
|
||
system_prompt?: string;
|
||
temperature?: number;
|
||
max_tokens?: number;
|
||
model_id?: string;
|
||
skills?: string[];
|
||
tools?: string[];
|
||
memory_enabled?: boolean;
|
||
reasoning_enabled?: boolean;
|
||
}
|
||
|
||
interface LinkedSkill {
|
||
id: string;
|
||
skill_name: string;
|
||
state: string;
|
||
version: number;
|
||
}
|
||
|
||
interface AgentStats {
|
||
total_agents: number;
|
||
published_agents: number;
|
||
pending_agents: number;
|
||
official_agents: number;
|
||
user_agents: number;
|
||
total_installs: number;
|
||
average_rating: number;
|
||
by_source: Record<string, number>;
|
||
}
|
||
|
||
interface AvailableModel {
|
||
id: string;
|
||
name: string;
|
||
provider: string;
|
||
model_type?: string;
|
||
}
|
||
|
||
const AGENT_TYPES = [
|
||
{ id: 'skill', label: '技能' },
|
||
{ id: 'agent', label: 'Agent' },
|
||
{ id: 'mcp', label: 'MCP' },
|
||
];
|
||
|
||
const Agents: React.FC = () => {
|
||
const [agents, setAgents] = useState<Agent[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [loading, setLoading] = useState(false);
|
||
const [stats, setStats] = useState<AgentStats | null>(null);
|
||
|
||
// Filters
|
||
const [keyword, setKeyword] = useState('');
|
||
const [filterStatus, setFilterStatus] = useState('');
|
||
const [filterSource, setFilterSource] = useState('');
|
||
|
||
// Modal
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
description: '',
|
||
icon: '',
|
||
price: '0',
|
||
is_member_free: false,
|
||
system_prompt: '',
|
||
temperature: '0.7',
|
||
max_tokens: '2048',
|
||
model_id: '',
|
||
skills: [] as string[],
|
||
memory_enabled: false,
|
||
reasoning_enabled: false,
|
||
});
|
||
|
||
// Detail drawer
|
||
const [showDetail, setShowDetail] = useState(false);
|
||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||
|
||
// Available skills for linking
|
||
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||
// Available MCP servers for tool_policy sandbox allowlist
|
||
const [availableMcps, setAvailableMcps] = useState<any[]>([]);
|
||
// Tool policy (sandbox) — null means disabled / no policy
|
||
const [toolPolicy, setToolPolicy] = useState<any>(null);
|
||
// Phase 3.3: orchestration graph editor (JSON text + error)
|
||
const [orchestrationText, setOrchestrationText] = useState<string>('');
|
||
const [orchestrationError, setOrchestrationError] = useState<string>('');
|
||
|
||
// Toast
|
||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||
setToast({ msg, type });
|
||
setTimeout(() => setToast(null), 3000);
|
||
};
|
||
|
||
// Load agents
|
||
const loadAgents = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: any = { page, page_size: 15 };
|
||
if (keyword) params.keyword = keyword;
|
||
if (filterStatus) params.status = filterStatus;
|
||
if (filterSource) params.source = filterSource;
|
||
const result = await adminAPI.getAdminAgents(params) as any;
|
||
const data = result.data || result;
|
||
setAgents(data.items || []);
|
||
setTotal(data.total || 0);
|
||
} catch (e: any) {
|
||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Load stats
|
||
const loadStats = async () => {
|
||
try {
|
||
const result = await adminAPI.getAdminAgentsStats() as any;
|
||
const data = result.data || result;
|
||
setStats(data);
|
||
} catch {}
|
||
};
|
||
|
||
// Load available skills & models
|
||
const loadAvailable = async () => {
|
||
try {
|
||
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
|
||
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
||
adminAPI.getAdminAvailableModels() as any,
|
||
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
|
||
]);
|
||
const skills = (skillsResult.data || skillsResult).items || [];
|
||
const models = (modelsResult.data || modelsResult) || [];
|
||
const mcps = (mcpsResult.data || mcpsResult).items || [];
|
||
setAvailableSkills(skills);
|
||
setAvailableModels(models);
|
||
setAvailableMcps(mcps);
|
||
} catch {}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadAgents();
|
||
}, [page, keyword, filterStatus, filterSource]);
|
||
|
||
useEffect(() => {
|
||
loadStats();
|
||
}, []);
|
||
|
||
// Search
|
||
const handleSearch = () => {
|
||
setPage(1);
|
||
loadAgents();
|
||
};
|
||
|
||
// Open create
|
||
const openCreate = async () => {
|
||
setEditingAgent(null);
|
||
await loadAvailable();
|
||
setForm({
|
||
name: '',
|
||
description: '',
|
||
icon: '',
|
||
price: '0',
|
||
is_member_free: false,
|
||
system_prompt: '你是一个智能助手。',
|
||
temperature: '0.7',
|
||
max_tokens: '2048',
|
||
model_id: '',
|
||
skills: [],
|
||
memory_enabled: false,
|
||
reasoning_enabled: false,
|
||
});
|
||
setToolPolicy(null);
|
||
setOrchestrationText('');
|
||
setOrchestrationError('');
|
||
setShowModal(true);
|
||
};
|
||
|
||
// Open edit
|
||
const openEdit = async (agent: Agent) => {
|
||
setEditingAgent(agent);
|
||
await loadAvailable();
|
||
await openEditLoadPolicy(agent.id);
|
||
await openEditLoadOrchestration(agent.id);
|
||
|
||
const cfg = agent.config || {};
|
||
setForm({
|
||
name: agent.name,
|
||
description: agent.description || '',
|
||
icon: agent.icon || '',
|
||
price: String(agent.price),
|
||
is_member_free: agent.is_member_free,
|
||
system_prompt: cfg.system_prompt || '',
|
||
temperature: String(cfg.temperature || 0.7),
|
||
max_tokens: String(cfg.max_tokens || 2048),
|
||
model_id: cfg.model_id || '',
|
||
skills: cfg.skills || [],
|
||
memory_enabled: cfg.memory_enabled || false,
|
||
reasoning_enabled: cfg.reasoning_enabled || false,
|
||
});
|
||
setShowModal(true);
|
||
};
|
||
|
||
// Fetch existing tool_policy (sandbox) for this agent
|
||
const openEditLoadPolicy = async (agentId: string) => {
|
||
try {
|
||
const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any;
|
||
setToolPolicy(tpRes?.data?.tool_policy || null);
|
||
} catch {
|
||
setToolPolicy(null);
|
||
}
|
||
};
|
||
// Phase 3.3: 载入已有编排图
|
||
const openEditLoadOrchestration = async (agentId: string) => {
|
||
try {
|
||
const oRes = await adminAPI.getAgentOrchestration(agentId) as any;
|
||
const og = oRes?.data?.orchestration;
|
||
setOrchestrationText(og ? JSON.stringify(og, null, 2) : '');
|
||
setOrchestrationError('');
|
||
} catch {
|
||
setOrchestrationText('');
|
||
}
|
||
};
|
||
const openDetail = async (agent: Agent) => {
|
||
setDetailAgent(agent);
|
||
setShowDetail(true);
|
||
};
|
||
|
||
// Save agent
|
||
const handleSave = async () => {
|
||
if (!form.name.trim()) {
|
||
showToast('Agent 名称不能为空', 'error');
|
||
return;
|
||
}
|
||
|
||
const payload: any = {
|
||
name: form.name,
|
||
description: form.description,
|
||
icon: form.icon || undefined,
|
||
price: parseFloat(form.price) || 0,
|
||
is_member_free: form.is_member_free,
|
||
config: {
|
||
system_prompt: form.system_prompt,
|
||
temperature: parseFloat(form.temperature) || 0.7,
|
||
max_tokens: parseInt(form.max_tokens) || 2048,
|
||
model_id: form.model_id || undefined,
|
||
skills: form.skills,
|
||
memory_enabled: form.memory_enabled,
|
||
reasoning_enabled: form.reasoning_enabled,
|
||
},
|
||
};
|
||
|
||
try {
|
||
if (editingAgent) {
|
||
await adminAPI.updateAdminAgent(editingAgent.id, payload);
|
||
// Phase 3.1: persist tool_policy sandbox (null clears it)
|
||
try {
|
||
await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy);
|
||
showToast('Agent 已更新');
|
||
} catch (pe: any) {
|
||
showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error');
|
||
}
|
||
} else {
|
||
await adminAPI.createAdminAgent(payload);
|
||
showToast('Agent 创建成功');
|
||
}
|
||
setShowModal(false);
|
||
loadAgents();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Toggle agent
|
||
const handleToggle = async (agent: Agent) => {
|
||
const isActive = agent.status === 'published';
|
||
try {
|
||
await adminAPI.toggleAdminAgent(agent.id, !isActive);
|
||
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
|
||
loadAgents();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`操作失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Delete agent
|
||
const handleDelete = async (agent: Agent) => {
|
||
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
|
||
try {
|
||
await adminAPI.deleteAdminAgent(agent.id);
|
||
showToast('Agent 已删除');
|
||
loadAgents();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Toggle skill in selection
|
||
const toggleSkill = (skillId: string) => {
|
||
setForm(f => ({
|
||
...f,
|
||
skills: f.skills.includes(skillId)
|
||
? f.skills.filter(s => s !== skillId)
|
||
: [...f.skills, skillId],
|
||
}));
|
||
};
|
||
|
||
const PAGE_SIZE = 15;
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
|
||
const statusColor = (status: string) => {
|
||
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
|
||
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
|
||
return { bg: '#FEE2E2', color: '#EF4444' };
|
||
};
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div className="toast" style={{
|
||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||
padding: '12px 20px', borderRadius: 8,
|
||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||
}}>
|
||
{toast.msg}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="admin-page-header">
|
||
<div>
|
||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Agent 管理</h2>
|
||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
{stats && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #6366F1' }}>
|
||
<div className="stat-value">{stats.total_agents}</div>
|
||
<div className="stat-label">Agent 总数</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #10B981' }}>
|
||
<div className="stat-value">{stats.published_agents}</div>
|
||
<div className="stat-label">已发布</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #F59E0B' }}>
|
||
<div className="stat-value">{stats.pending_agents}</div>
|
||
<div className="stat-label">待发布</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||
<div className="stat-value">{stats.total_installs.toLocaleString()}</div>
|
||
<div className="stat-label">总安装量</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||
<div className="stat-value">{stats.average_rating.toFixed(1)}</div>
|
||
<div className="stat-label">平均评分</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #EC4899' }}>
|
||
<div className="stat-value">{stats.official_agents} / {stats.user_agents}</div>
|
||
<div className="stat-label">官方 / 用户</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||
<input className="admin-form-input" placeholder="搜索名称、描述..."
|
||
value={keyword} onChange={e => setKeyword(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||
style={{ maxWidth: 280 }} />
|
||
<select className="admin-form-input" value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
|
||
style={{ maxWidth: 140 }}>
|
||
<option value="">全部状态</option>
|
||
<option value="published">已发布</option>
|
||
<option value="pending">待发布</option>
|
||
<option value="removed">已下架</option>
|
||
</select>
|
||
<select className="admin-form-input" value={filterSource} onChange={e => { setFilterSource(e.target.value); setPage(1); }}
|
||
style={{ maxWidth: 140 }}>
|
||
<option value="">全部来源</option>
|
||
<option value="official">官方</option>
|
||
<option value="user">用户</option>
|
||
</select>
|
||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||
{(keyword || filterStatus || filterSource) && (
|
||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterStatus(''); setFilterSource(''); setPage(1); }}>
|
||
清空
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>智能体</th>
|
||
<th>类型</th>
|
||
<th>来源</th>
|
||
<th>状态</th>
|
||
<th>价格</th>
|
||
<th>安装量</th>
|
||
<th>评分</th>
|
||
<th>更新时间</th>
|
||
<th style={{ width: 240 }}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||
) : agents.length === 0 ? (
|
||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无 Agent</td></tr>
|
||
) : agents.map(agent => {
|
||
const sc = statusColor(agent.status);
|
||
return (
|
||
<tr key={agent.id}>
|
||
<td>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{agent.icon ? (
|
||
<img src={agent.icon} alt="" style={{ width: 32, height: 32, borderRadius: 8, objectFit: 'cover' }} />
|
||
) : (
|
||
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#EEF2FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
|
||
🤖
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{agent.name}</div>
|
||
{agent.description && (
|
||
<div style={{ fontSize: 12, color: '#6B7280', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{agent.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 10, background: '#EEF2FF', color: '#6366F1' }}>
|
||
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span style={{ fontSize: 12, color: agent.source === 'official' ? '#10B981' : '#6B7280' }}>
|
||
{agent.source === 'official' ? '官方' : '用户'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12, background: sc.bg, color: sc.color, fontWeight: 500 }}>
|
||
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
|
||
</span>
|
||
</td>
|
||
<td>{agent.price === 0 ? <span style={{ color: '#10B981' }}>免费</span> : `¥${agent.price}`}</td>
|
||
<td>{agent.install_count.toLocaleString()}</td>
|
||
<td>{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'}</td>
|
||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||
</td>
|
||
<td>
|
||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
<button className="admin-btn small" onClick={() => openDetail(agent)}>详情</button>
|
||
<button className="admin-btn small" onClick={() => openEdit(agent)}>编辑</button>
|
||
<button
|
||
className={`admin-btn small ${agent.status === 'published' ? 'warning' : 'success'}`}
|
||
onClick={() => handleToggle(agent)}
|
||
>
|
||
{agent.status === 'published' ? '禁用' : '启用'}
|
||
</button>
|
||
<button className="admin-btn small danger" onClick={() => handleDelete(agent)}>删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="admin-pagination">
|
||
<span style={{ color: '#6B7280', fontSize: 13 }}>共 {total} 条,第 {page}/{totalPages} 页</span>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||
return p <= totalPages && (
|
||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`} onClick={() => setPage(p)}>{p}</button>
|
||
);
|
||
})}
|
||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create/Edit Modal */}
|
||
{showModal && (
|
||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>{editingAgent ? '编辑 Agent' : '创建 Agent'}</h3>
|
||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||
<div className="form-group">
|
||
<label className="form-label">名称 *</label>
|
||
<input className="admin-form-input" value={form.name}
|
||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||
placeholder="例如: 智能代码审查助手" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">图标 URL</label>
|
||
<input className="admin-form-input" value={form.icon}
|
||
onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
|
||
placeholder="https://..." />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">价格(元,0=免费)</label>
|
||
<input className="admin-form-input" type="number" value={form.price}
|
||
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={form.is_member_free}
|
||
onChange={e => setForm(f => ({ ...f, is_member_free: e.target.checked }))} />
|
||
会员免费
|
||
</label>
|
||
</div>
|
||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||
<label className="form-label">描述</label>
|
||
<textarea className="admin-form-input" rows={2} value={form.description}
|
||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||
placeholder="描述 Agent 的功能和使用场景..." />
|
||
</div>
|
||
</div>
|
||
|
||
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||
|
||
<div style={{ fontWeight: 600, marginBottom: 12 }}>⚙️ Agent 配置</div>
|
||
|
||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||
<label className="form-label">系统提示词</label>
|
||
<textarea className="admin-form-input"
|
||
value={form.system_prompt}
|
||
onChange={e => setForm(f => ({ ...f, system_prompt: e.target.value }))}
|
||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 120 }}
|
||
placeholder="定义 Agent 的角色、行为和能力..." />
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||
<div className="form-group">
|
||
<label className="form-label">模型</label>
|
||
<select className="admin-form-input" value={form.model_id}
|
||
onChange={e => setForm(f => ({ ...f, model_id: e.target.value }))}>
|
||
<option value="">默认模型</option>
|
||
{availableModels.map(m => (
|
||
<option key={m.id} value={m.id}>{m.name} ({m.provider})</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">温度参数</label>
|
||
<input className="admin-form-input" type="number" step="0.1" min="0" max="2"
|
||
value={form.temperature}
|
||
onChange={e => setForm(f => ({ ...f, temperature: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">最大令牌数</label>
|
||
<input className="admin-form-input" type="number"
|
||
value={form.max_tokens}
|
||
onChange={e => setForm(f => ({ ...f, max_tokens: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={form.memory_enabled}
|
||
onChange={e => setForm(f => ({ ...f, memory_enabled: e.target.checked }))} />
|
||
记忆能力
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={form.reasoning_enabled}
|
||
onChange={e => setForm(f => ({ ...f, reasoning_enabled: e.target.checked }))} />
|
||
推理能力
|
||
</label>
|
||
</div>
|
||
|
||
{/* Linked Skills */}
|
||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||
<label className="form-label">关联技能(可多选)</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||
{availableSkills.length === 0 ? (
|
||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
|
||
) : availableSkills.map(skill => (
|
||
<label key={skill.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
|
||
borderRadius: 12, fontSize: 12, cursor: 'pointer',
|
||
background: form.skills.includes(skill.id) ? '#EEF2FF' : '#F9FAFB',
|
||
border: `1px solid ${form.skills.includes(skill.id) ? '#6366F1' : '#E5E7EB'}`,
|
||
color: form.skills.includes(skill.id) ? '#6366F1' : '#374151',
|
||
}}>
|
||
<input type="checkbox" style={{ display: 'none' }}
|
||
checked={form.skills.includes(skill.id)}
|
||
onChange={() => toggleSkill(skill.id)} />
|
||
{skill.skill_name}
|
||
{skill.state !== 'baby' && (
|
||
<span style={{ fontSize: 10, background: '#E5E7EB', padding: '1px 4px', borderRadius: 4, marginLeft: 4 }}>
|
||
{skill.state}
|
||
</span>
|
||
)}
|
||
</label>
|
||
))}
|
||
</div>
|
||
{form.skills.length > 0 && (
|
||
<div style={{ marginTop: 6, fontSize: 12, color: '#6366F1' }}>
|
||
已选 {form.skills.length} 个技能
|
||
</div>
|
||
)}
|
||
</div>
|
||
{editingAgent && (
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||
<div style={{ fontWeight: 600, marginBottom: 12 }}>🛡️ 工具权限沙箱 (tool_policy)</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 12 }}>
|
||
<input type="checkbox" checked={toolPolicy !== null} onChange={e => {
|
||
if (e.target.checked) {
|
||
setToolPolicy({ allowed_tools: { skill_ids: [], mcp_ids: [] }, max_calls_per_conversation: null, per_round_cap: null, fallback_message: '' });
|
||
} else {
|
||
setToolPolicy(null);
|
||
}
|
||
}} />
|
||
启用工具/MCP 权限沙箱(限制该 Agent 可调用的工具与调用频次)
|
||
</label>
|
||
{toolPolicy && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label className="form-label">允许调用的技能(不选 = 禁止调用任何技能)</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||
{availableSkills.length === 0 ? (
|
||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
|
||
) : availableSkills.map((skill: any) => {
|
||
const checked = (toolPolicy.allowed_tools?.skill_ids || []).includes(skill.id);
|
||
return (
|
||
<label key={skill.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
|
||
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
|
||
const cur = toolPolicy.allowed_tools?.skill_ids || [];
|
||
const next = checked ? cur.filter((x: string) => x !== skill.id) : [...cur, skill.id];
|
||
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), skill_ids: next } });
|
||
}} />
|
||
{skill.skill_name}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label className="form-label">允许调用的 MCP 服务(不选 = 禁止调用任何 MCP)</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||
{availableMcps.length === 0 ? (
|
||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无 MCP 服务</span>
|
||
) : availableMcps.map((m: any) => {
|
||
const checked = (toolPolicy.allowed_tools?.mcp_ids || []).includes(m.id);
|
||
return (
|
||
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
|
||
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
|
||
const cur = toolPolicy.allowed_tools?.mcp_ids || [];
|
||
const next = checked ? cur.filter((x: string) => x !== m.id) : [...cur, m.id];
|
||
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), mcp_ids: next } });
|
||
}} />
|
||
{m.name}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label className="form-label">单对话最大调用次数(留空=不限制)</label>
|
||
<input className="admin-form-input" type="number" min="0"
|
||
value={toolPolicy.max_calls_per_conversation ?? ''}
|
||
onChange={e => setToolPolicy({ ...toolPolicy, max_calls_per_conversation: e.target.value === '' ? null : parseInt(e.target.value) })} />
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label className="form-label">单轮最大调用次数(留空=不限制)</label>
|
||
<input className="admin-form-input" type="number" min="0"
|
||
value={toolPolicy.per_round_cap ?? ''}
|
||
onChange={e => setToolPolicy({ ...toolPolicy, per_round_cap: e.target.value === '' ? null : parseInt(e.target.value) })} />
|
||
</div>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label className="form-label">额度耗尽提示语(可选)</label>
|
||
<textarea className="admin-form-input" rows={2}
|
||
value={toolPolicy.fallback_message || ''}
|
||
onChange={e => setToolPolicy({ ...toolPolicy, fallback_message: e.target.value })}
|
||
placeholder="例如:该智能体已达到工具调用上限,请开启新对话或调整智能体设置。" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{editingAgent && (
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🕸️ 多 Agent 编排图 (orchestration)</div>
|
||
<div style={{ fontSize: 12, color: '#6B7280', marginBottom: 8 }}>
|
||
用有向图描述多个智能体的协作:节点类型 llm / local / external / reduce,边 from→to 表示数据流。留空表示不启用编排(走原对话逻辑)。
|
||
</div>
|
||
<textarea
|
||
className="admin-form-input"
|
||
rows={14}
|
||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12, whiteSpace: 'pre', overflowWrap: 'normal', width: '100%' }}
|
||
value={orchestrationText}
|
||
spellCheck={false}
|
||
onChange={e => { setOrchestrationText(e.target.value); setOrchestrationError(''); }}
|
||
placeholder={'{\n "nodes": [{"id":"router","type":"llm"},{"id":"worker","type":"local","agent_id":"子智能体ID"},{"id":"summary","type":"reduce"}],\n "edges": [{"from":"router","to":"worker"},{"from":"worker","to":"summary"}]\n}'}
|
||
/>
|
||
{orchestrationError && (
|
||
<div style={{ color: '#DC2626', fontSize: 12, marginTop: 6, whiteSpace: 'pre-wrap' }}>{orchestrationError}</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||
<button
|
||
className="admin-btn"
|
||
onClick={() => {
|
||
try {
|
||
const parsed = orchestrationText.trim() ? JSON.parse(orchestrationText) : null;
|
||
setOrchestrationText(parsed ? JSON.stringify(parsed, null, 2) : '');
|
||
setOrchestrationError('');
|
||
} catch (e: any) {
|
||
setOrchestrationError('JSON 格式错误: ' + (e.message || e));
|
||
}
|
||
}}
|
||
>格式化</button>
|
||
<button
|
||
className="admin-btn"
|
||
onClick={() => {
|
||
const tpl = {
|
||
nodes: [
|
||
{ id: 'router', type: 'llm', system_prompt: '判断用户意图并路由到合适的处理智能体。' },
|
||
{ id: 'worker', type: 'local', agent_id: '在此填入子智能体ID' },
|
||
{ id: 'summary', type: 'reduce', system_prompt: '汇总各子任务结果,输出面向用户的最终答复。' },
|
||
],
|
||
edges: [
|
||
{ from: 'router', to: 'worker' },
|
||
{ from: 'worker', to: 'summary' },
|
||
],
|
||
};
|
||
setOrchestrationText(JSON.stringify(tpl, null, 2));
|
||
setOrchestrationError('');
|
||
}}
|
||
>填入示例</button>
|
||
<button
|
||
className="admin-btn primary"
|
||
onClick={async () => {
|
||
try {
|
||
const text = orchestrationText.trim();
|
||
const parsed = text ? JSON.parse(text) : null;
|
||
await adminAPI.setAgentOrchestration(editingAgent!.id, parsed);
|
||
setOrchestrationError('');
|
||
showToast('编排图已保存');
|
||
} catch (e: any) {
|
||
const msg = e?.response?.data?.detail || e?.message || String(e);
|
||
setOrchestrationError('保存失败: ' + msg);
|
||
}
|
||
}}
|
||
>保存编排图</button>
|
||
{orchestrationText.trim() && (
|
||
<button
|
||
className="admin-btn"
|
||
onClick={async () => {
|
||
try {
|
||
await adminAPI.setAgentOrchestration(editingAgent!.id, null);
|
||
setOrchestrationText('');
|
||
setOrchestrationError('');
|
||
showToast('编排图已清除');
|
||
} catch (e: any) {
|
||
setOrchestrationError('清除失败: ' + (e?.message || e));
|
||
}
|
||
}}
|
||
>清除编排</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||
<button className="admin-btn primary" onClick={handleSave}>
|
||
{editingAgent ? '保存更改' : '创建 Agent'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Detail Drawer */}
|
||
{showDetail && detailAgent && (
|
||
<div className="modal-overlay" onClick={() => setShowDetail(false)}>
|
||
<div className="modal-content" style={{ maxWidth: 600 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>{detailAgent.name}</h3>
|
||
<button className="modal-close" onClick={() => setShowDetail(false)}>×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||
<div className="detail-item"><span className="detail-label">类型</span><span>{detailAgent.type}</span></div>
|
||
<div className="detail-item"><span className="detail-label">来源</span><span>{detailAgent.source === 'official' ? '官方' : '用户'}</span></div>
|
||
<div className="detail-item"><span className="detail-label">状态</span><span>{detailAgent.status}</span></div>
|
||
<div className="detail-item"><span className="detail-label">价格</span><span>¥{detailAgent.price}</span></div>
|
||
<div className="detail-item"><span className="detail-label">安装量</span><span>{detailAgent.install_count}</span></div>
|
||
<div className="detail-item"><span className="detail-label">评分</span><span>{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}</span></div>
|
||
<div className="detail-item"><span className="detail-label">发布者</span><span>{detailAgent.publisher_name || detailAgent.publisher_id}</span></div>
|
||
<div className="detail-item"><span className="detail-label">创建时间</span><span>{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}</span></div>
|
||
</div>
|
||
|
||
{detailAgent.description && (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<div style={{ fontWeight: 500, marginBottom: 4 }}>描述</div>
|
||
<p style={{ color: '#6B7280', fontSize: 13, margin: 0 }}>{detailAgent.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{detailAgent.config && (
|
||
<div>
|
||
<div style={{ fontWeight: 500, marginBottom: 8 }}>⚙️ 配置</div>
|
||
<div style={{ background: '#F9FAFB', borderRadius: 8, padding: 12, fontSize: 13 }}>
|
||
{detailAgent.config.system_prompt && (
|
||
<div style={{ marginBottom: 8 }}>
|
||
<span style={{ color: '#6B7280' }}>System Prompt:</span>
|
||
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
|
||
{detailAgent.config.system_prompt}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
|
||
<span>Temperature: <code>{detailAgent.config.temperature}</code></span>
|
||
<span>Max Tokens: <code>{detailAgent.config.max_tokens}</code></span>
|
||
<span>Model: <code>{detailAgent.config.model_id || '默认'}</code></span>
|
||
<span>记忆: <code>{detailAgent.config.memory_enabled ? '✓' : '✗'}</code></span>
|
||
<span>推理: <code>{detailAgent.config.reasoning_enabled ? '✓' : '✗'}</code></span>
|
||
<span>关联技能: <code>{detailAgent.config.skills?.length || 0} 个</code></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<style>{`
|
||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||
.modal-body { padding: 24px; overflow-y: auto; }
|
||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||
.detail-item { display: flex; gap: 8px; font-size: 13px; }
|
||
.detail-label { color: #6B7280; min-width: 60px; }
|
||
`}</style>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Agents;
|