|
|
@@ -0,0 +1,417 @@
|
|
|
+/**
|
|
|
+ * AgentPacks.tsx — AgentPack 管理页面 (M2 Phase H).
|
|
|
+ *
|
|
|
+ * Features:
|
|
|
+ * • 列出已安装的 packs(名称、版本、领域、权限摘要)
|
|
|
+ * • 从本地 zip 路径安装新 pack(loopback-only POST /install)
|
|
|
+ * • 卸载 pack(DELETE)
|
|
|
+ * • 一键"从此包创建智能体"(POST /{id}/create-agent → 跳转 /agents)
|
|
|
+ */
|
|
|
+import { useState } from 'react'
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
+import { useNavigate } from 'react-router-dom'
|
|
|
+import {
|
|
|
+ Package,
|
|
|
+ Plus,
|
|
|
+ Trash2,
|
|
|
+ Bot,
|
|
|
+ FolderOpen,
|
|
|
+ RefreshCw,
|
|
|
+ ShieldCheck,
|
|
|
+ WifiOff,
|
|
|
+ Terminal,
|
|
|
+} from 'lucide-react'
|
|
|
+import toast from 'react-hot-toast'
|
|
|
+import { agentpacksApi, type AgentPack } from '../api/agentpacks'
|
|
|
+import { PageHeader, Spinner, EmptyState, Button, Badge, Card } from '../components/ui'
|
|
|
+
|
|
|
+// ── Permission chips ─────────────────────────────────────────────
|
|
|
+
|
|
|
+function PermChip({ label, allowed }: { label: string; allowed: boolean }) {
|
|
|
+ return (
|
|
|
+ <span
|
|
|
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
|
|
+ allowed ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {label}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function PermissionBadges({ perms }: { perms: AgentPack['permissions'] }) {
|
|
|
+ return (
|
|
|
+ <div className="flex flex-wrap gap-1 mt-2">
|
|
|
+ <PermChip label={perms.network ? '联网' : '无网络'} allowed={perms.network} />
|
|
|
+ <PermChip label={perms.shell ? '可执行 shell' : '无 shell'} allowed={perms.shell} />
|
|
|
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-600">
|
|
|
+ 写入: {perms.file_write}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Domain badge ─────────────────────────────────────────────────
|
|
|
+
|
|
|
+const DOMAIN_COLORS: Record<string, 'blue' | 'purple' | 'green' | 'yellow'> = {
|
|
|
+ research: 'purple',
|
|
|
+ finance: 'blue',
|
|
|
+ legal: 'yellow',
|
|
|
+ medical: 'green',
|
|
|
+}
|
|
|
+
|
|
|
+function DomainBadge({ domain }: { domain: string }) {
|
|
|
+ return (
|
|
|
+ <Badge variant={DOMAIN_COLORS[domain] ?? 'default'}>
|
|
|
+ {domain}
|
|
|
+ </Badge>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Install modal ─────────────────────────────────────────────────
|
|
|
+
|
|
|
+function InstallModal({
|
|
|
+ onClose,
|
|
|
+ onInstalled,
|
|
|
+}: {
|
|
|
+ onClose: () => void
|
|
|
+ onInstalled: (pack: AgentPack) => void
|
|
|
+}) {
|
|
|
+ const [zipPath, setZipPath] = useState('')
|
|
|
+ const [allowShell, setAllowShell] = useState(false)
|
|
|
+
|
|
|
+ const mut = useMutation({
|
|
|
+ mutationFn: () => agentpacksApi.install(zipPath.trim(), allowShell),
|
|
|
+ onSuccess: (res) => {
|
|
|
+ toast.success(`已安装:${res.installed.name} v${res.installed.version}`)
|
|
|
+ onInstalled(res.installed)
|
|
|
+ },
|
|
|
+ onError: (e: any) => toast.error(e.message ?? '安装失败'),
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
|
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
|
|
+ <h3 className="text-base font-semibold mb-1">安装 AgentPack</h3>
|
|
|
+ <p className="text-xs text-gray-500 mb-4">
|
|
|
+ 填写本机 .zip 路径(仅支持 127.0.0.1 回环地址请求)
|
|
|
+ </p>
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-medium text-gray-700 mb-1">
|
|
|
+ ZIP 文件路径 *
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ value={zipPath}
|
|
|
+ onChange={(e) => setZipPath(e.target.value)}
|
|
|
+ placeholder="/Users/me/packs/research.reviewer-0.1.0.zip"
|
|
|
+ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <label className="flex items-center gap-2 cursor-pointer select-none">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={allowShell}
|
|
|
+ onChange={(e) => setAllowShell(e.target.checked)}
|
|
|
+ className="rounded text-indigo-600"
|
|
|
+ />
|
|
|
+ <span className="text-xs text-gray-600">
|
|
|
+ 允许 shell 权限(危险:仅限受信任来源)
|
|
|
+ </span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-end gap-2 mt-5">
|
|
|
+ <button
|
|
|
+ onClick={onClose}
|
|
|
+ className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => mut.mutate()}
|
|
|
+ disabled={!zipPath.trim() || mut.isPending}
|
|
|
+ className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
|
|
|
+ >
|
|
|
+ {mut.isPending && <RefreshCw size={14} className="animate-spin" />}
|
|
|
+ 安装
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Create-agent modal ────────────────────────────────────────────
|
|
|
+
|
|
|
+function CreateAgentModal({
|
|
|
+ pack,
|
|
|
+ onClose,
|
|
|
+ onCreated,
|
|
|
+}: {
|
|
|
+ pack: AgentPack
|
|
|
+ onClose: () => void
|
|
|
+ onCreated: (agentId: string) => void
|
|
|
+}) {
|
|
|
+ const [name, setName] = useState(`${pack.name} (副本)`)
|
|
|
+ const [desc, setDesc] = useState(pack.description ?? '')
|
|
|
+
|
|
|
+ const mut = useMutation({
|
|
|
+ mutationFn: () =>
|
|
|
+ agentpacksApi.createAgent(pack.id, { name: name.trim(), description: desc.trim() }),
|
|
|
+ onSuccess: (res) => {
|
|
|
+ toast.success(`智能体「${res.name}」已创建`)
|
|
|
+ onCreated(res.agent_id)
|
|
|
+ },
|
|
|
+ onError: (e: any) => toast.error(e.message ?? '创建失败'),
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
|
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
|
|
+ <h3 className="text-base font-semibold mb-1">从 Pack 创建智能体</h3>
|
|
|
+ <p className="text-xs text-gray-500 mb-4">
|
|
|
+ 基于 <strong>{pack.name}</strong> v{pack.version}
|
|
|
+ </p>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-medium text-gray-700 mb-1">名称 *</label>
|
|
|
+ <input
|
|
|
+ value={name}
|
|
|
+ onChange={(e) => setName(e.target.value)}
|
|
|
+ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-medium text-gray-700 mb-1">描述(可选)</label>
|
|
|
+ <textarea
|
|
|
+ value={desc}
|
|
|
+ onChange={(e) => setDesc(e.target.value)}
|
|
|
+ rows={2}
|
|
|
+ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-end gap-2 mt-5">
|
|
|
+ <button
|
|
|
+ onClick={onClose}
|
|
|
+ className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => mut.mutate()}
|
|
|
+ disabled={!name.trim() || mut.isPending}
|
|
|
+ className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
|
|
|
+ >
|
|
|
+ {mut.isPending && <RefreshCw size={14} className="animate-spin" />}
|
|
|
+ 创建智能体
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Pack card ────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function PackCard({
|
|
|
+ pack,
|
|
|
+ onUninstall,
|
|
|
+ onCreateAgent,
|
|
|
+}: {
|
|
|
+ pack: AgentPack
|
|
|
+ onUninstall: () => void
|
|
|
+ onCreateAgent: () => void
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <Card className="flex flex-col gap-3 hover:shadow-md transition-shadow">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="flex items-start justify-between">
|
|
|
+ <div className="flex items-center gap-3 min-w-0">
|
|
|
+ <div className="p-2 bg-indigo-50 rounded-lg shrink-0">
|
|
|
+ <Package size={18} className="text-indigo-600" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-sm font-semibold text-gray-900 truncate">{pack.name}</p>
|
|
|
+ <p className="text-xs text-gray-400">
|
|
|
+ v{pack.version} ·
|
|
|
+ <span className="font-mono text-[10px]">{pack.id}</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={onUninstall}
|
|
|
+ className="text-gray-300 hover:text-red-500 transition-colors p-1 shrink-0"
|
|
|
+ title="卸载"
|
|
|
+ >
|
|
|
+ <Trash2 size={14} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Domain + audience */}
|
|
|
+ <div className="flex flex-wrap gap-1.5">
|
|
|
+ <DomainBadge domain={pack.domain} />
|
|
|
+ {pack.audience.slice(0, 2).map((a) => (
|
|
|
+ <Badge key={a} variant="default">
|
|
|
+ {a}
|
|
|
+ </Badge>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Description */}
|
|
|
+ {pack.description && (
|
|
|
+ <p className="text-xs text-gray-500 line-clamp-2">{pack.description}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Permission summary */}
|
|
|
+ <div className="text-[11px] text-gray-400 leading-relaxed border-t border-gray-100 pt-2">
|
|
|
+ {pack.permission_summary || (
|
|
|
+ <PermissionBadges perms={pack.permissions} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Actions */}
|
|
|
+ <div className="flex gap-2 mt-auto pt-2 border-t border-gray-100">
|
|
|
+ <Button
|
|
|
+ onClick={onCreateAgent}
|
|
|
+ size="sm"
|
|
|
+ className="flex-1"
|
|
|
+ icon={<Bot size={13} />}
|
|
|
+ >
|
|
|
+ 创建智能体
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </Card>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Main page ────────────────────────────────────────────────────
|
|
|
+
|
|
|
+export default function AgentPacks() {
|
|
|
+ const navigate = useNavigate()
|
|
|
+ const qc = useQueryClient()
|
|
|
+
|
|
|
+ const [showInstall, setShowInstall] = useState(false)
|
|
|
+ const [createPack, setCreatePack] = useState<AgentPack | null>(null)
|
|
|
+
|
|
|
+ const { data, isLoading, refetch } = useQuery({
|
|
|
+ queryKey: ['agentpacks'],
|
|
|
+ queryFn: agentpacksApi.list,
|
|
|
+ })
|
|
|
+
|
|
|
+ const uninstallMut = useMutation({
|
|
|
+ mutationFn: (packId: string) => agentpacksApi.uninstall(packId),
|
|
|
+ onSuccess: (_, packId) => {
|
|
|
+ toast.success('已卸载')
|
|
|
+ qc.invalidateQueries({ queryKey: ['agentpacks'] })
|
|
|
+ },
|
|
|
+ onError: (e: any) => toast.error(e.message ?? '卸载失败'),
|
|
|
+ })
|
|
|
+
|
|
|
+ const packs = data?.agentpacks ?? []
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="p-6 max-w-6xl mx-auto">
|
|
|
+ <PageHeader
|
|
|
+ title="智能体包"
|
|
|
+ description={
|
|
|
+ data
|
|
|
+ ? `已安装 ${data.count} 个 AgentPack`
|
|
|
+ : '管理可复用的智能体模版包'
|
|
|
+ }
|
|
|
+ action={
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ size="sm"
|
|
|
+ icon={<RefreshCw size={14} />}
|
|
|
+ onClick={() => refetch()}
|
|
|
+ >
|
|
|
+ 刷新
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ icon={<Plus size={14} />}
|
|
|
+ onClick={() => setShowInstall(true)}
|
|
|
+ >
|
|
|
+ 安装 Pack
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Security notice banner */}
|
|
|
+ <div className="mb-6 flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-800">
|
|
|
+ <ShieldCheck size={16} className="mt-0.5 shrink-0 text-amber-600" />
|
|
|
+ <div>
|
|
|
+ <strong>安全提示</strong>:只安装来自可信来源的 Pack。
|
|
|
+ 内置科研 Pack(research.*)本地优先运行,
|
|
|
+ <WifiOff size={11} className="inline mx-0.5 align-middle" />无网络、
|
|
|
+ <Terminal size={11} className="inline mx-0.5 align-middle" />无 shell。
|
|
|
+ 第三方 Pack 请仔细核查权限摘要。
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {isLoading && (
|
|
|
+ <div className="flex items-center justify-center h-40">
|
|
|
+ <Spinner />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!isLoading && packs.length === 0 && (
|
|
|
+ <EmptyState
|
|
|
+ icon={<Package size={48} className="text-gray-300" />}
|
|
|
+ title="还没有安装 AgentPack"
|
|
|
+ description="AgentPack 是可复用的智能体模版,支持一键从 Pack 创建智能体。内置三个科研 Pack 可直接使用。"
|
|
|
+ action={
|
|
|
+ <Button
|
|
|
+ icon={<Plus size={16} />}
|
|
|
+ onClick={() => setShowInstall(true)}
|
|
|
+ >
|
|
|
+ 安装 Pack
|
|
|
+ </Button>
|
|
|
+ }
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!isLoading && packs.length > 0 && (
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
+ {packs.map((pack) => (
|
|
|
+ <PackCard
|
|
|
+ key={pack.id}
|
|
|
+ pack={pack}
|
|
|
+ onUninstall={() => {
|
|
|
+ if (confirm(`确认卸载「${pack.name}」?此操作不可恢复。`)) {
|
|
|
+ uninstallMut.mutate(pack.id)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onCreateAgent={() => setCreatePack(pack)}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Modals */}
|
|
|
+ {showInstall && (
|
|
|
+ <InstallModal
|
|
|
+ onClose={() => setShowInstall(false)}
|
|
|
+ onInstalled={(pack) => {
|
|
|
+ setShowInstall(false)
|
|
|
+ qc.invalidateQueries({ queryKey: ['agentpacks'] })
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {createPack && (
|
|
|
+ <CreateAgentModal
|
|
|
+ pack={createPack}
|
|
|
+ onClose={() => setCreatePack(null)}
|
|
|
+ onCreated={(agentId) => {
|
|
|
+ setCreatePack(null)
|
|
|
+ navigate('/agents')
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|