-
-
Notifications
You must be signed in to change notification settings - Fork 914
feat:在插件配置文件加入type="file",允许用户在配置界面上传文件 #2734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
你好 - 我已经审查了你的更改,它们看起来很棒!
AI 代理的提示
请处理此代码审查中的评论:
## 单独评论
### 评论 1
<location> `dashboard/src/components/config/fields/FileField.vue:98` </location>
<code_context>
+ refresh();
+});
+
+watch(() => props.pluginName + props.fieldKey, () => refresh());
+
+function humanSize(n: number) {
</code_context>
<issue_to_address>
将 pluginName 和 fieldKey 拼接用于 watch 可能会导致不必要的刷新。
观察拼接后的字符串可能会在只有一个值更改时触发不必要的刷新。将 [pluginName, fieldKey] 作为数组观察将确保仅当其中任何一个值更改时才触发刷新。
</issue_to_address>
### 评论 2
<location> `astrbot/dashboard/routes/plugin_config_files.py:98` </location>
<code_context>
+ return safe
+
+
+class PluginConfigFileFieldRoute(Route):
+ """Per-plugin file field manager (list/upload/delete)."""
+
</code_context>
<issue_to_address>
考虑将错误处理、模式验证和文件名清理重构为可重用的助手和库,以减少样板代码并提高可维护性。
- 将 HTTP 层样板代码(try/except + `Response()` 包装)提取到装饰器中,以使每个处理程序保持 DRY:
```python
# utils/http.py
import traceback
from functools import wraps
from .route import Response
from astrbot.core import logger
def with_error_response(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return wrapper
```
```python
# In PluginConfigFileFieldRoute
from utils.http import with_error_response
@with_error_response
async def list_files(self, plugin_name: str):
# body now has no try/except or Response() wrapping
```
- 将重复的“加载插件元数据 + 验证模式字段”合并到一个助手函数中:
```python
# utils/plugin_schema.py
from astrbot.core.star.star import star_registry
from pathlib import Path
def load_field_schema(plugin_name: str, field: str) -> Tuple[Any, Path, Dict]:
md = next((m for m in star_registry if m.name == plugin_name), None)
if not md or not md.config:
raise ValueError(f"插件 {plugin_name} 未注册配置")
schema = md.config.schema or {}
field_schema = schema.get(field)
if not field_schema or field_schema.get("type") != "file":
raise ValueError(f"字段 {field} 未定义为 file 类型")
root = Path(StarTools.get_data_dir(plugin_name))
dest = (root / field_schema["dest_dir"]).resolve()
dest.mkdir(parents=True, exist_ok=True)
if not dest.is_relative_to(root):
raise ValueError("非法目录")
return md, root, dest, field_schema
```
```python
# then in each handler
md, root, target_root, field_schema = load_field_schema(plugin_name, field)
```
- 使用经过实战检验的库(例如 python-slugify)替换自定义清理逻辑:
```python
# utils/filename.py
from slugify import slugify
import uuid, datetime, os
def gen_safe_filename(tmpl: str, original: str) -> str:
name, ext = os.path.splitext(original.lower())
ctx = {
"timestamp": datetime.utcnow().strftime("%Y%m%d-%H%M%S"),
"uuid": uuid.uuid4().hex,
"ext": ext,
"name": slugify(name),
"original": slugify(name) + ext
}
result = tmpl.format(**ctx)
safe = slugify(result, separator="_") + (ext if not result.endswith(ext) else "")
return safe
```
- 使用 `Path.is_relative_to()` 而不是手动字符串检查:
```python
# In ensure_inside_root or inline
if not dest_path.is_relative_to(root):
raise ValueError("非法路径")
```
这些重构为每个处理程序减少了约 100 行的管道代码,集中了错误处理/验证,并利用现有库进行文件名清理和路径安全检查。
</issue_to_address>
帮助我更有用!请在每条评论上点击 👍 或 👎,我将使用这些反馈来改进您的评论。
Original comment in English
Hey there - I've reviewed your changes and they look great!
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location> `dashboard/src/components/config/fields/FileField.vue:98` </location>
<code_context>
+ refresh();
+});
+
+watch(() => props.pluginName + props.fieldKey, () => refresh());
+
+function humanSize(n: number) {
</code_context>
<issue_to_address>
Concatenating pluginName and fieldKey for watch may cause unnecessary refreshes.
Watching the concatenated string may trigger refreshes unnecessarily when only one value changes. Watching [pluginName, fieldKey] as an array will ensure refreshes occur only when either value changes.
</issue_to_address>
### Comment 2
<location> `astrbot/dashboard/routes/plugin_config_files.py:98` </location>
<code_context>
+ return safe
+
+
+class PluginConfigFileFieldRoute(Route):
+ """Per-plugin file field manager (list/upload/delete)."""
+
</code_context>
<issue_to_address>
Consider refactoring error handling, schema validation, and filename sanitization into reusable helpers and libraries to reduce boilerplate and improve maintainability.
- Extract HTTP‐layer boilerplate (try/except + `Response()` wrapping) into a decorator to DRY up each handler:
```python
# utils/http.py
import traceback
from functools import wraps
from .route import Response
from astrbot.core import logger
def with_error_response(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return wrapper
```
```python
# In PluginConfigFileFieldRoute
from utils.http import with_error_response
@with_error_response
async def list_files(self, plugin_name: str):
# body now has no try/except or Response() wrapping
```
- Consolidate repeated “load plugin‐md + validate schema field” into one helper:
```python
# utils/plugin_schema.py
from astrbot.core.star.star import star_registry
from pathlib import Path
def load_field_schema(plugin_name: str, field: str) -> Tuple[Any, Path, Dict]:
md = next((m for m in star_registry if m.name == plugin_name), None)
if not md or not md.config:
raise ValueError(f"插件 {plugin_name} 未注册配置")
schema = md.config.schema or {}
field_schema = schema.get(field)
if not field_schema or field_schema.get("type") != "file":
raise ValueError(f"字段 {field} 未定义为 file 类型")
root = Path(StarTools.get_data_dir(plugin_name))
dest = (root / field_schema["dest_dir"]).resolve()
dest.mkdir(parents=True, exist_ok=True)
if not dest.is_relative_to(root):
raise ValueError("非法目录")
return md, root, dest, field_schema
```
```python
# then in each handler
md, root, target_root, field_schema = load_field_schema(plugin_name, field)
```
- Replace custom sanitize logic with a battle‐tested library (e.g. python‐slugify):
```python
# utils/filename.py
from slugify import slugify
import uuid, datetime, os
def gen_safe_filename(tmpl: str, original: str) -> str:
name, ext = os.path.splitext(original.lower())
ctx = {
"timestamp": datetime.utcnow().strftime("%Y%m%d-%H%M%S"),
"uuid": uuid.uuid4().hex,
"ext": ext,
"name": slugify(name),
"original": slugify(name) + ext
}
result = tmpl.format(**ctx)
safe = slugify(result, separator="_") + (ext if not result.endswith(ext) else "")
return safe
```
- Use `Path.is_relative_to()` instead of manual string checks:
```python
# In ensure_inside_root or inline
if not dest_path.is_relative_to(root):
raise ValueError("非法路径")
```
These refactorings remove ~100 lines of plumbing per handler, centralize error‐handling/validation, and leverage existing libraries for filename‐sanitization and path safety.
</issue_to_address>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
refresh(); | ||
}); | ||
|
||
watch(() => props.pluginName + props.fieldKey, () => refresh()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
吹毛求疵: 将 pluginName 和 fieldKey 拼接用于 watch 可能会导致不必要的刷新。
观察拼接后的字符串可能会在只有一个值更改时触发不必要的刷新。将 [pluginName, fieldKey] 作为数组观察将确保仅当其中任何一个值更改时才触发刷新。
Original comment in English
nitpick: Concatenating pluginName and fieldKey for watch may cause unnecessary refreshes.
Watching the concatenated string may trigger refreshes unnecessarily when only one value changes. Watching [pluginName, fieldKey] as an array will ensure refreshes occur only when either value changes.
return safe | ||
|
||
|
||
class PluginConfigFileFieldRoute(Route): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
问题 (复杂性): 考虑将错误处理、模式验证和文件名清理重构为可重用的助手和库,以减少样板代码并提高可维护性。
- 将 HTTP 层样板代码(try/except +
Response()
包装)提取到装饰器中,以使每个处理程序保持 DRY:
# utils/http.py
import traceback
from functools import wraps
from .route import Response
from astrbot.core import logger
def with_error_response(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return wrapper
# In PluginConfigFileFieldRoute
from utils.http import with_error_response
@with_error_response
async def list_files(self, plugin_name: str):
# body now has no try/except or Response() wrapping
- 将重复的“加载插件元数据 + 验证模式字段”合并到一个助手函数中:
# utils/plugin_schema.py
from astrbot.core.star.star import star_registry
from pathlib import Path
def load_field_schema(plugin_name: str, field: str) -> Tuple[Any, Path, Dict]:
md = next((m for m in star_registry if m.name == plugin_name), None)
if not md or not md.config:
raise ValueError(f"插件 {plugin_name} 未注册配置")
schema = md.config.schema or {}
field_schema = schema.get(field)
if not field_schema or field_schema.get("type") != "file":
raise ValueError(f"字段 {field} 未定义为 file 类型")
root = Path(StarTools.get_data_dir(plugin_name))
dest = (root / field_schema["dest_dir"]).resolve()
dest.mkdir(parents=True, exist_ok=True)
if not dest.is_relative_to(root):
raise ValueError("非法目录")
return md, root, dest, field_schema
# then in each handler
md, root, target_root, field_schema = load_field_schema(plugin_name, field)
- 使用经过实战检验的库(例如 python-slugify)替换自定义清理逻辑:
# utils/filename.py
from slugify import slugify
import uuid, datetime, os
def gen_safe_filename(tmpl: str, original: str) -> str:
name, ext = os.path.splitext(original.lower())
ctx = {
"timestamp": datetime.utcnow().strftime("%Y%m%d-%H%M%S"),
"uuid": uuid.uuid4().hex,
"ext": ext,
"name": slugify(name),
"original": slugify(name) + ext
}
result = tmpl.format(**ctx)
safe = slugify(result, separator="_") + (ext if not result.endswith(ext) else "")
return safe
- 使用
Path.is_relative_to()
而不是手动字符串检查:
# In ensure_inside_root or inline
if not dest_path.is_relative_to(root):
raise ValueError("非法路径")
这些重构为每个处理程序减少了约 100 行的管道代码,集中了错误处理/验证,并利用现有库进行文件名清理和路径安全检查。
Original comment in English
issue (complexity): Consider refactoring error handling, schema validation, and filename sanitization into reusable helpers and libraries to reduce boilerplate and improve maintainability.
- Extract HTTP‐layer boilerplate (try/except +
Response()
wrapping) into a decorator to DRY up each handler:
# utils/http.py
import traceback
from functools import wraps
from .route import Response
from astrbot.core import logger
def with_error_response(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return wrapper
# In PluginConfigFileFieldRoute
from utils.http import with_error_response
@with_error_response
async def list_files(self, plugin_name: str):
# body now has no try/except or Response() wrapping
- Consolidate repeated “load plugin‐md + validate schema field” into one helper:
# utils/plugin_schema.py
from astrbot.core.star.star import star_registry
from pathlib import Path
def load_field_schema(plugin_name: str, field: str) -> Tuple[Any, Path, Dict]:
md = next((m for m in star_registry if m.name == plugin_name), None)
if not md or not md.config:
raise ValueError(f"插件 {plugin_name} 未注册配置")
schema = md.config.schema or {}
field_schema = schema.get(field)
if not field_schema or field_schema.get("type") != "file":
raise ValueError(f"字段 {field} 未定义为 file 类型")
root = Path(StarTools.get_data_dir(plugin_name))
dest = (root / field_schema["dest_dir"]).resolve()
dest.mkdir(parents=True, exist_ok=True)
if not dest.is_relative_to(root):
raise ValueError("非法目录")
return md, root, dest, field_schema
# then in each handler
md, root, target_root, field_schema = load_field_schema(plugin_name, field)
- Replace custom sanitize logic with a battle‐tested library (e.g. python‐slugify):
# utils/filename.py
from slugify import slugify
import uuid, datetime, os
def gen_safe_filename(tmpl: str, original: str) -> str:
name, ext = os.path.splitext(original.lower())
ctx = {
"timestamp": datetime.utcnow().strftime("%Y%m%d-%H%M%S"),
"uuid": uuid.uuid4().hex,
"ext": ext,
"name": slugify(name),
"original": slugify(name) + ext
}
result = tmpl.format(**ctx)
safe = slugify(result, separator="_") + (ext if not result.endswith(ext) else "")
return safe
- Use
Path.is_relative_to()
instead of manual string checks:
# In ensure_inside_root or inline
if not dest_path.is_relative_to(root):
raise ValueError("非法路径")
These refactorings remove ~100 lines of plumbing per handler, centralize error‐handling/validation, and leverage existing libraries for filename‐sanitization and path safety.
if isinstance(max_mb, (int, float)) and max_mb > 0: | ||
if len(content) > max_mb * 1024 * 1024: | ||
return Response().error("文件过大").__dict__ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
建议 (代码质量): 合并嵌套的 if 条件 (merge-nested-ifs
)
if isinstance(max_mb, (int, float)) and max_mb > 0: | |
if len(content) > max_mb * 1024 * 1024: | |
return Response().error("文件过大").__dict__ | |
if isinstance(max_mb, (int, float)) and max_mb > 0 and len(content) > max_mb * 1024 * 1024: | |
return Response().error("文件过大").__dict__ | |
解释
过多的嵌套会使代码难以理解,在 Python 中尤其如此,因为没有括号来帮助区分不同的嵌套级别。阅读深度嵌套的代码令人困惑,因为您必须跟踪哪些条件与哪些级别相关。因此,我们力求在可能的情况下减少嵌套,而使用 and
组合两个 if
条件的情况是一个简单的胜利。
Original comment in English
suggestion (code-quality): Merge nested if conditions (merge-nested-ifs
)
if isinstance(max_mb, (int, float)) and max_mb > 0: | |
if len(content) > max_mb * 1024 * 1024: | |
return Response().error("文件过大").__dict__ | |
if isinstance(max_mb, (int, float)) and max_mb > 0 and len(content) > max_mb * 1024 * 1024: | |
return Response().error("文件过大").__dict__ | |
Explanation
Too much nesting can make code difficult to understand, and this is especiallytrue in Python, where there are no brackets to help out with the delineation of
different nesting levels.
Reading deeply nested code is confusing, since you have to keep track of which
conditions relate to which levels. We therefore strive to reduce nesting where
possible, and the situation where two if
conditions can be combined using
and
is an easy win.
for md in star_registry: | ||
if md.name == plugin_name: | ||
return md | ||
return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
建议 (代码质量): 使用内置函数 next
而不是 for 循环 (use-next
)
for md in star_registry: | |
if md.name == plugin_name: | |
return md | |
return None | |
return next((md for md in star_registry if md.name == plugin_name), None) |
Original comment in English
suggestion (code-quality): Use the built-in function next
instead of a for-loop (use-next
)
for md in star_registry: | |
if md.name == plugin_name: | |
return md | |
return None | |
return next((md for md in star_registry if md.name == plugin_name), None) |
original_clean = f"{name_clean}{ext}" | ||
|
||
# Support direct original name keepers | ||
if tmpl.strip().lower() in ("original", "{original}", "{filename}"): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
建议 (代码质量): 在检查字面量集合的成员资格时使用集合 (collection-into-set
)
if tmpl.strip().lower() in ("original", "{original}", "{filename}"): | |
if tmpl.strip().lower() in {"original", "{original}", "{filename}"}: |
Original comment in English
suggestion (code-quality): Use set when checking membership of a collection of literals (collection-into-set
)
if tmpl.strip().lower() in ("original", "{original}", "{filename}"): | |
if tmpl.strip().lower() in {"original", "{original}", "{filename}"}: |
logger.error(traceback.format_exc()) | ||
return Response().error(str(e)).__dict__ | ||
|
||
async def upload_file(self, plugin_name: str): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
问题 (代码质量): 在 PluginConfigFileFieldRoute.upload_file 中发现代码质量低 - 25% (low-code-quality
)
解释
此函数的质量得分低于 25% 的质量阈值。此得分是方法长度、认知复杂度和工作内存的组合。
您如何解决这个问题?
可能值得重构此函数,使其更短、更易读。
- 通过将功能片段提取到自己的函数中来减少函数长度。这是您可以做的最重要的事情——理想情况下,一个函数应该少于 10 行。
- 减少嵌套,也许可以通过引入卫语句来提前返回。
- 确保变量的作用域紧密,以便使用相关概念的代码在函数内部集中在一起,而不是分散开来。
Original comment in English
issue (code-quality): Low code quality found in PluginConfigFileFieldRoute.upload_file - 25% (low-code-quality
)
Explanation
The quality score for this function is below the quality threshold of 25%.This score is a combination of the method length, cognitive complexity and working memory.
How can you solve this?
It might be worth refactoring this function to make it shorter and more readable.
- Reduce the function length by extracting pieces of functionality out into
their own functions. This is the most important thing you can do - ideally a
function should be less than 10 lines. - Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
sits together within the function rather than being scattered.
else: | ||
if new_conf.get(field) == rel_path: | ||
new_conf[field] = "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
建议 (代码质量): 将 else 子句的嵌套 if 语句合并到 elif 中 (merge-else-if-into-elif
)
else: | |
if new_conf.get(field) == rel_path: | |
new_conf[field] = "" | |
elif new_conf.get(field) == rel_path: | |
new_conf[field] = "" |
Original comment in English
suggestion (code-quality): Merge else clause's nested if statement into elif (merge-else-if-into-elif
)
else: | |
if new_conf.get(field) == rel_path: | |
new_conf[field] = "" | |
elif new_conf.get(field) == rel_path: | |
new_conf[field] = "" |
Motivation
让插件作者在配置面板中直接上传并引用文件,统一落盘到每个插件的专属数据目录,遵循 AstrBot 数据/代码分离的最佳实践。
降低插件实现“词表/模版/资源文件”类需求的门槛,开箱即用。
Modifications
后端
新增配置类型默认值映射:astrbot/core/config/default.py 增加 file: ""。
新增文件字段管理路由:astrbot/dashboard/routes/plugin_config_files.py
GET /api/plugin/{plugin}/config/filefield/list?field=...
POST /api/plugin/{plugin}/config/filefield/upload(multipart:field, file)
DELETE /api/plugin/{plugin}/config/filefield/delete?field=...&path=...
路由注册:astrbot/dashboard/routes/init.py、astrbot/dashboard/server.py
文件名模板扩展:支持 {timestamp} {uuid} {ext} {name} {original},以及 original/{original}/{filename} 直通保留原文件名(均做安全清洗)。
前端
前端
新增文件字段 UI 与 API
dashboard/src/components/config/fields/FileField.vue: 当前值、上传(自动应用)、文件列表(设为当前/删除/刷新)
dashboard/src/api/configFileField.ts: list/upload/delete 封装
统一上传按钮为可复用组件
dashboard/src/components/shared/FilePickButton.vue: 封装“隐藏 v-file-input + 触发按钮”,与插件安装页一致
FileField 改为复用该组件
渲染接入
dashboard/src/components/shared/AstrBotConfig.vue: 渲染 type: 'file'
dashboard/src/components/shared/AstrBotConfigV4.vue: 渲染 type: 'file'
上传按钮(单击弹出选择文件,选中即自动上传)
当前值展示(复制/清空)
文件列表(复制路径/设为当前/删除/刷新)
支持 multiple 基础交互(数组形式)
渲染接入:
插件配置:dashboard/src/components/shared/AstrBotConfig.vue
JSON 选择器/系统配置渲染器:dashboard/src/components/shared/AstrBotConfigV4.vue
其他
在插件 _conf_schema.json 中为目标字段声明如下(最小示例):
packages/your_plugin/_conf_schema.json:1
{
"custom_vocabulary": {
"description": "上传词表(.txt)",
"type": "file",
"hint": "会保存到插件数据目录/vocab",
"accept": [".txt"], // 可选:允许的后缀列表(前端过滤 + 后端校验)
"max_size_mb": 4, // 可选:大小上限(MB)
"dest_dir": "vocab", // 必填:相对插件数据目录的子目录
"name_template": "original", // 可选:文件命名模板,见下文
"multiple": false // 可选:是否以数组形式保存多个值
}
}
字段含义:
type: 固定写 "file"(配置值为相对路径字符串或数组)
dest_dir: 必填,存放文件的子目录(相对 StarTools.get_data_dir(plugin))
accept: 可选,后缀白名单,例:[".txt",".csv"]
max_size_mb: 可选,大小上限(MB)
name_template: 可选,命名模板
支持占位符:{timestamp}、{uuid}、{ext}、{name}、{original}
特殊值:original / {original} / {filename} 表示保留原始文件名(会做安全清洗)
默认:{timestamp}-{uuid}{ext}
multiple: 可选,true 则配置保存为数组,上传时追加;false(默认)保存为字符串(覆盖)
运行时保存值:
后端在上传成功后会将“相对路径”(相对插件数据目录)写回配置,如:vocab/my_vocab.txt
Backend APIs
列表:GET /api/plugin/{plugin}/config/filefield/list?field=...
上传:POST /api/plugin/{plugin}/config/filefield/upload(multipart:field, file)
删除:DELETE /api/plugin/{plugin}/config/filefield/delete?field=...&path=...
安全性:所有路径均以 StarTools.get_data_dir(plugin) 与 schema.dest_dir 为根,阻断越权;校验大小/后缀;命名清洗。
Frontend
在渲染到 type: 'file' 字段时显示“上传文件”按钮 + 文件库列表,并在设为当前/删除时同步更新配置值。

Compatibility
###功能展示
Check
requirements.txt
和pyproject.toml
文件相应位置。Sourcery 总结
为插件引入一个新的“file”配置字段类型,允许用户在仪表板 UI 中上传、列出、设置和删除文件,具有安全的存储和命名规范,并添加对本地构建的仪表板的检测,以方便开发。
新功能:
改进:
Original summary in English
Summary by Sourcery
Introduce a new "file" config field type for plugins, allowing users to upload, list, set, and delete files in the dashboard UI with secure storage and naming conventions, and add detection of a locally built dashboard for development convenience.
New Features:
Enhancements: