Files
qihuaiflow/frontend/src/pages/admin/Agents.tsx
T

906 lines
43 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.
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;