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:
334
backend/app/api/plugins.py
Normal file
334
backend/app/api/plugins.py
Normal 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()
|
||||
Reference in New Issue
Block a user