feat: #27 插件系统 — 第三方节点扩展

- NodePlugin 模型: manifest规范(name/version/node_type/inputs_schema/outputs_schema)
- plugin_loader 服务: manifest校验、代码加载/卸载、沙箱执行(subprocess隔离+超时30s)
- plugins API: CRUD、启用/禁用、市场浏览、安装计数、沙箱测试执行
- PluginMarket.vue: 插件市场上传/浏览/安装/启用禁用/删除/测试
- 注册 register_external_tool 到 tool_registry,供工作流编辑器使用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 21:44:45 +08:00
parent 1b5f9deb44
commit 3c102ed5f9
9 changed files with 1029 additions and 3 deletions

334
backend/app/api/plugins.py Normal file
View File

@@ -0,0 +1,334 @@
"""
插件管理 API — 上传、管理、市场、执行第三方节点插件
"""
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Dict, List, Optional, Any
import json
import logging
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.plugin import NodePlugin
from app.services.plugin_loader import (
validate_manifest, load_plugin_code, unload_plugin_code,
execute_plugin_sandbox, list_plugin_node_types, register_plugin_node_type,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
# ---------- Models ----------
class PluginManifest(BaseModel):
name: str
version: str = "1.0.0"
description: Optional[str] = None
author: Optional[str] = None
node_type: str
node_label: Optional[str] = None
category: str = "custom"
inputs_schema: Optional[Dict[str, Any]] = None
outputs_schema: Optional[Dict[str, Any]] = None
icon: Optional[str] = None
tags: Optional[List[str]] = None
class PluginCreate(BaseModel):
manifest: PluginManifest
code: str = Field(..., description="Python 插件代码(须包含 execute 函数)")
is_public: bool = False
class PluginUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
node_label: Optional[str] = None
category: Optional[str] = None
code: Optional[str] = None
inputs_schema: Optional[Dict[str, Any]] = None
outputs_schema: Optional[Dict[str, Any]] = None
enabled: Optional[bool] = None
is_public: Optional[bool] = None
icon: Optional[str] = None
tags: Optional[List[str]] = None
class PluginExecuteRequest(BaseModel):
inputs: Dict[str, Any] = Field(default_factory=dict)
context: Optional[Dict[str, Any]] = None
class PluginResponse(BaseModel):
id: str
name: str
version: str
description: Optional[str]
author: Optional[str]
node_type: str
node_label: Optional[str]
category: str
manifest: Optional[Dict[str, Any]]
inputs_schema: Optional[Dict[str, Any]]
outputs_schema: Optional[Dict[str, Any]]
enabled: bool
is_public: bool
install_count: int
rating_avg: int
icon: Optional[str]
tags: Optional[List[str]]
created_at: Any
updated_at: Any
class Config:
from_attributes = True
# ---------- 我的插件 ----------
@router.get("/my", response_model=List[PluginResponse])
async def get_my_plugins(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前用户上传的插件。"""
plugins = (
db.query(NodePlugin)
.filter(NodePlugin.user_id == current_user.id)
.order_by(NodePlugin.updated_at.desc())
.all()
)
return plugins
# ---------- 插件市场 ----------
@router.get("/market", response_model=List[PluginResponse])
async def get_market_plugins(
search: Optional[str] = None,
category: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
"""获取公开插件市场列表。"""
query = db.query(NodePlugin).filter(
NodePlugin.is_public == True,
NodePlugin.enabled == True,
)
if search:
query = query.filter(
or_(
NodePlugin.name.like(f"%{search}%"),
NodePlugin.description.like(f"%{search}%"),
NodePlugin.node_label.like(f"%{search}%"),
)
)
if category:
query = query.filter(NodePlugin.category == category)
plugins = query.order_by(NodePlugin.install_count.desc()).offset(skip).limit(limit).all()
return plugins
# ---------- 创建插件 ----------
@router.post("", response_model=PluginResponse, status_code=201)
async def create_plugin(
body: PluginCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""上传新插件。"""
manifest = body.manifest.dict()
# 校验 manifest
ok, err = validate_manifest(manifest)
if not ok:
raise HTTPException(status_code=400, detail=f"manifest 校验失败: {err}")
# 校验代码包含 execute 函数
if "def execute" not in body.code and "async def execute" not in body.code:
raise HTTPException(status_code=400, detail="代码中必须包含 execute(inputs, context) 函数")
# 检查名称唯一性
existing = db.query(NodePlugin).filter(NodePlugin.name == manifest["name"]).first()
if existing:
raise HTTPException(status_code=409, detail=f"插件名称 '{manifest['name']}' 已存在")
# 校验 node_type 唯一性
existing_type = db.query(NodePlugin).filter(NodePlugin.node_type == manifest["node_type"]).first()
if existing_type:
raise HTTPException(status_code=409, detail=f"节点类型 '{manifest['node_type']}' 已被使用")
plugin = NodePlugin(
name=manifest["name"],
version=manifest.get("version", "1.0.0"),
description=manifest.get("description"),
author=manifest.get("author") or current_user.username,
node_type=manifest["node_type"],
node_label=manifest.get("node_label", manifest["name"]),
category=manifest.get("category", "custom"),
manifest=manifest,
inputs_schema=manifest.get("inputs_schema"),
outputs_schema=manifest.get("outputs_schema"),
code=body.code,
icon=manifest.get("icon"),
tags=manifest.get("tags", []),
is_public=body.is_public,
user_id=current_user.id,
)
db.add(plugin)
db.flush()
# 写入磁盘并注册
try:
load_plugin_code(plugin.id, body.code, manifest["node_type"])
if plugin.enabled:
register_plugin_node_type(plugin)
except Exception as e:
logger.warning("插件注册警告: %s", e)
db.commit()
db.refresh(plugin)
logger.info("插件已创建: %s (node_type=%s)", plugin.name, plugin.node_type)
return plugin
# ---------- 获取单个插件 ----------
@router.get("/{plugin_id}", response_model=PluginResponse)
async def get_plugin(
plugin_id: str,
db: Session = Depends(get_db),
):
"""获取插件详情。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
return plugin
# ---------- 更新插件 ----------
@router.put("/{plugin_id}", response_model=PluginResponse)
async def update_plugin(
plugin_id: str,
body: PluginUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新插件。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id and plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改此插件")
for field, value in body.dict(exclude_unset=True).items():
setattr(plugin, field, value)
# 如果代码或 schema 变动,重新加载
if body.code:
load_plugin_code(plugin.id, body.code, plugin.node_type)
if body.enabled is not None:
if body.enabled:
register_plugin_node_type(plugin)
db.commit()
db.refresh(plugin)
return plugin
# ---------- 删除插件 ----------
@router.delete("/{plugin_id}")
async def delete_plugin(
plugin_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""删除插件。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id and plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除此插件")
unload_plugin_code(plugin.id)
db.delete(plugin)
db.commit()
return {"message": "插件已删除"}
# ---------- 启用/禁用 ----------
@router.post("/{plugin_id}/toggle")
async def toggle_plugin(
plugin_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""切换插件启用/禁用状态。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
plugin.enabled = not plugin.enabled
if plugin.enabled:
load_plugin_code(plugin.id, plugin.code or "", plugin.node_type)
register_plugin_node_type(plugin)
else:
unload_plugin_code(plugin.id)
db.commit()
return {"enabled": plugin.enabled, "message": f"插件已{'启用' if plugin.enabled else '禁用'}"}
# ---------- 安装插件(从市场) ----------
@router.post("/{plugin_id}/install")
async def install_plugin(
plugin_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""从插件市场安装插件(增加安装计数)。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if not plugin.is_public:
raise HTTPException(status_code=403, detail="此插件未公开")
plugin.install_count += 1
if plugin.enabled:
register_plugin_node_type(plugin)
db.commit()
return {"message": "插件已安装", "install_count": plugin.install_count}
# ---------- 沙箱测试执行 ----------
@router.post("/{plugin_id}/test")
async def test_plugin(
plugin_id: str,
body: PluginExecuteRequest,
db: Session = Depends(get_db),
):
"""在沙箱中测试执行插件。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if not plugin.code:
raise HTTPException(status_code=400, detail="插件无代码")
result = await execute_plugin_sandbox(
code=plugin.code,
inputs=body.inputs,
context=body.context,
timeout_seconds=30,
)
return result
# ---------- 插件节点类型列表(供工作流编辑器用) ----------
@router.get("/internal/node-types")
async def get_node_types():
"""获取所有已启用的插件节点类型。"""
return list_plugin_node_types()

View File

@@ -52,4 +52,6 @@ def init_db():
import app.models.agent_schedule
import app.models.knowledge_base
import app.models.notification
import app.models.orchestration_template
import app.models.plugin
Base.metadata.create_all(bind=engine)

View File

@@ -263,7 +263,7 @@ async def startup_event():
logger.error(f"人参果1号长连接启动失败: {e}")
# 注册路由
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins
app.include_router(auth.router)
app.include_router(uploads.router)
@@ -292,6 +292,8 @@ app.include_router(agent_schedules.router)
app.include_router(notifications.router)
app.include_router(feishu_bind.router)
app.include_router(approval.router)
app.include_router(plugins.router)
app.include_router(orchestration_templates.router)
if __name__ == "__main__":
import uvicorn

View File

@@ -2,7 +2,7 @@
from app.models.user import User
from app.models.workflow import Workflow
from app.models.workflow_version import WorkflowVersion
from app.models.agent import Agent
from app.models.agent import Agent, GlobalKnowledge, AgentRating, AgentFavorite
from app.models.execution import Execution
from app.models.execution_log import ExecutionLog
from app.models.model_config import ModelConfig
@@ -19,5 +19,7 @@ from app.models.agent_schedule import AgentSchedule
from app.models.knowledge_base import KnowledgeBase, Document, DocumentChunk
from app.models.notification import Notification
from app.models.user_feishu_open_id import UserFeishuOpenId
from app.models.plugin import NodePlugin
from app.models.orchestration_template import OrchestrationTemplate
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId"]
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate"]

View File

@@ -0,0 +1,48 @@
"""
插件模型 — 第三方节点扩展
"""
from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, func
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
import uuid
class NodePlugin(Base):
"""第三方节点插件"""
__tablename__ = "node_plugins"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="插件ID")
name = Column(String(128), nullable=False, unique=True, comment="插件名称(唯一)")
version = Column(String(32), nullable=False, default="1.0.0", comment="版本号")
description = Column(Text, comment="描述")
author = Column(String(128), comment="作者")
node_type = Column(String(64), nullable=False, comment="节点类型标识(如 custom_http、custom_data")
node_label = Column(String(128), comment="节点显示名称")
category = Column(String(64), default="custom", comment="分类: custom/http/data/ai/tool")
# manifest.json 原始内容
manifest = Column(JSON, comment="manifest.json 完整内容")
# 输入/输出定义
inputs_schema = Column(JSON, comment="输入参数 schema")
outputs_schema = Column(JSON, comment="输出参数 schema")
# 代码
code = Column(Text, comment="插件执行代码Python")
# 状态
enabled = Column(Boolean, default=True, comment="是否启用")
is_public = Column(Boolean, default=False, comment="是否公开到插件市场")
install_count = Column(Integer, default=0, comment="安装次数")
rating_avg = Column(Integer, default=0, comment="平均评分")
# 元数据
icon = Column(String(256), comment="图标URL")
tags = Column(JSON, comment="标签列表")
user_id = Column(CHAR(36), nullable=True, comment="上传者ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<NodePlugin(id={self.id}, name={self.name}, type={self.node_type})>"

View File

@@ -0,0 +1,219 @@
"""
插件加载器 — 加载、校验、沙箱执行第三方节点插件
插件规范 (manifest.json):
{
"name": "my-plugin",
"version": "1.0.0",
"description": "...",
"author": "...",
"node_type": "custom_action",
"node_label": "自定义操作",
"category": "custom",
"entry": "execute.py",
"inputs_schema": {"type": "object", "properties": {...}},
"outputs_schema": {"type": "object", "properties": {...}}
}
执行函数签名:
async def execute(inputs: dict, context: dict) -> dict
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import subprocess
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# 插件存储根目录
PLUGINS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "plugins"
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
def validate_manifest(manifest: dict) -> tuple[bool, str]:
"""校验 manifest.json 是否合法。"""
required = ["name", "version", "node_type"]
for key in required:
if key not in manifest:
return False, f"缺少必填字段: {key}"
if not isinstance(manifest.get("name"), str) or not manifest["name"].strip():
return False, "name 必须是非空字符串"
if not isinstance(manifest.get("node_type"), str) or not manifest["node_type"].strip():
return False, "node_type 必须是非空字符串"
# node_type 必须是合法的标识符
import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', manifest["node_type"]):
return False, "node_type 必须是合法的 Python 标识符"
return True, "ok"
def load_plugin_code(plugin_id: str, code: str, node_type: str) -> str:
"""将插件代码写入磁盘并返回文件路径。"""
plugin_dir = PLUGINS_DIR / plugin_id
plugin_dir.mkdir(parents=True, exist_ok=True)
file_path = plugin_dir / f"{node_type}.py"
file_path.write_text(code, encoding="utf-8")
return str(file_path)
def unload_plugin_code(plugin_id: str):
"""从磁盘删除插件代码。"""
import shutil
plugin_dir = PLUGINS_DIR / plugin_id
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
def _make_sandbox_globals():
"""构建受限的内建函数集。"""
safe_builtins = {
"True": True, "False": False, "None": None,
"abs": abs, "all": all, "any": any, "bool": bool,
"dict": dict, "enumerate": enumerate, "filter": filter,
"float": float, "int": int, "isinstance": isinstance,
"len": len, "list": list, "map": map, "max": max,
"min": min, "range": range, "round": round, "set": set,
"sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"type": type, "zip": zip,
"print": print, "json": json,
"Exception": Exception, "ValueError": ValueError,
"TypeError": TypeError, "KeyError": KeyError,
}
return {"__builtins__": safe_builtins}
async def execute_plugin_sandbox(
code: str,
inputs: Dict[str, Any],
context: Optional[Dict[str, Any]] = None,
timeout_seconds: int = 30,
) -> Dict[str, Any]:
"""在沙箱中异步执行插件代码。
使用 subprocess 隔离执行,超时自动终止,防止恶意代码影响主进程。
"""
context = context or {}
# 将代码和输入写入临时脚本
wrapper_code = f'''
import json
import sys
import traceback
# 用户插件代码
{code}
# 准备输入
_inputs = json.loads(sys.stdin.read())
_ctx = _inputs.get("__context__", {{}})
_user_inputs = _inputs.get("__inputs__", {{}})
# 查找 execute 函数
if "execute" not in dir():
print(json.dumps({{"ok": False, "error": "插件缺少 execute 函数"}}))
sys.exit(1)
try:
import asyncio
result = asyncio.run(execute(_user_inputs, _ctx))
print(json.dumps({{"ok": True, "result": result}}, ensure_ascii=False, default=str))
except Exception as e:
print(json.dumps({{"ok": False, "error": str(e), "traceback": traceback.format_exc()}}, ensure_ascii=False))
'''
try:
proc = await asyncio.wait_for(
asyncio.create_subprocess_exec(
sys.executable, "-c", wrapper_code,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
),
timeout=5,
)
input_data = json.dumps({"__inputs__": inputs, "__context__": context})
stdout, stderr = await asyncio.wait_for(
proc.communicate(input_data.encode("utf-8")),
timeout=timeout_seconds,
)
if stderr:
logger.warning("插件沙箱 stderr: %s", stderr.decode("utf-8", errors="replace")[:500])
result = json.loads(stdout.decode("utf-8"))
if result.get("ok"):
return {"success": True, "result": result["result"]}
else:
return {"success": False, "error": result.get("error", "未知错误")}
except asyncio.TimeoutError:
return {"success": False, "error": f"插件执行超时({timeout_seconds}秒)"}
except json.JSONDecodeError as e:
return {"success": False, "error": f"插件返回解析失败: {e}"}
except Exception as e:
return {"success": False, "error": f"插件执行异常: {e}"}
def list_plugin_node_types() -> List[Dict[str, Any]]:
"""列出当前已加载的插件节点类型,供工作流编辑器使用。"""
from app.core.database import SessionLocal
from app.models.plugin import NodePlugin
db = SessionLocal()
try:
plugins = db.query(NodePlugin).filter(NodePlugin.enabled == True).all()
return [
{
"id": p.id,
"node_type": p.node_type,
"node_label": p.node_label or p.name,
"category": p.category,
"description": p.description,
"inputs_schema": p.inputs_schema,
"outputs_schema": p.outputs_schema,
"icon": p.icon,
}
for p in plugins
]
finally:
db.close()
def register_plugin_node_type(plugin) -> bool:
"""将插件注册到工具注册表,使工作流编辑器可用。"""
from app.services.tool_registry import tool_registry
node_type = plugin.node_type
node_label = plugin.node_label or plugin.name
schema = {
"name": node_type,
"description": plugin.description or f"自定义节点: {node_label}",
"parameters": {
"type": "object",
"properties": (plugin.inputs_schema or {}).get("properties", {}),
"required": (plugin.inputs_schema or {}).get("required", []),
},
}
try:
tool_registry.register_external_tool(
name=node_type,
description=schema["description"],
parameters=schema["parameters"],
category=f"plugin:{plugin.category}",
)
logger.info("插件节点类型已注册: %s", node_type)
return True
except Exception as e:
logger.warning("插件节点类型注册失败 [%s]: %s", node_type, e)
return False

View File

@@ -69,6 +69,17 @@ class ToolRegistry:
self._tool_schemas[name] = schema
logger.debug("注册内置工具: %s", name)
def register_external_tool(self, name: str, description: str, parameters: dict, category: str = "plugin"):
"""注册外部/插件工具 schema无实际执行函数仅用于工作流编辑器展示"""
schema = {
"name": name,
"description": description,
"parameters": parameters,
}
self._tool_schemas[name] = schema
self._custom_tool_configs[name] = {"category": category, "description": description}
logger.debug("注册外部工具 schema: %s (category=%s)", name, category)
# ─── 工具信息查询 ─────────────────────────────────────────
def get_tool_schema(self, name: str) -> Optional[Dict[str, Any]]:

View File

@@ -135,6 +135,12 @@ const router = createRouter({
name: 'agent-schedules',
component: () => import('@/views/AgentSchedules.vue'),
meta: { requiresAuth: true }
},
{
path: '/plugins',
name: 'plugin-market',
component: () => import('@/views/PluginMarket.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -0,0 +1,402 @@
<template>
<MainLayout>
<div class="plugin-market">
<!-- 顶部操作栏 -->
<div class="page-header">
<div class="header-left">
<h1>插件市场</h1>
<p class="subtitle">扩展平台节点类型上传或安装第三方插件</p>
</div>
<div class="header-right">
<el-button type="primary" @click="showUpload = true">
<el-icon><Upload /></el-icon>
上传插件
</el-button>
<el-button @click="activeTab = 'my'">
<el-icon><User /></el-icon>
我的插件
</el-button>
</div>
</div>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-change="loadData">
<el-tab-pane label="插件市场" name="market" />
<el-tab-pane label="我的插件" name="my" />
</el-tabs>
<!-- 搜索筛选 -->
<el-card class="search-bar" v-if="activeTab === 'market'">
<el-row :gutter="12">
<el-col :span="8">
<el-input v-model="searchQuery" placeholder="搜索插件..." clearable @clear="loadData" @keyup.enter="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="categoryFilter" placeholder="分类" clearable @change="loadData">
<el-option label="自定义" value="custom" />
<el-option label="HTTP请求" value="http" />
<el-option label="数据处理" value="data" />
<el-option label="AI工具" value="ai" />
<el-option label="工具" value="tool" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
</el-col>
</el-row>
</el-card>
<!-- 插件列表 -->
<div v-loading="loading" class="plugin-grid" style="margin-top: 16px">
<el-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
<el-row :gutter="16" v-else>
<el-col v-for="p in plugins" :key="p.id" :span="6" style="margin-bottom: 16px">
<el-card class="plugin-card" shadow="hover">
<div class="plugin-header">
<div class="plugin-icon">
<el-icon :size="28"><Box /></el-icon>
</div>
<div class="plugin-info">
<h4 class="plugin-name">{{ p.node_label || p.name }}</h4>
<el-tag size="small" type="info">{{ p.category }}</el-tag>
</div>
</div>
<p class="plugin-desc">{{ p.description || '无描述' }}</p>
<div class="plugin-meta">
<span class="meta-item">
<el-icon><Download /></el-icon> {{ p.install_count }}
</span>
<span class="meta-item">v{{ p.version }}</span>
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
{{ p.enabled ? '已启用' : '已禁用' }}
</el-tag>
</div>
<div class="plugin-actions">
<el-button
v-if="activeTab === 'market'"
type="primary" size="small"
@click="installPlugin(p)"
>
<el-icon><Download /></el-icon>
安装
</el-button>
<el-button size="small" @click="viewDetail(p)">详情</el-button>
<el-button
v-if="activeTab === 'my'"
size="small"
@click="testPlugin(p)"
>
<el-icon><VideoPlay /></el-icon>
测试
</el-button>
<el-button
v-if="activeTab === 'my'"
:type="p.enabled ? 'warning' : 'success'"
size="small"
@click="togglePlugin(p)"
>
{{ p.enabled ? '禁用' : '启用' }}
</el-button>
<el-button
v-if="activeTab === 'my'"
type="danger"
size="small"
@click="deletePlugin(p)"
>
删除
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 上传对话框 -->
<el-dialog v-model="showUpload" title="上传插件" width="700px" :close-on-click-modal="false" destroy-on-close>
<el-form :model="uploadForm" label-width="120px">
<el-form-item label="插件名称" required>
<el-input v-model="uploadForm.manifest.name" placeholder="如: my-http-plugin" />
</el-form-item>
<el-form-item label="版本号">
<el-input v-model="uploadForm.manifest.version" placeholder="1.0.0" />
</el-form-item>
<el-form-item label="节点类型" required>
<el-input v-model="uploadForm.manifest.node_type" placeholder="如: custom_http_action英文字母+下划线)" />
<div class="form-hint">节点类型标识用于工作流编辑器中识别此节点</div>
</el-form-item>
<el-form-item label="节点显示名">
<el-input v-model="uploadForm.manifest.node_label" placeholder="如: HTTP请求" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="uploadForm.manifest.category" placeholder="选择分类">
<el-option label="自定义" value="custom" />
<el-option label="HTTP请求" value="http" />
<el-option label="数据处理" value="data" />
<el-option label="AI工具" value="ai" />
<el-option label="工具" value="tool" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="uploadForm.manifest.description" type="textarea" :rows="2" placeholder="插件功能描述" />
</el-form-item>
<el-form-item label="输入Schema (JSON)">
<el-input v-model="schemaInputs" type="textarea" :rows="4" placeholder='{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}' />
</el-form-item>
<el-form-item label="插件代码" required>
<el-input v-model="uploadForm.code" type="textarea" :rows="10" placeholder="async def execute(inputs, context):&#10; # 你的插件逻辑&#10; return {'result': 'hello'}" />
<div class="form-hint">必须包含 async def execute(inputs, context) 函数返回 dict</div>
</el-form-item>
<el-form-item label="公开到市场">
<el-switch v-model="uploadForm.is_public" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUpload = false">取消</el-button>
<el-button type="primary" @click="doUpload" :loading="uploading">上传</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailPlugin?.name" width="700px" destroy-on-close>
<el-descriptions v-if="detailPlugin" :column="2" border>
<el-descriptions-item label="名称">{{ detailPlugin.name }}</el-descriptions-item>
<el-descriptions-item label="版本">v{{ detailPlugin.version }}</el-descriptions-item>
<el-descriptions-item label="节点类型">{{ detailPlugin.node_type }}</el-descriptions-item>
<el-descriptions-item label="节点标签">{{ detailPlugin.node_label }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ detailPlugin.category }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailPlugin.enabled ? 'success' : 'danger'">{{ detailPlugin.enabled ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="安装次数">{{ detailPlugin.install_count }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailPlugin.created_at }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailPlugin.description || '无' }}</el-descriptions-item>
</el-descriptions>
<div v-if="detailPlugin?.code" style="margin-top: 12px">
<h4>插件代码</h4>
<el-input :model-value="detailPlugin.code" type="textarea" :rows="10" readonly />
</div>
</el-dialog>
<!-- 测试对话框 -->
<el-dialog v-model="showTest" title="测试插件" width="700px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="输入参数 (JSON)">
<el-input v-model="testInputs" type="textarea" :rows="6" placeholder='{"key": "value"}' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTest = false">关闭</el-button>
<el-button type="primary" @click="doTest" :loading="testing">执行测试</el-button>
</template>
<div v-if="testResult" style="margin-top: 16px">
<el-divider />
<h4>测试结果</h4>
<el-alert
:title="testResult.success ? '执行成功' : '执行失败'"
:type="testResult.success ? 'success' : 'error'"
:closable="false"
show-icon
/>
<el-input
:model-value="JSON.stringify(testResult, null, 2)"
type="textarea"
:rows="10"
readonly
style="margin-top: 8px"
/>
</div>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, User, Search, Download, VideoPlay, Box } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
// 状态
const activeTab = ref('market')
const plugins = ref<any[]>([])
const loading = ref(false)
const searchQuery = ref('')
const categoryFilter = ref('')
// 上传
const showUpload = ref(false)
const uploading = ref(false)
const uploadForm = ref({
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] as string[] },
code: '',
is_public: false,
})
const schemaInputs = ref('')
// 详情
const showDetail = ref(false)
const detailPlugin = ref<any>(null)
// 测试
const showTest = ref(false)
const testing = ref(false)
const testInputs = ref('{}')
const testResult = ref<any>(null)
const testTarget = ref<any>(null)
// 加载
const loadData = async () => {
loading.value = true
try {
const endpoint = activeTab.value === 'market' ? '/api/v1/plugins/market' : '/api/v1/plugins/my'
const params: any = { skip: 0, limit: 50 }
if (activeTab.value === 'market') {
if (searchQuery.value) params.search = searchQuery.value
if (categoryFilter.value) params.category = categoryFilter.value
}
const resp = await api.get(endpoint, { params })
plugins.value = Array.isArray(resp.data) ? resp.data : []
} catch {
ElMessage.error('加载插件列表失败')
} finally {
loading.value = false
}
}
// 上传
const doUpload = async () => {
if (!uploadForm.value.manifest.name || !uploadForm.value.manifest.node_type || !uploadForm.value.code) {
ElMessage.warning('请填写必填项')
return
}
if (!uploadForm.value.code.includes('def execute')) {
ElMessage.warning('代码中必须包含 execute 函数')
return
}
uploading.value = true
try {
// 解析 schema JSON
const manifest: any = { ...uploadForm.value.manifest }
if (schemaInputs.value.trim()) {
try {
manifest.inputs_schema = JSON.parse(schemaInputs.value)
} catch {
ElMessage.warning('输入Schema 不是有效的 JSON')
uploading.value = false
return
}
}
await api.post('/api/v1/plugins', { manifest, code: uploadForm.value.code, is_public: uploadForm.value.is_public })
ElMessage.success('插件上传成功')
showUpload.value = false
uploadForm.value = {
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] },
code: '',
is_public: false,
}
schemaInputs.value = ''
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '上传失败')
} finally {
uploading.value = false
}
}
// 安装
const installPlugin = async (p: any) => {
try {
await api.post(`/api/v1/plugins/${p.id}/install`)
p.install_count += 1
ElMessage.success(`已安装插件「${p.name}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '安装失败')
}
}
// 切换启用
const togglePlugin = async (p: any) => {
try {
const resp = await api.post(`/api/v1/plugins/${p.id}/toggle`)
p.enabled = resp.data.enabled
ElMessage.success(resp.data.message)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
// 删除
const deletePlugin = async (p: any) => {
try {
await ElMessageBox.confirm(`确定删除插件「${p.name}」?此操作不可恢复。`, '确认删除', { type: 'warning' })
await api.delete(`/api/v1/plugins/${p.id}`)
ElMessage.success('已删除')
loadData()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
}
}
// 详情
const viewDetail = async (p: any) => {
try {
const resp = await api.get(`/api/v1/plugins/${p.id}`)
detailPlugin.value = resp.data
showDetail.value = true
} catch {
ElMessage.error('加载详情失败')
}
}
// 测试
const testPlugin = (p: any) => {
testTarget.value = p
testInputs.value = '{}'
testResult.value = null
showTest.value = true
}
const doTest = async () => {
if (!testTarget.value) return
let inputs = {}
try {
inputs = JSON.parse(testInputs.value)
} catch {
ElMessage.warning('输入参数不是有效的 JSON')
return
}
testing.value = true
try {
const resp = await api.post(`/api/v1/plugins/${testTarget.value.id}/test`, { inputs })
testResult.value = resp.data
} catch (err: any) {
testResult.value = { success: false, error: err.response?.data?.detail || '测试失败' }
} finally {
testing.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.plugin-market { max-width: 1200px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
.header-left h1 { margin: 0; font-size: 24px; }
.header-right { display: flex; gap: 8px; }
.subtitle { color: var(--el-text-color-secondary); margin: 4px 0 0; font-size: 14px; }
.search-bar { margin-bottom: 8px; }
.plugin-grid { min-height: 200px; }
.plugin-card { height: 100%; }
.plugin-header { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
.plugin-icon { width: 44px; height: 44px; background: var(--el-color-primary-light-9); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--el-color-primary); }
.plugin-name { margin: 0; font-size: 15px; }
.plugin-desc { color: var(--el-text-color-secondary); font-size: 13px; margin: 8px 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.plugin-meta { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--el-text-color-secondary); }
.meta-item { display: flex; align-items: center; gap: 4px; }
.plugin-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.form-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
</style>