"""自定义工具注册与存储。 - 仅支持全局管理员可见/可用。 - 每个工具一个文件夹,三层各自独立文件: definition.json / execution.py / return.json (+ meta.json 备注)。 - 目前执行层仅支持 python 代码模板;Node/HTTP 暂未启用。 """ from __future__ import annotations import json import shutil from pathlib import Path from typing import Dict, List, Optional, Any import re from config import ( CUSTOM_TOOL_DIR, CUSTOM_TOOLS_ENABLED, CUSTOM_TOOL_DEFINITION_FILE, CUSTOM_TOOL_EXECUTION_FILE, CUSTOM_TOOL_RETURN_FILE, CUSTOM_TOOL_META_FILE, ) class CustomToolRegistry: """文件式 registry,扫描目录结构提供增删改查。""" ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") def __init__(self, root: str = CUSTOM_TOOL_DIR, enabled: bool = CUSTOM_TOOLS_ENABLED): self.root = Path(root).expanduser().resolve() self.enabled = bool(enabled) self.root.mkdir(parents=True, exist_ok=True) self._cache: List[Dict[str, Any]] = [] if self.enabled: self._cache = self._load_all() @classmethod def _is_valid_tool_id(cls, tool_id: str) -> bool: """工具 ID 规则:以字母开头,可包含字母、数字、下划线、短横线。""" return bool(tool_id and cls.ID_PATTERN.match(tool_id)) # ------------------------------------------------------------------ # 加载与持久化 # ------------------------------------------------------------------ def _load_tool_dir(self, tool_dir: Path) -> Optional[Dict[str, Any]]: try: if not tool_dir.is_dir(): return None def_path = tool_dir / CUSTOM_TOOL_DEFINITION_FILE exec_path = tool_dir / CUSTOM_TOOL_EXECUTION_FILE if not def_path.exists() or not exec_path.exists(): return None definition = json.loads(def_path.read_text(encoding="utf-8")) if not isinstance(definition, dict): return None tool_id = definition.get("id") or tool_dir.name execution_code = exec_path.read_text(encoding="utf-8") timeout = definition.get("timeout") execution = { "type": "python", "timeout": timeout, "code_template": execution_code, "file": str(exec_path), } return_conf = {} ret_path = tool_dir / CUSTOM_TOOL_RETURN_FILE if ret_path.exists(): try: data = json.loads(ret_path.read_text(encoding="utf-8")) if isinstance(data, dict): return_conf = data except Exception: pass meta = {} meta_path = tool_dir / CUSTOM_TOOL_META_FILE if meta_path.exists(): try: data = json.loads(meta_path.read_text(encoding="utf-8")) if isinstance(data, dict): meta = data except Exception: pass is_valid_id = self._is_valid_tool_id(tool_id) return { "id": tool_id, "description": definition.get("description") or f"自定义工具 {tool_id}", "parameters": definition.get("parameters") or {"type": "object", "properties": {}}, "required": definition.get("required") or [], "category": definition.get("category") or "custom", "icon": definition.get("icon") or meta.get("icon"), "execution": execution, "execution_code": execution_code, "return": return_conf, "meta": meta, "invalid_id": not is_valid_id, "validation_error": None if is_valid_id else "工具ID需以字母开头,可含字母/数字/_/-", } except Exception: return None def _load_all(self) -> List[Dict[str, Any]]: tools: List[Dict[str, Any]] = [] for child in sorted(self.root.iterdir()): item = self._load_tool_dir(child) if item: tools.append(item) return tools def reload(self) -> List[Dict[str, Any]]: self._cache = self._load_all() return list(self._cache) # ------------------------------------------------------------------ # 对外接口 # ------------------------------------------------------------------ def list_tools(self) -> List[Dict[str, Any]]: return list(self._cache) def get_tool(self, tool_id: str) -> Optional[Dict[str, Any]]: for item in self._cache: if item.get("id") == tool_id: return item return None def upsert_tool(self, payload: Dict[str, Any]) -> Dict[str, Any]: """插入或更新单个工具定义(不做深度校验,满足私有化需求)。""" tool_id = (payload.get("id") or "").strip() if not tool_id: raise ValueError("id 必填") if not self._is_valid_tool_id(tool_id): raise ValueError("工具 ID 不合法:需以字母开头,可包含字母、数字、下划线、短横线") tool_dir = self.root / tool_id tool_dir.mkdir(parents=True, exist_ok=True) # definition definition = { "id": tool_id, "description": payload.get("description"), "parameters": payload.get("parameters"), "required": payload.get("required"), "category": payload.get("category") or "custom", "icon": payload.get("icon"), "timeout": payload.get("timeout"), } (tool_dir / CUSTOM_TOOL_DEFINITION_FILE).write_text( json.dumps(definition, ensure_ascii=False, indent=2), encoding="utf-8" ) # execution code exec_code = payload.get("execution_code") or ( (payload.get("execution") or {}).get("code_template") ) if not exec_code: exec_code = "# add python code here\n" (tool_dir / CUSTOM_TOOL_EXECUTION_FILE).write_text(exec_code, encoding="utf-8") # return layer return_conf = payload.get("return") or payload.get("return_config") or {} if return_conf: (tool_dir / CUSTOM_TOOL_RETURN_FILE).write_text( json.dumps(return_conf, ensure_ascii=False, indent=2), encoding="utf-8" ) elif (tool_dir / CUSTOM_TOOL_RETURN_FILE).exists(): (tool_dir / CUSTOM_TOOL_RETURN_FILE).unlink() # meta meta = payload.get("meta") or payload.get("notes") or {} if meta: (tool_dir / CUSTOM_TOOL_META_FILE).write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" ) elif (tool_dir / CUSTOM_TOOL_META_FILE).exists(): (tool_dir / CUSTOM_TOOL_META_FILE).unlink() self.reload() return self.get_tool(tool_id) or {} def delete_tool(self, tool_id: str) -> bool: tool_dir = self.root / tool_id if tool_dir.exists() and tool_dir.is_dir(): shutil.rmtree(tool_dir) self.reload() return True return False def build_default_tool_category() -> Dict[str, Any]: """生成自定义工具的默认类别定义,用于前端和终端展示。""" return { "id": "custom", "label": "自定义工具", "tools": [], "default_enabled": True, "silent_when_disabled": False, }