Sfoglia il codice sorgente

feat(M2): Phase H — webui AgentPack 管理页面

新增 /agentpacks 路由和页面,完成 M2 AgentPack ecosystem 前端闭环:

- webui/src/api/agentpacks.ts   — API client (list/get/install/uninstall/create-agent)
- webui/src/pages/AgentPacks.tsx — 管理页面
    • Pack 卡片列表(名称、版本、领域、权限摘要 chips)
    • 从本地 zip 路径安装(InstallModal → POST /agentpacks/install)
    • 卸载(DELETE /agentpacks/{id},带二次确认)
    • 从 Pack 创建智能体(CreateAgentModal → POST /{id}/create-agent → navigate /agents)
    • 安全提示 banner(提示内置 pack 无网络/无 shell)
- webui/src/App.tsx              — 添加 <Route path="agentpacks"> 路由
- webui/src/components/Sidebar.tsx — 添加"智能体包"导航项(Package 图标)

构建验证:tsc + vite build 全通过,无 TS 错误。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kenny67nju 3 mesi fa
parent
commit
e010efa

+ 2 - 0
webui/src/App.tsx

@@ -11,6 +11,7 @@ import Chat from './pages/Chat'
 import Providers from './pages/Providers'
 import Knowledge from './pages/Knowledge'
 import KnowledgeDetail from './pages/KnowledgeDetail'
+import AgentPacks from './pages/AgentPacks'
 
 function RequireAuth({ children }: { children: React.ReactNode }) {
   const apiKey = useAppStore(s => s.apiKey)
@@ -41,6 +42,7 @@ export default function App() {
           <Route path="settings/providers" element={<Providers />} />
           <Route path="knowledge" element={<Knowledge />} />
           <Route path="knowledge/:kbId" element={<KnowledgeDetail />} />
+          <Route path="agentpacks" element={<AgentPacks />} />
         </Route>
         <Route path="*" element={<Navigate to="/" replace />} />
       </Routes>

+ 79 - 0
webui/src/api/agentpacks.ts

@@ -0,0 +1,79 @@
+/**
+ * api/agentpacks.ts — client for AgentPack endpoints (M2 Phase H).
+ *
+ * Endpoints:
+ *   GET    /api/v1/agentpacks            → list installed packs
+ *   GET    /api/v1/agentpacks/{id}       → pack detail
+ *   POST   /api/v1/agentpacks/install    → install from local zip path
+ *   DELETE /api/v1/agentpacks/{id}       → uninstall
+ *   POST   /api/v1/agentpacks/{id}/create-agent → create agent from pack
+ */
+import { api } from './client'
+
+export interface PackPermissions {
+  network: boolean
+  shell: boolean
+  file_write: string
+}
+
+export interface AgentPack {
+  id: string
+  version: string
+  name: string
+  domain: string
+  path: string
+  description: string | null
+  audience: string[]
+  permissions: PackPermissions
+  permission_summary: string
+  model_recommended: string[]
+  entrypoint: string
+}
+
+export interface ListPacksResponse {
+  agentpacks: AgentPack[]
+  count: number
+}
+
+export interface InstallResponse {
+  ok: boolean
+  installed: AgentPack
+}
+
+export interface CreateAgentResponse {
+  ok: boolean
+  agent_id: string
+  name: string
+  pack: { id: string; version: string; name: string }
+}
+
+export interface UninstallResponse {
+  ok: boolean
+  removed_versions: number
+}
+
+export const agentpacksApi = {
+  list: (): Promise<ListPacksResponse> =>
+    api.get('/agentpacks'),
+
+  get: (packId: string, version?: string): Promise<AgentPack> =>
+    api.get(`/agentpacks/${packId}${version ? `?version=${version}` : ''}`),
+
+  install: (zipPath: string, allowShell = false): Promise<InstallResponse> =>
+    api.post('/agentpacks/install', { zip_path: zipPath, allow_shell: allowShell }),
+
+  uninstall: (packId: string, version?: string): Promise<UninstallResponse> =>
+    api.delete(`/agentpacks/${packId}${version ? `?version=${version}` : ''}`),
+
+  createAgent: (
+    packId: string,
+    payload: {
+      name: string
+      description?: string
+      version?: string
+      kb_ids?: string[]
+      tags?: string[]
+    },
+  ): Promise<CreateAgentResponse> =>
+    api.post(`/agentpacks/${packId}/create-agent`, payload),
+}

+ 2 - 1
webui/src/components/Sidebar.tsx

@@ -1,5 +1,5 @@
 import { NavLink, useNavigate } from 'react-router-dom'
-import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig } from 'lucide-react'
+import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig, Package } from 'lucide-react'
 import { useAppStore } from '../store/app'
 import { clsx } from '../lib/clsx'
 import { useMode, MODE_META, type DeploymentMode } from '../api/mode'
@@ -13,6 +13,7 @@ function navItems(mode: DeploymentMode) {
   return [
     { to: '/dashboard', label: desktop ? '今日工作' : '仪表盘', icon: LayoutDashboard },
     { to: '/agents', label: '智能体', icon: Bot },
+    { to: '/agentpacks', label: '智能体包', icon: Package },
     { to: '/knowledge', label: desktop ? '资料库' : '知识体', icon: LibraryBig },
     { to: '/settings/providers', label: desktop ? '模型与隐私' : '模型提供商', icon: Cpu },
   ]

+ 417 - 0
webui/src/pages/AgentPacks.tsx

@@ -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} &nbsp;·&nbsp;
+              <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>
+  )
+}