diff --git a/core/main_terminal.py b/core/main_terminal.py index be9c384..b3c064f 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -20,6 +20,7 @@ try: TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, ) except ImportError: import sys @@ -39,6 +40,7 @@ except ImportError: TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, ) from modules.file_manager import FileManager from modules.search_engine import SearchEngine @@ -54,6 +56,8 @@ from modules.personalization_manager import ( load_personalization_config, build_personalization_prompt, ) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor try: from config.limits import THINKING_FAST_INTERVAL except ImportError: @@ -139,6 +143,21 @@ class MainTerminal: self.current_session_id = 0 # 用于标识不同的任务会话 # 工具类别(可被管理员动态覆盖) self.tool_categories_map = dict(TOOL_CATEGORIES) + # 自定义工具仅管理员可见/可用 + self.user_role: str = "user" + self.custom_tools_enabled = bool(CUSTOM_TOOLS_ENABLED) + self.custom_tool_registry = CustomToolRegistry() + self.custom_tool_executor = CustomToolExecutor(self.custom_tool_registry, self.terminal_ops) + if self.custom_tools_enabled: + default_custom_cat = build_default_tool_category() + # 若未存在 custom 分类则添加,方便前端/策略统一展示 + if "custom" not in self.tool_categories_map: + self.tool_categories_map["custom"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_custom_cat["label"], + tools=default_custom_cat["tools"], + default_enabled=True, + silent_when_disabled=False, + ) self.admin_forced_category_states: Dict[str, Optional[bool]] = {} self.admin_disabled_models: List[str] = [] self.admin_policy_ui_blocks: Dict[str, bool] = {} @@ -564,6 +583,14 @@ class MainTerminal: """应用管理员策略(工具分类、强制开关、模型禁用)。""" if categories: self.tool_categories_map = dict(categories) + # 保证自定义工具分类存在(仅当功能启用) + if self.custom_tools_enabled and "custom" not in self.tool_categories_map: + self.tool_categories_map["custom"] = type(next(iter(TOOL_CATEGORIES.values())))( + label="自定义工具", + tools=[], + default_enabled=True, + silent_when_disabled=False, + ) # 重新构建启用状态映射,保留已有值 new_states: Dict[str, bool] = {} for key, cat in self.tool_categories_map.items(): @@ -1170,6 +1197,53 @@ class MainTerminal: params["required"] = ["intent"] return tools + # 自定义工具只对管理员开放,定义来自文件 registry + def _build_custom_tools(self) -> List[Dict]: + if not (self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin"): + return [] + try: + definitions = self.custom_tool_registry.reload() + except Exception: + definitions = self.custom_tool_registry.list_tools() + if not definitions: + # 更新分类为空列表,避免旧缓存 + if "custom" in self.tool_categories_map: + self.tool_categories_map["custom"].tools = [] + return [] + + tools: List[Dict] = [] + tool_ids: List[str] = [] + for item in definitions: + tool_id = item.get("id") + if not tool_id: + continue + if item.get("invalid_id"): + # 跳过不合法的工具 ID,避免供应商严格校验时报错 + continue + tool_ids.append(tool_id) + params = item.get("parameters") or {"type": "object", "properties": {}} + if isinstance(params, dict) and params.get("type") != "object": + params = {"type": "object", "properties": {}} + required = item.get("required") + if isinstance(required, list): + params = dict(params) + params["required"] = required + + tools.append({ + "type": "function", + "function": { + "name": tool_id, + "description": item.get("description") or f"自定义工具: {tool_id}", + "parameters": params + } + }) + + # 覆盖 custom 分类的工具列表 + if "custom" in self.tool_categories_map: + self.tool_categories_map["custom"].tools = tool_ids + + return tools + def define_tools(self) -> List[Dict]: """定义可用工具(添加确认工具)""" current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -1773,6 +1847,10 @@ class MainTerminal: } } }) + # 附加自定义工具(仅管理员可见) + custom_tools = self._build_custom_tools() + if custom_tools: + tools.extend(custom_tools) if self.disabled_tools: tools = [ tool for tool in tools @@ -1823,9 +1901,20 @@ class MainTerminal: "success": False, "error": f"参数预检查失败: {str(e)}" }, ensure_ascii=False) + + # 自定义工具预解析(仅管理员) + custom_tool = None + if self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin": + try: + self.custom_tool_registry.reload() + except Exception: + pass + custom_tool = self.custom_tool_registry.get_tool(tool_name) try: - if tool_name == "read_file": + if custom_tool: + result = await self.custom_tool_executor.run(tool_name, arguments) + elif tool_name == "read_file": result = self._handle_read_tool(arguments) elif tool_name in {"vlm_analyze", "ocr_image"}: path = arguments.get("path") diff --git a/doc/custom_tools_guide.md b/doc/custom_tools_guide.md new file mode 100644 index 0000000..4ee4968 --- /dev/null +++ b/doc/custom_tools_guide.md @@ -0,0 +1,502 @@ +# 自定义工具开发指南(非常详细|三层架构版) + +适用对象:**从零开始、第一次上手** 的管理员。 +当前能力:**仅支持 Python**,且必须在容器内执行;自定义工具仅管理员可见/可用。 + +> 本项目默认不做严格校验(私有化客制化场景)。这让你更自由,但也更容易踩坑。本文用大量例子/反例把坑提前说明。 + +--- +## 0. 快速上手(5 分钟 checklist) + +1) 创建工具文件夹:`data/custom_tools//` +2) 写 3 个文件: + - `definition.json`(定义层) + - `execution.py`(执行层) + - `return.json`(返回层,可选但强烈建议先写一个简单截断) +3) 刷新管理端: + - 打开 `admin/policy`:工具应该出现在 `custom` 分类的可选列表里 +4) 用管理员账号在聊天中调用工具(让模型调用或你手动触发) + +如果第 3 步看不到工具:先看本文的“常见问题/排查”章节。 + +--- +## 1. 三层架构:你在写什么? + +每个自定义工具由三层组成,各层之间通过“工具运行时”串起来: + +### 1.1 定义层:`definition.json` +你告诉系统: +- 工具 ID(也是模型调用的函数名) +- 工具用途描述(给模型看,决定它会不会用、怎么用) +- 参数 schema(模型按它组织参数) +- 分类/图标/超时等元信息 + +### 1.2 执行层:`execution.py`(Python 模板) +你提供“低代码逻辑”: +- 文件内容会先被做一次 **字符串格式化**(把 `{参数名}` 替换成实际入参) +- 然后在容器内作为 Python 脚本运行 + +### 1.3 返回层:`return.json`(后处理) +对执行结果二次处理,例如: +- 限制返回长度(避免输出爆炸) +- 用模板把结果包装成更友好的消息 + +--- +## 2. 目录结构规范(必须遵守) + +每个工具一个独立目录:`data/custom_tools//` + +标准文件名: +``` +definition.json # 定义层(必需) +execution.py # 执行层(必需) +return.json # 返回层(可选) +meta.json # 备注/展示层(可选) +``` + +只要某个目录同时存在 `definition.json` + `execution.py`,系统就会把它加载为“自定义工具”。 + +> 文件编码建议统一 UTF-8。 + +--- +## 3. 运行时规则(你需要牢记的“真实行为”) + +### 3.1 谁能看到/调用? +- **只有管理员账号**能看到/调用自定义工具 +- 普通用户看不到工具,也无法在聊天中调用 + +### 3.2 工具如何出现在 `admin/policy`? +- 后端会自动注入一个分类 `custom`(名称“自定义工具”) +- 并把扫描到的自定义工具 ID 填入该分类的工具列表 +- 所以 admin/policy 的“工具可选列表”会出现这些工具名,从而可分配到别的分类 + +### 3.3 执行层为什么“像模板”? +执行层会先做一次 Python `str.format` 类似的格式化: +- `{a}`、`{op}`、`{b}` 这样的占位符会被替换 +- 这会导致一个大坑:**Python 字典/集合的花括号 `{}` 会被误认为占位符**(下文详解) + +--- +## 4. 定义层 `definition.json`:字段规范 + 大量示例 + +### 4.1 推荐字段(强烈建议都写) +```json +{ + "id": "calc", + "description": "简单两数运算(加减乘除),仅管理员可见", + "parameters": { + "type": "object", + "properties": { + "a": { "type": "number", "description": "第一个数字" }, + "op": { "type": "string", "enum": ["+", "-", "*", "/"], "description": "运算符" }, + "b": { "type": "number", "description": "第二个数字" } + }, + "required": ["a", "op", "b"] + }, + "category": "custom", + "icon": "brain.svg", + "timeout": 15 +} +``` + +字段说明: +- `id`(必填):工具 ID,建议与文件夹名一致(例如文件夹 `calc/`,id 也叫 `calc`) +- `description`(强烈建议):越清楚越好(模型会读它决定是否调用) +- `parameters`(建议):JSON Schema 风格 + - `type`:固定 `"object"` + - `properties`:参数字典 + - `required`:必填参数数组 +- `category`(可选,默认 custom):用于管理端分类 +- `icon`(可选):SVG 文件名(来自 `static/icons/`) +- `timeout`(可选,默认 30):执行超时秒数 + +> 注意:当前实现里 `required` 以 `parameters.required` 为准;另外顶层 `required` 字段也会被识别,但建议统一写在 `parameters.required`。 + +--- +### 4.2 定义层示例 A:最小可用“回声工具” +文件:`data/custom_tools/echo/definition.json` +```json +{ + "id": "echo", + "description": "回声:原样返回 text", + "parameters": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "要回显的文本" } + }, + "required": ["text"] + }, + "category": "custom", + "timeout": 10 +} +``` + +--- +### 4.3 定义层示例 B:可选参数(optional)怎么写? +```json +{ + "id": "greet", + "description": "打招呼:name 可选", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "名字(可选)" } + }, + "required": [] + } +} +``` +执行层里要记得处理 name 缺失的情况(见执行层示例)。 + +--- +### 4.4 反例:参数 schema 写错导致模型不会传参 +反例(`type` 写成 string): +```json +{ + "id": "bad_schema", + "parameters": { + "type": "string", + "properties": { + "a": { "type": "number" } + } + } +} +``` +后果:模型端可能无法正确构造对象参数,调用失败或入参为空。 + +--- +## 5. 执行层 `execution.py`:核心坑点、写法规范、正反例 + +### 5.1 执行层“模板替换”规则(最关键) +执行层在运行前会做一次模板替换: +- `{a}` → 入参 a 的值 +- `{op}` → 入参 op 的值 + +因此: +- **你写的不是纯 Python,而是 Python + 占位符** +- 任何你写的 `{...}` 都可能被当成占位符解析 + +--- +### 5.2 大坑:字典/集合花括号必须双写 `{{` `}}` + +你之前遇到的报错: +```json +{ + "success": false, + "error": "缺少必填参数: \"'+'\"" +} +``` +根因就是模板引擎把这段当成“需要一个叫 `+'` 的占位符”: +```py +ops = {'+': operator.add, ...} +``` + +**正确写法:把字典的 `{}` 变成 `{{}}`** +示例(正确): +```py +ops = {{'+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.truediv}} +``` + +反例(错误): +```py +ops = {'+': operator.add} +``` + +同理:集合 `{1,2,3}`、字典推导式 `{k:v for ...}` 都需要转义,否则会被误当占位符。 + +--- +### 5.3 参数是字符串时,必须自己加引号(或安全编码) + +如果参数是字符串: +- `{name}` 替换后会变成裸文本,导致 Python 语法错误 + +正确(加引号): +```py +name = '{name}' +``` + +反例(没引号,容易 SyntaxError): +```py +name = {name} +``` + +更稳健的写法(推荐):用 JSON 序列化来安全注入字符串(避免引号/换行破坏代码)。 +```py +import json +name = json.loads({name_json}) +``` +但这需要你在定义层把参数设计成 `name_json`,且调用时传入 JSON 字符串;新手不建议第一天就这么做。 + +--- +### 5.4 强烈建议:执行层先做“输入校验”,再计算 +因为没有强校验,入参可能是空、类型不对、字符串里有奇怪字符。 + +示例:对 calc 的 op 做严格白名单检查: +```py +a = {a} +op = '{op}' +b = {b} +if op not in ['+','-','*','/']: + raise ValueError('op must be one of + - * /') +``` + +反例(危险):用 eval 解析运算符/表达式 +```py +print(eval(f\"{a}{op}{b}\")) # 不要这么写 +``` +原因:op 或其他参数可被注入恶意表达式。 + +--- +### 5.5 执行层示例 1:echo(最简单) +文件:`data/custom_tools/echo/execution.py` +```py +text = '{text}' +print(text) +``` + +--- +### 5.6 执行层示例 2:greet(可选参数) +文件:`data/custom_tools/greet/execution.py` +```py +name = '{name}' +name = (name or '').strip() +if not name: + print('你好!') +else: + print(f'你好,{name}!') +``` +注意:这里用到了 f-string 的 `{name}`,**它是 Python 运行时的花括号,不会参与模板替换**,因为模板替换已经在执行前完成,且我们这里的 `{name}` 是在 Python 代码字符串里,不是模板语法(但仍要注意别在最外层写出新的 `{}` 占位符)。 + +--- +### 5.7 执行层示例 3:calc(你要的三参数计算器) +文件:`data/custom_tools/calc/execution.py` +```py +a = {a} +op = '{op}' +b = {b} +import operator +ops = {{'+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.truediv}} +print(ops[op](a, b)) +``` + +要点回顾: +- 数字:`a = {a}`、`b = {b}` +- 字符串:`op = '{op}'` +- 字典:`ops = {{...}}`(双花括号) + +--- +### 5.8 反例:输出过大导致工具失败/截断 +反例: +```py +print('A' * 1000000) +``` +后果:输出有全局上限(默认 10k 字符左右),你会看到截断或失败提示。 +建议:配合 return.json 的 `truncate`,或自行在执行层控制输出长度。 + +--- +### 5.9 反例:死循环导致超时 +```py +while True: + pass +``` +后果:到 `timeout` 后被中断,返回 timeout。 + +--- +## 6. 返回层 `return.json`:怎么做二次处理(例子 + 反例) + +返回层是可选的,但强烈建议至少写一个 `truncate`,避免工具输出污染上下文。 + +### 6.1 支持字段(当前版本) +```json +{ + "truncate": 2000, + "template": "[tool_id={tool_id}] 输出: {output}" +} +``` + +- `truncate`:整数,输出超过该长度时截断到前 N 字符 +- `template`:字符串模板,可用变量: + - `{output}`:stdout+stderr 合并后的输出文本(已按 truncate 处理后的版本) + - `{stderr}`:stderr(如果存在) + - `{return_code}`:进程返回码 + - `{tool_id}`:工具 id + +> `template` 只是影响 `message` 展示,不会改变 `output` 字段本身(除 truncate)。 + +--- +### 6.2 返回层示例:calc 结果更友好 +文件:`data/custom_tools/calc/return.json` +```json +{ + "truncate": 2000, + "template": "[calc] 结果: {output}" +} +``` + +--- +### 6.3 反例:template 写错占位符导致忽略 +```json +{ "template": "结果={result}" } +``` +因为 `{result}` 不是支持变量,格式化会失败,系统会回退到默认 message(你会看到输出但不按模板展示)。 + +--- +## 7. 备注/展示层 `meta.json`:备注与图标 + +### 7.1 meta.json 适合放什么? +示例: +```json +{ + "notes": "这是一个示例工具,用于演示参数与模板写法", + "icon": "calculator.svg", + "owner": "admin", + "created_at": "2026-01-05" +} +``` + +### 7.2 图标怎么选? +目前图标字段会被保存/返回(前端后续可用它显示工具卡片 SVG)。 +建议写成 `static/icons/` 下的文件名,例如: +- `brain.svg` +- `terminal.svg` +- `bot.svg` + +你可以用命令查看有哪些图标: +```bash +ls static/icons +``` + +> 当前 UI 可能还没把 icon 真正渲染出来,但字段已经为未来对接准备好。 + +--- +## 8. 如何创建一个新工具:从 0 到可用(完整示例) + +下面示例创建一个工具 `text_len`:计算字符串长度,并在返回层截断。 + +### 8.1 创建目录 +``` +data/custom_tools/text_len/ +``` + +### 8.2 definition.json +```json +{ + "id": "text_len", + "description": "计算文本长度(字符数)", + "parameters": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "输入文本" } + }, + "required": ["text"] + }, + "category": "custom", + "timeout": 10 +} +``` + +### 8.3 execution.py +```py +text = '{text}' +print(len(text)) +``` + +### 8.4 return.json(可选) +```json +{ + "truncate": 200, + "template": "[text_len] 字符数: {output}" +} +``` + +### 8.5 验证 +1) 管理端 `admin/policy`:应看到 `text_len` +2) 管理员聊天:让模型调用 `text_len`,或提示它“用 text_len 计算这段话长度” + +--- +## 9. 用 API 管理工具(可选) + +如果你不想手工改文件,也可以调用后端接口(管理员权限): + +### 9.1 列表 +`GET /api/admin/custom-tools` + +### 9.2 新增/更新 +`POST /api/admin/custom-tools` + +请求 JSON 推荐结构(字段越全越好): +```json +{ + "id": "calc", + "description": "简单两数运算", + "parameters": { "type": "object", "properties": {}, "required": [] }, + "required": [], + "category": "custom", + "icon": "brain.svg", + "timeout": 15, + "execution_code": "print('hello')\n", + "return": { "truncate": 2000, "template": "输出: {output}" }, + "meta": { "notes": "通过 API 创建" } +} +``` + +### 9.3 删除 +`DELETE /api/admin/custom-tools?id=calc` + +--- +## 10. 常见问题与排查(非常实用) + +### 10.1 admin/policy 看不到工具 +逐条检查: +1) 你是否用 **管理员账号** 登录? +2) 工具目录是否存在:`data/custom_tools//` +3) 是否同时存在两个文件: + - `data/custom_tools//definition.json` + - `data/custom_tools//execution.py` +4) `definition.json` 是否是合法 JSON(多一个逗号都会解析失败) +5) 刷新页面(强刷),必要时重启后端服务 + +### 10.2 调用时报 “缺少必填参数: \"'+'\"” 或类似奇怪 key +几乎可以确定是:**执行层里有未转义的字典/集合花括号**。 +把 `{` 改成 `{{`,把 `}` 改成 `}}`(只针对“字典/集合字面量部分”)。 + +### 10.3 调用时报 SyntaxError(语法错误) +常见原因: +- 字符串参数没加引号:`name = {name}`(错误) +- 参数中包含换行/引号导致代码被截断(建议先做简单输入,或改用更稳健的注入方式) + +### 10.4 调用成功但输出很乱/太长 +解决: +- 在 `return.json` 里加 `truncate` +- 或执行层自行控制输出 + +### 10.5 工具能执行但模型不愿意用/总用错 +解决: +- 优化 `description`:写清楚输入输出、限制、例子 +- `parameters.properties.*.description` 写清楚含义 +- `enum` 尽量给明确可选值(例如运算符) + +--- +## 11. 规范建议(团队协作必看) + +### 11.1 命名规范 +- 工具 id:小写 + 下划线(例如 `calc`, `text_len`, `db_query`) +- 目录名与 `definition.json.id` 保持一致 + +### 11.2 输出规范 +建议输出尽量“短而确定”: +- 优先输出一个结果值或一段简短摘要 +- 需要结构化时输出 JSON(但注意长度) + +### 11.3 安全与边界 +虽然是私有化环境,也强烈建议: +- 不要在执行层写破坏性命令/删除文件 +- 不要 `eval` 用户输入 +- 给工具合理的 `timeout` +- 给返回层加 `truncate` + +--- +## 12. 当前版本限制(请先接受这些现实) +- 仅支持 Python(没有 NodeJS 执行) +- 不提供强校验与沙箱策略自定义(依赖现有容器执行与全局限制) +- 图标字段已预留(`icon`),但前端是否渲染取决于后续 UI 接入 + diff --git a/modules/custom_tool_registry.py b/modules/custom_tool_registry.py new file mode 100644 index 0000000..5b61798 --- /dev/null +++ b/modules/custom_tool_registry.py @@ -0,0 +1,201 @@ +"""自定义工具注册与存储。 + +- 仅支持全局管理员可见/可用。 +- 每个工具一个文件夹,三层各自独立文件: + 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, + } diff --git a/static/custom_tools/guide.html b/static/custom_tools/guide.html new file mode 100644 index 0000000..924fef7 --- /dev/null +++ b/static/custom_tools/guide.html @@ -0,0 +1,15 @@ + + + + + + 自定义工具开发指南 + + + + + +
+ + + diff --git a/static/custom_tools/index.html b/static/custom_tools/index.html new file mode 100644 index 0000000..d4b9b5c --- /dev/null +++ b/static/custom_tools/index.html @@ -0,0 +1,15 @@ + + + + + + 自定义工具管理 + + + + + +
+ + + diff --git a/static/src/admin/CustomToolsApp.vue b/static/src/admin/CustomToolsApp.vue new file mode 100644 index 0000000..ade5c2b --- /dev/null +++ b/static/src/admin/CustomToolsApp.vue @@ -0,0 +1,675 @@ + + + + + diff --git a/static/src/admin/CustomToolsGuideApp.vue b/static/src/admin/CustomToolsGuideApp.vue new file mode 100644 index 0000000..9e5a306 --- /dev/null +++ b/static/src/admin/CustomToolsGuideApp.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/static/src/admin/customToolsGuideMain.ts b/static/src/admin/customToolsGuideMain.ts new file mode 100644 index 0000000..5dec77f --- /dev/null +++ b/static/src/admin/customToolsGuideMain.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import CustomToolsGuideApp from './CustomToolsGuideApp.vue'; + +createApp(CustomToolsGuideApp).mount('#custom-tools-guide'); diff --git a/static/src/env.d.ts b/static/src/env.d.ts index 71c238b..2792ba9 100644 --- a/static/src/env.d.ts +++ b/static/src/env.d.ts @@ -6,3 +6,7 @@ declare module '*.vue' { declare module 'prismjs'; declare module 'katex/contrib/auto-render'; +declare module '*.md' { + const content: string; + export default content; +} diff --git a/utils/logger.py b/utils/logger.py index 9546583..996fe85 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -34,9 +34,9 @@ def setup_logger(name: str, log_file: str = None) -> logging.Logger: # 创建格式化器 formatter = logging.Formatter(LOG_FORMAT) - # 控制台处理器(只显示WARNING及以上) + # 控制台处理器(与全局 LOG_LEVEL 保持一致,便于实时查看) console_handler = logging.StreamHandler() - console_handler.setLevel(logging.WARNING) + console_handler.setLevel(getattr(logging, LOG_LEVEL)) console_handler.setFormatter(formatter) logger.addHandler(console_handler) diff --git a/vite.config.ts b/vite.config.ts index fbf6d89..d6faa7a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,8 @@ import vue from '@vitejs/plugin-vue'; const entry = fileURLToPath(new URL('./static/src/main.ts', import.meta.url)); const adminEntry = fileURLToPath(new URL('./static/src/admin/main.ts', import.meta.url)); const adminPolicyEntry = fileURLToPath(new URL('./static/src/admin/policyMain.ts', import.meta.url)); +const adminCustomToolsEntry = fileURLToPath(new URL('./static/src/admin/customToolsMain.ts', import.meta.url)); +const adminCustomToolsGuideEntry = fileURLToPath(new URL('./static/src/admin/customToolsGuideMain.ts', import.meta.url)); export default defineConfig({ plugins: [vue()], @@ -15,7 +17,9 @@ export default defineConfig({ input: { main: entry, admin: adminEntry, - adminPolicy: adminPolicyEntry + adminPolicy: adminPolicyEntry, + adminCustomTools: adminCustomToolsEntry, + adminCustomToolsGuide: adminCustomToolsGuideEntry }, output: { entryFileNames: 'assets/[name].js', diff --git a/web_server.py b/web_server.py index 5a2d9f7..b994c17 100644 --- a/web_server.py +++ b/web_server.py @@ -22,6 +22,7 @@ from datetime import datetime from collections import defaultdict, deque, Counter from config.model_profiles import get_model_profile from modules import admin_policy_manager, balance_client +from modules.custom_tool_registry import CustomToolRegistry from werkzeug.utils import secure_filename from werkzeug.routing import BaseConverter import secrets @@ -156,7 +157,7 @@ def build_review_lines(messages, limit=None): # 控制台输出策略:默认静默,只保留简要事件 _ORIGINAL_PRINT = print -ENABLE_VERBOSE_CONSOLE = False +ENABLE_VERBOSE_CONSOLE = True def brief_log(message: str): @@ -269,6 +270,7 @@ class ConversationIdConverter(BaseConverter): app.url_map.converters['conv'] = ConversationIdConverter user_manager = UserManager() +custom_tool_registry = CustomToolRegistry() container_manager = UserContainerManager() user_terminals: Dict[str, WebTerminal] = {} terminal_rooms: Dict[str, set] = {} @@ -288,6 +290,8 @@ MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {} MONITOR_SNAPSHOT_CACHE_LIMIT = 120 ADMIN_ASSET_DIR = (Path(app.static_folder) / 'admin_dashboard').resolve() +ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve() +ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve() RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_FEED_LIMIT = 60 @@ -818,6 +822,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm username = (username or get_current_username()) if not username: return None, None + record = get_current_user_record() workspace = user_manager.ensure_user_workspace(username) container_handle = container_manager.ensure_container(username, str(workspace.project_path)) usage_tracker = get_or_create_usage_tracker(username, workspace) @@ -856,6 +861,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm terminal.terminal_manager.broadcast = terminal.message_callback user_terminals[username] = terminal terminal.username = username + terminal.user_role = get_current_user_role(record) terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode @@ -863,6 +869,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm terminal.update_container_session(container_handle) attach_user_broadcast(terminal, username) terminal.username = username + terminal.user_role = get_current_user_role(record) terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) # 应用管理员策略(工具分类、强制开关、模型禁用) @@ -1466,6 +1473,13 @@ def admin_policy_page(): """管理员策略配置页面""" return send_from_directory(Path(app.static_folder) / 'admin_policy', 'index.html') +@app.route('/admin/custom-tools') +@login_required +@admin_required +def admin_custom_tools_page(): + """自定义工具管理页面""" + return send_from_directory(str(ADMIN_CUSTOM_TOOLS_DIR), 'index.html') + @app.route('/api/admin/balance', methods=['GET']) @login_required @@ -1670,6 +1684,75 @@ def admin_policy_api(): except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 + +@app.route('/api/admin/custom-tools', methods=['GET', 'POST', 'DELETE']) +@api_login_required +@admin_api_required +def admin_custom_tools_api(): + """自定义工具管理(仅全局管理员)。""" + try: + if request.method == 'GET': + return jsonify({"success": True, "data": custom_tool_registry.list_tools()}) + if request.method == 'POST': + payload = request.get_json() or {} + saved = custom_tool_registry.upsert_tool(payload) + return jsonify({"success": True, "data": saved}) + # DELETE + tool_id = request.args.get("id") or (request.get_json() or {}).get("id") + if not tool_id: + return jsonify({"success": False, "error": "缺少 id"}), 400 + removed = custom_tool_registry.delete_tool(tool_id) + if removed: + return jsonify({"success": True, "data": {"deleted": tool_id}}) + return jsonify({"success": False, "error": "未找到该工具"}), 404 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + logging.exception("custom-tools API error") + return jsonify({"success": False, "error": str(exc)}), 500 + + +@app.route('/api/admin/custom-tools/file', methods=['GET', 'POST']) +@api_login_required +@admin_api_required +def admin_custom_tools_file_api(): + tool_id = request.args.get("id") or (request.get_json() or {}).get("id") + name = request.args.get("name") or (request.get_json() or {}).get("name") + if not tool_id or not name: + return jsonify({"success": False, "error": "缺少 id 或 name"}), 400 + tool_dir = Path(custom_tool_registry.root) / tool_id + if not tool_dir.exists(): + return jsonify({"success": False, "error": "工具不存在"}), 404 + target = tool_dir / name + + if request.method == 'GET': + if not target.exists(): + return jsonify({"success": False, "error": "文件不存在"}), 404 + try: + return target.read_text(encoding="utf-8") + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + # POST 保存文件 + payload = request.get_json() or {} + content = payload.get("content") + try: + target.write_text(content or "", encoding="utf-8") + return jsonify({"success": True}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@app.route('/api/admin/custom-tools/reload', methods=['POST']) +@api_login_required +@admin_api_required +def admin_custom_tools_reload_api(): + try: + custom_tool_registry.reload() + return jsonify({"success": True}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + @app.route('/api/effective-policy', methods=['GET']) @api_login_required def effective_policy_api():