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()