feat: add custom tools guide and id validation

This commit is contained in:
JOJO 2026-01-05 21:46:55 +08:00
parent 4cfd0ef090
commit 5c7cdd72c9
12 changed files with 1755 additions and 5 deletions

View File

@ -20,6 +20,7 @@ try:
TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
) )
except ImportError: except ImportError:
import sys import sys
@ -39,6 +40,7 @@ except ImportError:
TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
) )
from modules.file_manager import FileManager from modules.file_manager import FileManager
from modules.search_engine import SearchEngine from modules.search_engine import SearchEngine
@ -54,6 +56,8 @@ from modules.personalization_manager import (
load_personalization_config, load_personalization_config,
build_personalization_prompt, build_personalization_prompt,
) )
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
try: try:
from config.limits import THINKING_FAST_INTERVAL from config.limits import THINKING_FAST_INTERVAL
except ImportError: except ImportError:
@ -139,6 +143,21 @@ class MainTerminal:
self.current_session_id = 0 # 用于标识不同的任务会话 self.current_session_id = 0 # 用于标识不同的任务会话
# 工具类别(可被管理员动态覆盖) # 工具类别(可被管理员动态覆盖)
self.tool_categories_map = dict(TOOL_CATEGORIES) 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_forced_category_states: Dict[str, Optional[bool]] = {}
self.admin_disabled_models: List[str] = [] self.admin_disabled_models: List[str] = []
self.admin_policy_ui_blocks: Dict[str, bool] = {} self.admin_policy_ui_blocks: Dict[str, bool] = {}
@ -564,6 +583,14 @@ class MainTerminal:
"""应用管理员策略(工具分类、强制开关、模型禁用)。""" """应用管理员策略(工具分类、强制开关、模型禁用)。"""
if categories: if categories:
self.tool_categories_map = dict(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] = {} new_states: Dict[str, bool] = {}
for key, cat in self.tool_categories_map.items(): for key, cat in self.tool_categories_map.items():
@ -1170,6 +1197,53 @@ class MainTerminal:
params["required"] = ["intent"] params["required"] = ["intent"]
return tools 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]: def define_tools(self) -> List[Dict]:
"""定义可用工具(添加确认工具)""" """定义可用工具(添加确认工具)"""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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: if self.disabled_tools:
tools = [ tools = [
tool for tool in tools tool for tool in tools
@ -1824,8 +1902,19 @@ class MainTerminal:
"error": f"参数预检查失败: {str(e)}" "error": f"参数预检查失败: {str(e)}"
}, ensure_ascii=False) }, ensure_ascii=False)
# 自定义工具预解析(仅管理员)
custom_tool = None
if self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin":
try: try:
if tool_name == "read_file": self.custom_tool_registry.reload()
except Exception:
pass
custom_tool = self.custom_tool_registry.get_tool(tool_name)
try:
if custom_tool:
result = await self.custom_tool_executor.run(tool_name, arguments)
elif tool_name == "read_file":
result = self._handle_read_tool(arguments) result = self._handle_read_tool(arguments)
elif tool_name in {"vlm_analyze", "ocr_image"}: elif tool_name in {"vlm_analyze", "ocr_image"}:
path = arguments.get("path") path = arguments.get("path")

502
doc/custom_tools_guide.md Normal file
View File

@ -0,0 +1,502 @@
# 自定义工具开发指南(非常详细|三层架构版)
适用对象:**从零开始、第一次上手** 的管理员。
当前能力:**仅支持 Python**,且必须在容器内执行;自定义工具仅管理员可见/可用。
> 本项目默认不做严格校验(私有化客制化场景)。这让你更自由,但也更容易踩坑。本文用大量例子/反例把坑提前说明。
---
## 0. 快速上手5 分钟 checklist
1) 创建工具文件夹:`data/custom_tools/<tool_id>/`
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/<tool_id>/`
标准文件名:
```
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 执行层示例 1echo最简单
文件:`data/custom_tools/echo/execution.py`
```py
text = '{text}'
print(text)
```
---
### 5.6 执行层示例 2greet可选参数
文件:`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 执行层示例 3calc你要的三参数计算器
文件:`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/<tool_id>/`
3) 是否同时存在两个文件:
- `data/custom_tools/<tool_id>/definition.json`
- `data/custom_tools/<tool_id>/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 接入

View File

@ -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,
}

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>自定义工具开发指南</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/dist/assets/adminCustomToolsGuide.css" />
<script src="/static/security.js"></script>
</head>
<body>
<div id="custom-tools-guide"></div>
<script type="module" src="/static/dist/assets/adminCustomToolsGuide.js"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>自定义工具管理</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/dist/assets/adminCustomTools.css" />
<script src="/static/security.js"></script>
</head>
<body>
<div id="custom-tools-app"></div>
<script type="module" src="/static/dist/assets/adminCustomTools.js"></script>
</body>
</html>

View File

@ -0,0 +1,675 @@
<template>
<div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }">
<!-- 列表模式 -->
<template v-if="!editorPageMode">
<header class="page-header">
<div>
<h1>自定义工具管理</h1>
<p>每个工具一个文件夹三层文件独立存放仅管理员可见并可调用</p>
</div>
<div class="actions">
<button type="button" class="primary" @click="openCreateModal">新建工具</button>
<button type="button" class="ghost" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button>
<a class="ghost" href="/static/custom_tools/guide.html" target="_blank" rel="noopener">查看开发指南</a>
<a class="ghost" href="/admin/monitor" target="_blank" rel="noopener">返回监控</a>
<a class="ghost" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
</div>
</header>
<section class="panel" v-if="error">
<p class="error">加载失败{{ error }}</p>
</section>
<section class="panel" v-else>
<div class="tool-list-header">
<h2>工具列表</h2>
<span class="muted"> {{ tools.length }} </span>
</div>
<div v-if="!tools.length" class="empty">暂无自定义工具</div>
<div class="tool-grid">
<div v-for="tool in tools" :key="tool.id" class="tool-card" @click="openEditorPage(tool)">
<div class="tool-card-title">
<strong>{{ tool.id }}</strong>
<span class="tag">{{ tool.category || 'custom' }}</span>
</div>
<p class="desc">{{ tool.description || '暂无描述' }}</p>
<div class="meta">
<span>参数{{ paramCount(tool) }} </span>
<span>超时{{ tool.timeout || 30 }}s</span>
</div>
<div class="files">
<span>{{ tool.execution_file || 'execution.py' }}</span>
<span>{{ tool.return_file || 'return.json' }}</span>
</div>
</div>
</div>
</section>
</template>
<!-- 纯编辑页面 -->
<section v-else class="editor-page">
<div class="editor-page-header">
<div class="breadcrumbs">
<a class="ghost" href="/admin/custom-tools">返回列表</a>
</div>
<div class="status" v-if="loading">加载中...</div>
<div class="status error" v-else-if="error">{{ error }}</div>
</div>
<div v-if="activeTool" class="editor-panel">
<header class="drawer-header">
<div>
<h3>{{ activeTool.id }}</h3>
<p>{{ activeTool.description || '未填写描述' }}</p>
</div>
<div class="drawer-actions">
<button type="button" class="primary" @click="saveAll" :disabled="saving">{{ saving ? '保存中...' : '保存' }}</button>
<button type="button" class="danger" @click="openDeleteConfirm()">删除</button>
<button type="button" class="ghost" @click="closeEditor">关闭</button>
</div>
</header>
<div class="tabs">
<button :class="{active: tab==='definition'}" @click="tab='definition'">definition.json</button>
<button :class="{active: tab==='execution'}" @click="tab='execution'">execution.py</button>
<button :class="{active: tab==='return'}" @click="tab='return'">return.json</button>
<button :class="{active: tab==='meta'}" @click="tab='meta'">meta.json</button>
</div>
<div class="editor">
<textarea v-if="tab==='definition'" v-model="buffers.definition" spellcheck="false"></textarea>
<textarea v-else-if="tab==='execution'" v-model="buffers.execution" spellcheck="false"></textarea>
<textarea v-else-if="tab==='return'" v-model="buffers.return" spellcheck="false"></textarea>
<textarea v-else v-model="buffers.meta" spellcheck="false"></textarea>
</div>
<div class="hint">
<p>提示execution.py 中的字典/集合需要用 <code v-pre>{{ ... }}</code> 包裹避免被模板替换</p>
<p>保存后无需重启系统会自动 reload 自定义工具</p>
</div>
</div>
<div v-else-if="!error" class="empty">正在加载工具...</div>
</section>
<!-- 创建工具弹窗 -->
<transition name="fade">
<div v-if="createModal" class="modal-backdrop">
<div class="modal">
<h3>创建新工具</h3>
<label>工具 ID小写/下划线<input v-model="createForm.id" /></label>
<label>描述<input v-model="createForm.description" /></label>
<div class="modal-actions">
<button type="button" class="ghost" @click="createModal=false">取消</button>
<button type="button" class="primary" @click="createTool" :disabled="creating">{{ creating ? '创建中...' : '创建' }}</button>
</div>
</div>
</div>
</transition>
<!-- 删除确认弹窗 -->
<transition name="fade">
<div v-if="confirmDeleteModal" class="modal-backdrop">
<div class="modal">
<h3>确认删除</h3>
<p class="delete-tip">确定删除工具 <strong>{{ deleteTargetId }}</strong> 该操作不可恢复</p>
<div class="modal-actions">
<button type="button" class="ghost" @click="confirmDeleteModal=false">取消</button>
<button type="button" class="danger" @click="performDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
interface CustomTool {
id: string;
description?: string;
parameters?: any;
category?: string;
icon?: string | null;
timeout?: number;
execution_file?: string;
return_file?: string;
}
const tools = ref<CustomTool[]>([]);
const loading = ref(false);
const error = ref('');
const activeTool = ref<CustomTool | null>(null);
const pendingOpenId = ref('');
const editorPageMode = ref(false);
const tab = ref<'definition' | 'execution' | 'return' | 'meta'>('definition');
const saving = ref(false);
const createModal = ref(false);
const creating = ref(false);
const confirmDeleteModal = ref(false);
const deleting = ref(false);
const deleteTargetId = ref('');
const createForm = reactive({ id: '', description: '' });
const buffers = reactive({ definition: '', execution: '', return: '', meta: '' });
const refresh = async () => {
loading.value = true;
error.value = '';
try {
const res = await fetch('/api/admin/custom-tools', { credentials: 'same-origin' });
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
tools.value = (data.data || []).map((t: any) => ({
...t,
execution_file: (t.execution && t.execution.file) || 'execution.py',
return_file: t.return ? 'return.json' : '(无返回层)'
}));
if (pendingOpenId.value) {
const target = tools.value.find((t) => t.id === pendingOpenId.value);
if (target) {
await openEditorInline(target);
} else {
error.value = `未找到工具 ${pendingOpenId.value}`;
}
pendingOpenId.value = '';
}
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
};
const openEditorInline = async (tool: CustomTool) => {
activeTool.value = tool;
tab.value = 'definition';
await loadBuffers(tool.id);
};
const openEditorPage = (tool: CustomTool) => {
const url = `/admin/custom-tools?tool=${encodeURIComponent(tool.id)}`;
window.open(url, '_blank', 'noopener');
};
const loadBuffers = async (id: string) => {
try {
buffers.definition = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`);
buffers.execution = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=execution.py`);
buffers.return = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=return.json`, '');
buffers.meta = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=meta.json`, '');
} catch (e: any) {
error.value = e.message;
}
};
const fetchText = async (url: string, fallback = ''): Promise<string> => {
const res = await fetch(url, { credentials: 'same-origin' });
if (res.status === 404) return fallback;
const text = await res.text();
return text || fallback;
};
const saveAll = async () => {
if (!activeTool.value) return;
saving.value = true;
try {
await saveFile(activeTool.value.id, 'definition.json', buffers.definition);
await saveFile(activeTool.value.id, 'execution.py', buffers.execution);
await saveFile(activeTool.value.id, 'return.json', buffers.return);
await saveFile(activeTool.value.id, 'meta.json', buffers.meta);
await reloadRegistry();
await refresh();
} catch (e: any) {
error.value = e.message;
} finally {
saving.value = false;
}
};
const saveFile = async (id: string, name: string, content: string) => {
const res = await fetch('/api/admin/custom-tools/file', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, content })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || `保存 ${name} 失败`);
};
const reloadRegistry = async () => {
await fetch('/api/admin/custom-tools/reload', { method: 'POST', credentials: 'same-origin' });
};
const confirmDelete = async () => {
if (!activeTool.value) return;
openDeleteConfirm(activeTool.value);
};
const openDeleteConfirm = (tool?: CustomTool) => {
const target = tool || activeTool.value;
if (!target) return;
deleteTargetId.value = target.id;
confirmDeleteModal.value = true;
};
const performDelete = async () => {
if (!deleteTargetId.value) return;
deleting.value = true;
try {
const res = await fetch(`/api/admin/custom-tools?id=${encodeURIComponent(deleteTargetId.value)}`, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json();
if (!data.success) throw new Error(data.error || '删除失败');
activeTool.value = null;
confirmDeleteModal.value = false;
deleteTargetId.value = '';
await refresh();
} catch (e: any) {
error.value = e.message;
}
deleting.value = false;
};
const openCreateModal = () => {
createForm.id = '';
createForm.description = '';
createModal.value = true;
};
const createTool = async () => {
const id = createForm.id.trim();
if (!id) {
error.value = '请填写工具 ID';
return;
}
const idPattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
if (!idPattern.test(id)) {
error.value = '工具 ID 需以字母开头,可包含字母/数字/_/-';
return;
}
creating.value = true;
try {
const payload = {
id,
description: createForm.description,
parameters: { type: 'object', properties: {}, required: [] },
execution_code: "print('hello')\n",
category: 'custom'
};
const res = await fetch('/api/admin/custom-tools', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!data.success) throw new Error(data.error || '创建失败');
createModal.value = false;
activeTool.value = null;
await refresh();
window.location.href = `/admin/custom-tools?tool=${encodeURIComponent(id)}`;
} catch (e: any) {
error.value = e.message;
} finally {
creating.value = false;
}
};
const closeEditor = () => {
window.location.href = '/admin/custom-tools';
};
const paramCount = (tool: CustomTool) => {
const props = tool.parameters?.properties || {};
return Object.keys(props).length;
};
onMounted(async () => {
const params = new URLSearchParams(window.location.search);
const toolId = params.get('tool');
if (toolId) {
pendingOpenId.value = toolId;
editorPageMode.value = true;
}
await refresh();
});
</script>
<style scoped>
/* Claude 经典:米色+白色+深棕的柔和高对比配色 */
:global(html, body) {
min-height: 100%;
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent),
#f8f3e8;
}
:global(#custom-tools-app) {
min-height: 100vh;
}
.custom-tools-page {
--sand: #f8f3e8;
--sand-strong: #efe5d6;
--paper: #ffffff;
--brown: #4b3626;
--brown-strong: #2f2015;
--ink: #1a120b;
--muted: #6f635a;
--border: #e3d8c8;
--glow: 0 14px 40px rgba(47, 32, 21, 0.12);
--radius-lg: 18px;
--radius-md: 12px;
width: 100%;
max-width: 1180px;
margin: 0 auto;
padding: 28px;
font-family: 'Space Grotesk', 'Noto Sans SC', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--ink);
background: transparent;
}
.custom-tools-page.editor-only {
max-width: 1080px;
width: min(1080px, 100%);
padding: 20px 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.page-header h1 {
margin: 0;
letter-spacing: 0.4px;
color: var(--brown-strong);
}
.page-header p {
margin: 4px 0 0;
color: var(--muted);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.panel {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px;
box-shadow: var(--glow);
}
.tool-list-header {
display: flex;
align-items: center;
gap: 8px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-top: 12px;
}
.tool-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s, background 0.2s;
background: linear-gradient(180deg, #fffdf8 0%, #ffffff 50%, #fbf6ed 100%);
}
.tool-card:hover {
border-color: var(--brown);
box-shadow: 0 12px 30px rgba(47, 32, 21, 0.12);
transform: translateY(-1px);
}
.tool-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tool-card-title strong {
word-break: break-all;
color: var(--brown-strong);
}
.desc {
margin: 6px 0;
color: var(--muted);
min-height: 32px;
word-break: break-word;
}
.meta,
.files {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--muted);
flex-wrap: wrap;
}
.files span {
max-width: 100%;
word-break: break-all;
}
.tag {
background: rgba(75, 54, 38, 0.08);
color: var(--brown-strong);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
white-space: nowrap;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.drawer-header h3 {
margin: 0;
}
.editor-panel {
margin-top: 16px;
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px;
box-shadow: var(--glow);
width: 100%;
box-sizing: border-box;
}
.editor-page {
margin-top: 12px;
}
.editor-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.breadcrumbs {
display: flex;
gap: 8px;
align-items: center;
}
.status {
color: var(--muted);
}
.status.error {
color: #dc2626;
}
.editor-only {
max-width: 1080px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tabs button {
padding: 8px 12px;
border: 1px solid var(--border);
background: #fdf8ef;
border-radius: 10px;
cursor: pointer;
color: var(--muted);
}
.tabs button.active {
border-color: var(--brown);
color: var(--brown-strong);
background: #fff;
box-shadow: inset 0 0 0 1px rgba(75, 54, 38, 0.04);
}
.editor {
flex: 1;
min-height: 320px;
width: 100%;
overflow: auto;
}
.editor textarea {
width: 100%;
height: 100%;
min-height: 320px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 13px;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
outline: none;
resize: vertical;
background: #fdf9f1;
color: var(--ink);
line-height: 1.5;
box-shadow: inset 0 2px 6px rgba(47, 32, 21, 0.04);
box-sizing: border-box;
max-width: 100%;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(26, 18, 11, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 2100;
}
.modal {
background: var(--paper);
padding: 18px;
border-radius: var(--radius-lg);
width: 360px;
max-width: min(420px, 90vw);
box-sizing: border-box;
box-shadow: 0 16px 40px rgba(47, 32, 21, 0.18);
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid var(--border);
}
.modal h3 {
margin: 0;
color: var(--brown-strong);
}
.modal input {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: #fdf8ef;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.delete-tip {
margin: 4px 0 8px;
color: var(--muted);
}
.error { color: #dc2626; }
.empty { color: var(--muted); padding: 12px; }
/* 按钮样式与主站一致风格 */
.primary,
.ghost,
.danger {
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.1;
border-radius: 12px;
padding: 10px 18px;
}
.primary {
background: linear-gradient(135deg, var(--claude-accent, #da7756) 0%, var(--claude-accent-strong, #bd5d3a) 100%);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 10px 22px rgba(47, 32, 21, 0.15);
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ghost {
border: 1px solid var(--border);
text-decoration: none;
color: var(--brown-strong);
background: #fffdf8;
}
.ghost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.danger {
background: linear-gradient(135deg, #c05621, #9c4221);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 12px 28px rgba(156, 66, 33, 0.22);
}
.actions .primary,
.actions .ghost {
text-decoration: none;
}
@media (max-width: 900px) {
.drawer-content {
max-width: 100%;
}
}
@media (max-width: 640px) {
.custom-tools-page {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
.actions {
width: 100%;
}
.tool-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.drawer {
width: 100%;
}
.drawer-content {
padding: 12px;
}
.editor textarea {
min-height: 220px;
}
}
</style>

View File

@ -0,0 +1,158 @@
<template>
<div class="guide-page">
<header class="guide-header">
<div class="title-block">
<p class="eyebrow">Custom Tools</p>
<h1>开发指南</h1>
<p class="desc">如何编写组织与调试自定义工具的完整说明</p>
</div>
<div class="actions">
<a class="ghost" href="/admin/custom-tools" target="_blank" rel="noopener">返回列表</a>
</div>
</header>
<section class="panel">
<div v-if="error" class="status error">{{ error }}</div>
<div v-else-if="loading" class="status">加载中...</div>
<article v-else class="markdown" v-html="html"></article>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { renderMarkdown } from '@/composables/useMarkdownRenderer';
import guideMd from '../../../doc/custom_tools_guide.md?raw';
const html = ref('');
const loading = ref(true);
const error = ref('');
onMounted(() => {
try {
html.value = renderMarkdown(guideMd);
} catch (e: any) {
error.value = e.message || '渲染指南失败';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
:global(html, body) {
min-height: 100%;
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent),
#f8f3e8;
}
:global(#custom-tools-guide) {
min-height: 100vh;
}
.guide-page {
--paper: #ffffff;
--border: #e3d8c8;
--ink: #1a120b;
--muted: #6f635a;
--accent: var(--claude-accent, #da7756);
--accent-strong: var(--claude-accent-strong, #bd5d3a);
--shadow: 0 16px 40px rgba(47, 32, 21, 0.12);
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px 32px;
font-family: 'Space Grotesk', 'Noto Sans SC', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--ink);
}
.guide-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 18px;
}
.title-block h1 {
margin: 4px 0;
color: #2f2015;
}
.title-block .eyebrow {
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
font-size: 12px;
margin: 0;
}
.title-block .desc {
margin: 6px 0 0;
color: var(--muted);
}
.actions .ghost {
padding: 10px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: #fffdf8;
color: #4b3626;
text-decoration: none;
font-weight: 600;
}
.panel {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 18px;
}
.status {
color: var(--muted);
}
.status.error {
color: #d64545;
}
.markdown :global(h1),
.markdown :global(h2),
.markdown :global(h3),
.markdown :global(h4) {
color: #2f2015;
margin: 20px 0 10px;
}
.markdown :global(p),
.markdown :global(li),
.markdown :global(code) {
color: var(--ink);
}
.markdown :global(code) {
background: #fdf8ef;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
font-size: 13px;
}
.markdown :global(pre) {
background: #0f172a;
color: #f8fafc;
border-radius: 10px;
padding: 14px;
overflow: auto;
}
.markdown :global(a) {
color: var(--accent-strong);
}
.markdown :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 12px;
color: var(--muted);
}
.markdown :global(table) {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
.markdown :global(th),
.markdown :global(td) {
border: 1px solid var(--border);
padding: 8px 10px;
}
.markdown :global(ul) {
padding-left: 20px;
}
</style>

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue';
import CustomToolsGuideApp from './CustomToolsGuideApp.vue';
createApp(CustomToolsGuideApp).mount('#custom-tools-guide');

4
static/src/env.d.ts vendored
View File

@ -6,3 +6,7 @@ declare module '*.vue' {
declare module 'prismjs'; declare module 'prismjs';
declare module 'katex/contrib/auto-render'; declare module 'katex/contrib/auto-render';
declare module '*.md' {
const content: string;
export default content;
}

View File

@ -34,9 +34,9 @@ def setup_logger(name: str, log_file: str = None) -> logging.Logger:
# 创建格式化器 # 创建格式化器
formatter = logging.Formatter(LOG_FORMAT) formatter = logging.Formatter(LOG_FORMAT)
# 控制台处理器(只显示WARNING及以上 # 控制台处理器(与全局 LOG_LEVEL 保持一致,便于实时查看
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING) console_handler.setLevel(getattr(logging, LOG_LEVEL))
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
logger.addHandler(console_handler) logger.addHandler(console_handler)

View File

@ -5,6 +5,8 @@ import vue from '@vitejs/plugin-vue';
const entry = fileURLToPath(new URL('./static/src/main.ts', import.meta.url)); 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 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 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({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
@ -15,7 +17,9 @@ export default defineConfig({
input: { input: {
main: entry, main: entry,
admin: adminEntry, admin: adminEntry,
adminPolicy: adminPolicyEntry adminPolicy: adminPolicyEntry,
adminCustomTools: adminCustomToolsEntry,
adminCustomToolsGuide: adminCustomToolsGuideEntry
}, },
output: { output: {
entryFileNames: 'assets/[name].js', entryFileNames: 'assets/[name].js',

View File

@ -22,6 +22,7 @@ from datetime import datetime
from collections import defaultdict, deque, Counter from collections import defaultdict, deque, Counter
from config.model_profiles import get_model_profile from config.model_profiles import get_model_profile
from modules import admin_policy_manager, balance_client from modules import admin_policy_manager, balance_client
from modules.custom_tool_registry import CustomToolRegistry
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
import secrets import secrets
@ -156,7 +157,7 @@ def build_review_lines(messages, limit=None):
# 控制台输出策略:默认静默,只保留简要事件 # 控制台输出策略:默认静默,只保留简要事件
_ORIGINAL_PRINT = print _ORIGINAL_PRINT = print
ENABLE_VERBOSE_CONSOLE = False ENABLE_VERBOSE_CONSOLE = True
def brief_log(message: str): def brief_log(message: str):
@ -269,6 +270,7 @@ class ConversationIdConverter(BaseConverter):
app.url_map.converters['conv'] = ConversationIdConverter app.url_map.converters['conv'] = ConversationIdConverter
user_manager = UserManager() user_manager = UserManager()
custom_tool_registry = CustomToolRegistry()
container_manager = UserContainerManager() container_manager = UserContainerManager()
user_terminals: Dict[str, WebTerminal] = {} user_terminals: Dict[str, WebTerminal] = {}
terminal_rooms: Dict[str, set] = {} terminal_rooms: Dict[str, set] = {}
@ -288,6 +290,8 @@ MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
MONITOR_SNAPSHOT_CACHE_LIMIT = 120 MONITOR_SNAPSHOT_CACHE_LIMIT = 120
ADMIN_ASSET_DIR = (Path(app.static_folder) / 'admin_dashboard').resolve() 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_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60 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()) username = (username or get_current_username())
if not username: if not username:
return None, None return None, None
record = get_current_user_record()
workspace = user_manager.ensure_user_workspace(username) workspace = user_manager.ensure_user_workspace(username)
container_handle = container_manager.ensure_container(username, str(workspace.project_path)) container_handle = container_manager.ensure_container(username, str(workspace.project_path))
usage_tracker = get_or_create_usage_tracker(username, workspace) 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 terminal.terminal_manager.broadcast = terminal.message_callback
user_terminals[username] = terminal user_terminals[username] = terminal
terminal.username = username terminal.username = username
terminal.user_role = get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
session['run_mode'] = terminal.run_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_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) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username) attach_user_broadcast(terminal, username)
terminal.username = username terminal.username = username
terminal.user_role = get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) 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') 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']) @app.route('/api/admin/balance', methods=['GET'])
@login_required @login_required
@ -1670,6 +1684,75 @@ def admin_policy_api():
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500 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']) @app.route('/api/effective-policy', methods=['GET'])
@api_login_required @api_login_required
def effective_policy_api(): def effective_policy_api():