From 3c102ed5f981664d2e2683f4ca8fac30093f8cd1 Mon Sep 17 00:00:00 2001 From: renjianbo <18691577328@163.com> Date: Wed, 6 May 2026 21:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20#27=20=E6=8F=92=E4=BB=B6=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20=E2=80=94=20=E7=AC=AC=E4=B8=89=E6=96=B9=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/plugins.py | 334 +++++++++++++++++++++ backend/app/core/database.py | 2 + backend/app/main.py | 4 +- backend/app/models/__init__.py | 6 +- backend/app/models/plugin.py | 48 +++ backend/app/services/plugin_loader.py | 219 ++++++++++++++ backend/app/services/tool_registry.py | 11 + frontend/src/router/index.ts | 6 + frontend/src/views/PluginMarket.vue | 402 ++++++++++++++++++++++++++ 9 files changed, 1029 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/plugins.py create mode 100644 backend/app/models/plugin.py create mode 100644 backend/app/services/plugin_loader.py create mode 100644 frontend/src/views/PluginMarket.vue diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py new file mode 100644 index 0000000..c5a7529 --- /dev/null +++ b/backend/app/api/plugins.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 131fbbd..316e079 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index f4ae5f0..0689fee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 95ae907..19c890e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"] \ No newline at end of file +__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"] \ No newline at end of file diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py new file mode 100644 index 0000000..7a8e16a --- /dev/null +++ b/backend/app/models/plugin.py @@ -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"" diff --git a/backend/app/services/plugin_loader.py b/backend/app/services/plugin_loader.py new file mode 100644 index 0000000..ee4c114 --- /dev/null +++ b/backend/app/services/plugin_loader.py @@ -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 diff --git a/backend/app/services/tool_registry.py b/backend/app/services/tool_registry.py index f49e625..e8df7f0 100644 --- a/backend/app/services/tool_registry.py +++ b/backend/app/services/tool_registry.py @@ -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]]: diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4abecf4..8d73ca1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 } } ] }) diff --git a/frontend/src/views/PluginMarket.vue b/frontend/src/views/PluginMarket.vue new file mode 100644 index 0000000..6c0a2c5 --- /dev/null +++ b/frontend/src/views/PluginMarket.vue @@ -0,0 +1,402 @@ + + + + +