feat: add custom tools guide and id validation
This commit is contained in:
parent
4cfd0ef090
commit
5c7cdd72c9
@ -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
|
||||
@ -1824,8 +1902,19 @@ class MainTerminal:
|
||||
"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")
|
||||
|
||||
502
doc/custom_tools_guide.md
Normal file
502
doc/custom_tools_guide.md
Normal 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 执行层示例 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/<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 接入
|
||||
|
||||
201
modules/custom_tool_registry.py
Normal file
201
modules/custom_tool_registry.py
Normal 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,
|
||||
}
|
||||
15
static/custom_tools/guide.html
Normal file
15
static/custom_tools/guide.html
Normal 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>
|
||||
15
static/custom_tools/index.html
Normal file
15
static/custom_tools/index.html
Normal 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>
|
||||
675
static/src/admin/CustomToolsApp.vue
Normal file
675
static/src/admin/CustomToolsApp.vue
Normal 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>
|
||||
158
static/src/admin/CustomToolsGuideApp.vue
Normal file
158
static/src/admin/CustomToolsGuideApp.vue
Normal 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>
|
||||
4
static/src/admin/customToolsGuideMain.ts
Normal file
4
static/src/admin/customToolsGuideMain.ts
Normal 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
4
static/src/env.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user