From 8f4e99625ac9a21c3becf65a418c3aba7588a243 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 17 Jan 2026 18:38:27 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 1 + astrbot/dashboard/routes/config.py | 323 ++++++++++++++++- .../src/components/shared/AstrBotConfig.vue | 24 +- .../components/shared/ConfigItemRenderer.vue | 19 + .../src/components/shared/FileConfigItem.vue | 336 ++++++++++++++++++ .../i18n/locales/en-US/features/config.json | 14 +- .../i18n/locales/zh-CN/features/config.json | 15 +- dashboard/src/views/ExtensionPage.vue | 1 + dashboard/tsconfig.base.json | 15 + dashboard/tsconfig.dom.json | 7 + dashboard/tsconfig.json | 5 +- dashboard/tsconfig.vite-config.json | 2 +- 12 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 dashboard/src/components/shared/FileConfigItem.vue create mode 100644 dashboard/tsconfig.base.json create mode 100644 dashboard/tsconfig.dom.json diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 510b162a7..730f1e249 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -3217,6 +3217,7 @@ class ChatProviderTemplate(TypedDict): "string": "", "text": "", "list": [], + "file": [], "object": {}, "template_list": [], } diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index bd2f9a264..b842cb662 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -21,7 +21,12 @@ from astrbot.core.provider import Provider from astrbot.core.provider.register import provider_registry from astrbot.core.star.star import star_registry +from astrbot.core.utils.astrbot_path import ( + get_astrbot_plugin_data_path, + get_astrbot_temp_path, +) from astrbot.core.utils.llm_metadata import LLM_METADATAS +from astrbot.core.utils.io import remove_dir from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from .route import Response, Route, RouteContext @@ -106,6 +111,16 @@ def validate(data: dict, metadata: dict = schema, path=""): _validate_template_list(value, meta, f"{path}{key}", errors, validate) continue + if meta["type"] == "file": + if not _expect_type(value, list, f"{path}{key}", errors, "list"): + continue + for idx, item in enumerate(value): + if not isinstance(item, str): + errors.append( + f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}", + ) + continue + if meta["type"] == "list" and not isinstance(value, list): errors.append( f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}", @@ -218,6 +233,8 @@ def __init__( "/config/default": ("GET", self.get_default_config), "/config/astrbot/update": ("POST", self.post_astrbot_configs), "/config/plugin/update": ("POST", self.post_plugin_configs), + "/config/plugin/file/upload": ("POST", self.upload_plugin_file), + "/config/plugin/file/delete": ("POST", self.delete_plugin_file), "/config/platform/new": ("POST", self.post_new_platform), "/config/platform/update": ("POST", self.post_update_platform), "/config/platform/delete": ("POST", self.post_delete_platform), @@ -876,6 +893,303 @@ async def post_plugin_configs(self): except Exception as e: return Response().error(str(e)).__dict__ + + def _get_plugin_metadata_by_name(self, plugin_name: str): + for plugin_md in star_registry: + if plugin_md.name == plugin_name: + return plugin_md + return None + + @staticmethod + def _get_schema_item(schema: dict | None, key_path: str) -> dict | None: + if not isinstance(schema, dict) or not key_path: + return None + if key_path in schema: + return schema.get(key_path) + + current = schema + parts = key_path.split(".") + for idx, part in enumerate(parts): + if part not in current: + return None + meta = current.get(part) + if idx == len(parts) - 1: + return meta + if not isinstance(meta, dict) or meta.get("type") != "object": + return None + current = meta.get("items", {}) + return None + + @staticmethod + def _sanitize_filename(name: str) -> str: + cleaned = os.path.basename(name).strip() + if not cleaned or cleaned in {".", ".."}: + return "" + for sep in (os.sep, os.altsep): + if sep: + cleaned = cleaned.replace(sep, "_") + return cleaned + + @staticmethod + def _sanitize_path_segment(segment: str) -> str: + cleaned = [] + for ch in segment: + if ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch.isdigit() or ch in { + "-", + "_", + }: + cleaned.append(ch) + else: + cleaned.append("_") + result = "".join(cleaned).strip("_") + return result or "_" + + @classmethod + def _config_key_to_folder(cls, key_path: str) -> str: + parts = [cls._sanitize_path_segment(p) for p in key_path.split(".") if p] + return "/".join(parts) if parts else "_" + + @staticmethod + def _normalize_rel_path(rel_path: str | None) -> str | None: + if not isinstance(rel_path, str): + return None + rel = rel_path.replace("\\", "/").lstrip("/") + if not rel: + return None + parts = [p for p in rel.split("/") if p] + if any(part in {".", ".."} for part in parts): + return None + if rel.startswith("../") or "/../" in rel: + return None + return "/".join(parts) + + @staticmethod + def _get_value_by_path(data: dict, key_path: str): + if key_path in data: + return data.get(key_path) + current = data + for part in key_path.split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current.get(part) + return current + + @staticmethod + def _set_value_by_path(data: dict, key_path: str, value) -> None: + if key_path in data: + data[key_path] = value + return + current = data + parts = key_path.split(".") + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + @classmethod + def _collect_file_keys(cls, schema: dict, prefix: str = "") -> list[str]: + keys = [] + for key, meta in schema.items(): + if not isinstance(meta, dict): + continue + meta_type = meta.get("type") + if meta_type == "file": + keys.append(f"{prefix}{key}" if prefix else key) + elif meta_type == "object": + child_prefix = f"{prefix}{key}." if prefix else f"{key}." + keys.extend(cls._collect_file_keys(meta.get("items", {}), child_prefix)) + return keys + + def _normalize_file_list(self, value, key_path: str) -> tuple[list[str], bool]: + if value is None: + return [], False + if not isinstance(value, list): + raise ValueError(f"Invalid file list for {key_path}") + folder = self._config_key_to_folder(key_path) + expected_prefix = f"files/{folder}/" + results = [] + changed = False + for item in value: + if not isinstance(item, str): + raise ValueError(f"Invalid file entry for {key_path}") + rel = self._normalize_rel_path(item) + if not rel or not rel.startswith("files/"): + raise ValueError(f"Invalid file path: {item}") + if rel.startswith(expected_prefix): + results.append(rel) + continue + if rel.count("/") == 1: + filename = rel.split("/", 1)[1] + if not filename: + raise ValueError(f"Invalid file path: {item}") + results.append(f"{expected_prefix}{filename}") + changed = True + continue + raise ValueError(f"Invalid file path: {item}") + return results, changed + + def _apply_plugin_file_ops(self, plugin_name: str, md, post_configs: dict) -> None: + schema = getattr(md.config, "schema", None) if md and md.config else None + if not isinstance(schema, dict): + return + + file_keys = self._collect_file_keys(schema) + if not file_keys: + return + + old_config = dict(md.config) + new_file_set = set() + old_file_set = set() + + for key_path in file_keys: + new_list, new_changed = self._normalize_file_list( + self._get_value_by_path(post_configs, key_path), + key_path, + ) + if new_changed: + self._set_value_by_path(post_configs, key_path, new_list) + old_list, _ = self._normalize_file_list( + self._get_value_by_path(old_config, key_path), + key_path, + ) + new_file_set.update(new_list) + old_file_set.update(old_list) + + plugin_data_dir = os.path.abspath( + os.path.join(get_astrbot_plugin_data_path(), plugin_name), + ) + staging_root = os.path.abspath( + os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", plugin_name), + ) + + for rel_path in sorted(new_file_set): + final_path = os.path.abspath(os.path.join(plugin_data_dir, rel_path)) + if not final_path.startswith(plugin_data_dir + os.sep): + raise ValueError(f"Invalid file path: {rel_path}") + staged_path = os.path.abspath(os.path.join(staging_root, rel_path)) + if not staged_path.startswith(staging_root + os.sep): + raise ValueError(f"Invalid staged path: {rel_path}") + if os.path.exists(staged_path): + os.makedirs(os.path.dirname(final_path), exist_ok=True) + os.replace(staged_path, final_path) + continue + legacy_path = os.path.join( + plugin_data_dir, + "files", + os.path.basename(rel_path), + ) + if os.path.isfile(legacy_path): + os.makedirs(os.path.dirname(final_path), exist_ok=True) + os.replace(legacy_path, final_path) + continue + if not os.path.exists(final_path): + raise ValueError(f"Missing staged file: {rel_path}") + + retained = new_file_set + for rel_path in sorted(old_file_set - retained): + final_path = os.path.abspath(os.path.join(plugin_data_dir, rel_path)) + if not final_path.startswith(plugin_data_dir + os.sep): + continue + if os.path.isfile(final_path): + os.remove(final_path) + continue + legacy_path = os.path.join( + plugin_data_dir, + "files", + os.path.basename(rel_path), + ) + if os.path.isfile(legacy_path): + os.remove(legacy_path) + + if os.path.isdir(staging_root): + remove_dir(staging_root) + + async def upload_plugin_file(self): + plugin_name = request.args.get("plugin_name") + key_path = request.args.get("key") + if not plugin_name or not key_path: + return Response().error("Missing plugin_name or key parameter").__dict__ + + md = self._get_plugin_metadata_by_name(plugin_name) + if not md or not md.config: + return Response().error( + f"Plugin {plugin_name} not found or has no config", + ).__dict__ + + meta = self._get_schema_item(md.config.schema, key_path) + if not meta or meta.get("type") != "file": + return Response().error("Config item not found or not file type").__dict__ + + file_types = meta.get("file_types") + allowed_exts = [] + if isinstance(file_types, list): + allowed_exts = [str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()] + + files = await request.files + if not files: + return Response().error("No files uploaded").__dict__ + + staging_root = os.path.join( + get_astrbot_temp_path(), + "plugin_file_uploads", + plugin_name, + ) + os.makedirs(staging_root, exist_ok=True) + + uploaded = [] + folder = self._config_key_to_folder(key_path) + errors = [] + for file in files.values(): + filename = self._sanitize_filename(file.filename or "") + if not filename: + errors.append("Invalid filename") + continue + + ext = os.path.splitext(filename)[1].lstrip(".").lower() + if allowed_exts and ext not in allowed_exts: + errors.append(f"Unsupported file type: {filename}") + continue + + rel_path = f"files/{folder}/{filename}" + save_path = os.path.join(staging_root, rel_path) + os.makedirs(os.path.dirname(save_path), exist_ok=True) + await file.save(save_path) + uploaded.append(rel_path) + + if not uploaded: + return Response().error( + "Upload failed: " + ", ".join(errors) if errors else "Upload failed", + ).__dict__ + + return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ + + async def delete_plugin_file(self): + plugin_name = request.args.get("plugin_name") + if not plugin_name: + return Response().error("Missing plugin_name parameter").__dict__ + + data = await request.get_json() + rel_path = data.get("path") if isinstance(data, dict) else None + rel_path = self._normalize_rel_path(rel_path) + if not rel_path or not rel_path.startswith("files/"): + return Response().error("Invalid path parameter").__dict__ + + md = self._get_plugin_metadata_by_name(plugin_name) + if not md: + return Response().error(f"Plugin {plugin_name} not found").__dict__ + + staging_root = os.path.abspath( + os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", plugin_name), + ) + staged_path = os.path.abspath( + os.path.normpath(os.path.join(staging_root, rel_path)), + ) + if staged_path.startswith(staging_root + os.sep) and os.path.isfile(staged_path): + os.remove(staged_path) + + return Response().ok(None, "Deletion staged").__dict__ + async def post_new_platform(self): new_platform_config = await request.json @@ -1132,6 +1446,13 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): raise ValueError(f"插件 {plugin_name} 没有注册配置") try: - save_config(post_configs, md.config) + errors, post_configs = validate_config( + post_configs, getattr(md.config, "schema", {}), is_core=False + ) + if errors: + raise ValueError(f"格式校验未通过: {errors}") + + self._apply_plugin_file_ops(plugin_name, md, post_configs) + md.config.save_config(post_configs) except Exception as e: raise e diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index 1590f384c..22dc03807 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -20,6 +20,14 @@ const props = defineProps({ type: String, required: true }, + pluginName: { + type: String, + default: '' + }, + pathPrefix: { + type: String, + default: '' + }, isEditing: { type: Boolean, default: false @@ -103,6 +111,10 @@ function shouldShowItem(itemMeta, itemKey) { return true } +function getItemPath(key) { + return props.pathPrefix ? `${props.pathPrefix}.${key}` : key +} + function hasVisibleItemsAfter(items, currentIndex) { const itemEntries = Object.entries(items) @@ -150,7 +162,13 @@ function hasVisibleItemsAfter(items, currentIndex) {
- +
@@ -205,6 +223,8 @@ function hasVisibleItemsAfter(items, currentIndex) { diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 23b8fe0bc..5f2341ee7 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -178,6 +178,16 @@ hide-details > + + import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import ListConfigItem from './ListConfigItem.vue' +import FileConfigItem from './FileConfigItem.vue' import ObjectEditor from './ObjectEditor.vue' import ProviderSelector from './ProviderSelector.vue' import PersonaSelector from './PersonaSelector.vue' @@ -225,6 +236,14 @@ const props = defineProps({ type: Object, default: null }, + pluginName: { + type: String, + default: '' + }, + configKey: { + type: String, + default: '' + }, loading: { type: Boolean, default: false diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue new file mode 100644 index 000000000..2e0d7adcf --- /dev/null +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index c510b6eaa..b8ded720f 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -89,6 +89,18 @@ }, "codeEditor": { "title": "Edit Configuration File" + }, + "fileUpload": { + "button": "Manage Files", + "dialogTitle": "Uploaded Files", + "dropzone": "Upload new file", + "allowedTypes": "Allowed types: {types}", + "empty": "No files uploaded", + "uploadSuccess": "Uploaded {count} files", + "uploadFailed": "Upload failed", + "deleteSuccess": "Deleted file", + "deleteFailed": "Delete failed", + "fileCount": "Files: {count}", + "done": "Done" } } - diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 0be423ef1..2334f1b46 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -89,5 +89,18 @@ }, "codeEditor": { "title": "编辑配置文件" + }, + "fileUpload": { + "button": "Manage Files", + "dialogTitle": "Uploaded Files", + "dropzone": "上传新文件", + "allowedTypes": "Allowed types: {types}", + "empty": "No files uploaded", + "uploadSuccess": "Uploaded {count} files", + "uploadFailed": "Upload failed", + "deleteSuccess": "Deleted file", + "deleteFailed": "Delete failed", + "fileCount": "Files: {count}", + "done": "完成" } -} \ No newline at end of file +} diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index c84862f2d..3ec6fca37 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -2138,6 +2138,7 @@ watch(isListView, (newVal) => { :metadata="extension_config.metadata" :iterable="extension_config.config" :metadataKey="curr_namespace" + :pluginName="curr_namespace" />

{{ tm("dialogs.config.noConfig") }}

diff --git a/dashboard/tsconfig.base.json b/dashboard/tsconfig.base.json new file mode 100644 index 000000000..f91fb491c --- /dev/null +++ b/dashboard/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true + } +} diff --git a/dashboard/tsconfig.dom.json b/dashboard/tsconfig.dom.json new file mode 100644 index 000000000..42d8d6d2f --- /dev/null +++ b/dashboard/tsconfig.dom.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["vite/client"] + } +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index 7820a40b1..c3cc8ff90 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@vue/tsconfig/tsconfig.dom.json", + "extends": "./tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/.d.ts"], "compilerOptions": { "ignoreDeprecations": "5.0", @@ -7,7 +7,8 @@ "paths": { "@/*": ["./src/*"] }, - "allowJs": true + "allowJs": true, + "noEmit": true }, "references": [ diff --git a/dashboard/tsconfig.vite-config.json b/dashboard/tsconfig.vite-config.json index a3d4b2151..d325881b0 100644 --- a/dashboard/tsconfig.vite-config.json +++ b/dashboard/tsconfig.vite-config.json @@ -1,5 +1,5 @@ { - "extends": "@vue/tsconfig/tsconfig.json", + "extends": "./tsconfig.base.json", "include": ["vite.config.*"], "compilerOptions": { "composite": true, From 9ef4e63c1149b49a41561658f47285347a6ce10d Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 17 Jan 2026 18:44:18 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E6=94=AF=E6=8C=81i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i18n/locales/zh-CN/features/config.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 2334f1b46..cd8cc9823 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -91,16 +91,16 @@ "title": "编辑配置文件" }, "fileUpload": { - "button": "Manage Files", - "dialogTitle": "Uploaded Files", + "button": "管理文件", + "dialogTitle": "已上传文件", "dropzone": "上传新文件", - "allowedTypes": "Allowed types: {types}", - "empty": "No files uploaded", - "uploadSuccess": "Uploaded {count} files", - "uploadFailed": "Upload failed", - "deleteSuccess": "Deleted file", - "deleteFailed": "Delete failed", - "fileCount": "Files: {count}", + "allowedTypes": "允许类型:{types}", + "empty": "暂无已上传文件", + "uploadSuccess": "已上传 {count} 个文件", + "uploadFailed": "上传失败", + "deleteSuccess": "已删除文件", + "deleteFailed": "删除失败", + "fileCount": "文件:{count}", "done": "完成" } } From dabbe701e08abf08b92ecf444ce250eadfb167bf Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 17 Jan 2026 19:16:56 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/config.py | 11 +++++++++++ .../src/components/shared/FileConfigItem.vue | 17 ++++++++++++++++- .../src/i18n/locales/en-US/features/config.json | 1 + .../src/i18n/locales/zh-CN/features/config.json | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index b842cb662..bb1200520 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -31,6 +31,8 @@ from .route import Response, Route, RouteContext +MAX_FILE_BYTES = 500 * 1024 * 1024 + def try_cast(value: Any, type_: str): if type_ == "int": @@ -1146,6 +1148,11 @@ async def upload_plugin_file(self): errors.append("Invalid filename") continue + file_size = getattr(file, "content_length", None) + if isinstance(file_size, int) and file_size > MAX_FILE_BYTES: + errors.append(f"File too large: {filename}") + continue + ext = os.path.splitext(filename)[1].lstrip(".").lower() if allowed_exts and ext not in allowed_exts: errors.append(f"Unsupported file type: {filename}") @@ -1155,6 +1162,10 @@ async def upload_plugin_file(self): save_path = os.path.join(staging_root, rel_path) os.makedirs(os.path.dirname(save_path), exist_ok=True) await file.save(save_path) + if os.path.isfile(save_path) and os.path.getsize(save_path) > MAX_FILE_BYTES: + os.remove(save_path) + errors.append(f"File too large: {filename}") + continue uploaded.append(rel_path) if not uploaded: diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue index 2e0d7adcf..c1adfa886 100644 --- a/dashboard/src/components/shared/FileConfigItem.vue +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -107,6 +107,8 @@ const dialog = ref(false) const isDragging = ref(false) const fileInput = ref(null) const uploading = ref(false) +const MAX_FILE_BYTES = 500 * 1024 * 1024 +const MAX_FILE_MB = 500 const fileList = computed({ get: () => (Array.isArray(props.modelValue) ? props.modelValue : []), @@ -165,10 +167,23 @@ const uploadFiles = async (files) => { return } + const oversized = files.filter((file) => file.size > MAX_FILE_BYTES) + if (oversized.length > 0) { + oversized.forEach((file) => { + toast.warning( + tm('fileUpload.fileTooLarge', { name: file.name, max: MAX_FILE_MB }) + ) + }) + } + const validFiles = files.filter((file) => file.size <= MAX_FILE_BYTES) + if (validFiles.length === 0) { + return + } + uploading.value = true try { const formData = new FormData() - files.forEach((file, index) => { + validFiles.forEach((file, index) => { formData.append(`file${index}`, file) }) diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index b8ded720f..dd3913530 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -98,6 +98,7 @@ "empty": "No files uploaded", "uploadSuccess": "Uploaded {count} files", "uploadFailed": "Upload failed", + "fileTooLarge": "File too large (max {max} MB): {name}", "deleteSuccess": "Deleted file", "deleteFailed": "Delete failed", "fileCount": "Files: {count}", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index cd8cc9823..3e2c8ffff 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -98,6 +98,7 @@ "empty": "暂无已上传文件", "uploadSuccess": "已上传 {count} 个文件", "uploadFailed": "上传失败", + "fileTooLarge": "文件过大(上限 {max} MB):{name}", "deleteSuccess": "已删除文件", "deleteFailed": "删除失败", "fileCount": "文件:{count}", From ec74fc23110b3925bb1db40fdb448b65f7226e25 Mon Sep 17 00:00:00 2001 From: xunxi Date: Mon, 19 Jan 2026 19:11:12 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E5=88=A0=E9=99=A4ide=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/tsconfig.base.json | 15 --------------- dashboard/tsconfig.dom.json | 7 ------- dashboard/tsconfig.json | 7 +++---- dashboard/tsconfig.vite-config.json | 4 ++-- 4 files changed, 5 insertions(+), 28 deletions(-) delete mode 100644 dashboard/tsconfig.base.json delete mode 100644 dashboard/tsconfig.dom.json diff --git a/dashboard/tsconfig.base.json b/dashboard/tsconfig.base.json deleted file mode 100644 index f91fb491c..000000000 --- a/dashboard/tsconfig.base.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "jsx": "preserve", - "resolveJsonModule": true, - "isolatedModules": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "useDefineForClassFields": true - } -} diff --git a/dashboard/tsconfig.dom.json b/dashboard/tsconfig.dom.json deleted file mode 100644 index 42d8d6d2f..000000000 --- a/dashboard/tsconfig.dom.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["vite/client"] - } -} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index c3cc8ff90..e83bb639c 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.dom.json", + "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/.d.ts"], "compilerOptions": { "ignoreDeprecations": "5.0", @@ -7,8 +7,7 @@ "paths": { "@/*": ["./src/*"] }, - "allowJs": true, - "noEmit": true + "allowJs": true }, "references": [ @@ -16,4 +15,4 @@ "path": "./tsconfig.vite-config.json" } ] -} +} \ No newline at end of file diff --git a/dashboard/tsconfig.vite-config.json b/dashboard/tsconfig.vite-config.json index d325881b0..170b576bf 100644 --- a/dashboard/tsconfig.vite-config.json +++ b/dashboard/tsconfig.vite-config.json @@ -1,9 +1,9 @@ { - "extends": "./tsconfig.base.json", + "extends": "@vue/tsconfig/tsconfig.json", "include": ["vite.config.*"], "compilerOptions": { "composite": true, "allowJs": true, "types": ["node"] } -} +} \ No newline at end of file From e34e72abd70bf45de0e05f028e7661b2c617f9de Mon Sep 17 00:00:00 2001 From: xunxi Date: Mon, 19 Jan 2026 19:25:18 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E5=88=A0=E9=99=A4ide=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/tsconfig.json | 2 +- dashboard/tsconfig.vite-config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index e83bb639c..7820a40b1 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -15,4 +15,4 @@ "path": "./tsconfig.vite-config.json" } ] -} \ No newline at end of file +} diff --git a/dashboard/tsconfig.vite-config.json b/dashboard/tsconfig.vite-config.json index 170b576bf..a3d4b2151 100644 --- a/dashboard/tsconfig.vite-config.json +++ b/dashboard/tsconfig.vite-config.json @@ -6,4 +6,4 @@ "allowJs": true, "types": ["node"] } -} \ No newline at end of file +} From f8cdfeca76a86086b411e1bf438a510fbca6a389 Mon Sep 17 00:00:00 2001 From: xunxi Date: Wed, 21 Jan 2026 20:54:16 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dapi=E6=9C=AA=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/config.py | 1 + .../src/components/shared/FileConfigItem.vue | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index bb1200520..54d44b3a4 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -1176,6 +1176,7 @@ async def upload_plugin_file(self): return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ async def delete_plugin_file(self): + """Delete a staged upload under temp; final deletion happens on config save.""" plugin_name = request.args.get("plugin_name") if not plugin_name: return Response().error("Missing plugin_name parameter").__dict__ diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue index c1adfa886..f6bc41806 100644 --- a/dashboard/src/components/shared/FileConfigItem.vue +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -226,6 +226,21 @@ const uploadFiles = async (files) => { const deleteFile = (filePath) => { fileList.value = fileList.value.filter((item) => item !== filePath) + + if (props.pluginName) { + axios + .post( + `/api/config/plugin/file/delete?plugin_name=${encodeURIComponent( + props.pluginName + )}`, + { path: filePath } + ) + .catch((error) => { + console.warn('Staged file delete failed:', error) + toast.warning(tm('fileUpload.deleteFailed')) + }) + } + toast.success(tm('fileUpload.deleteSuccess')) } From e282b6dd06ffeeda826b4e4f0dfbb20e74e5b32a Mon Sep 17 00:00:00 2001 From: xunxi Date: Fri, 23 Jan 2026 20:09:52 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_agent_tool_exec.py | 4 +- astrbot/core/message/components.py | 4 +- astrbot/core/pipeline/context_utils.py | 4 +- .../core/pipeline/preprocess_stage/stage.py | 2 +- .../method/agent_sub_stages/internal.py | 2 +- astrbot/core/pipeline/scheduler.py | 2 +- .../aiocqhttp/aiocqhttp_message_event.py | 6 +-- .../qqofficial/qqofficial_message_event.py | 9 +--- astrbot/core/star/command_management.py | 2 +- astrbot/core/star/config.py | 2 +- astrbot/core/star/filter/command.py | 2 +- astrbot/core/star/filter/custom_filter.py | 4 +- astrbot/core/star/register/star_handler.py | 2 +- astrbot/dashboard/routes/config.py | 52 +++++++++++++------ 14 files changed, 57 insertions(+), 40 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 5d40f48fa..060453161 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -256,7 +256,7 @@ async def call_local_llm_tool( # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 # 返回值只能是 MessageEventResult 或者 None(无返回值) _has_yielded = True - if isinstance(ret, (MessageEventResult, CommandResult)): + if isinstance(ret, MessageEventResult | CommandResult): # 如果返回值是 MessageEventResult, 设置结果并继续 event.set_result(ret) yield @@ -273,7 +273,7 @@ async def call_local_llm_tool( elif inspect.iscoroutine(ready_to_call): # 如果只是一个协程, 直接执行 ret = await ready_to_call - if isinstance(ret, (MessageEventResult, CommandResult)): + if isinstance(ret, MessageEventResult | CommandResult): event.set_result(ret) yield else: diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 050e36521..280276089 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -567,7 +567,7 @@ def __init__(self, content: list[BaseMessageComponent], **_): async def to_dict(self): data_content = [] for comp in self.content: - if isinstance(comp, (Image, Record)): + if isinstance(comp, Image | Record): # For Image and Record segments, we convert them to base64 bs64 = await comp.convert_to_base64() data_content.append( @@ -584,7 +584,7 @@ async def to_dict(self): # For File segments, we need to handle the file differently d = await comp.to_dict() data_content.append(d) - elif isinstance(comp, (Node, Nodes)): + elif isinstance(comp, Node | Nodes): # For Node segments, we recursively convert them to dict d = await comp.to_dict() data_content.append(d) diff --git a/astrbot/core/pipeline/context_utils.py b/astrbot/core/pipeline/context_utils.py index 1f5ba43a0..9402ce3e6 100644 --- a/astrbot/core/pipeline/context_utils.py +++ b/astrbot/core/pipeline/context_utils.py @@ -48,7 +48,7 @@ async def call_handler( # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 # 返回值只能是 MessageEventResult 或者 None(无返回值) _has_yielded = True - if isinstance(ret, (MessageEventResult, CommandResult)): + if isinstance(ret, MessageEventResult | CommandResult): # 如果返回值是 MessageEventResult, 设置结果并继续 event.set_result(ret) yield @@ -65,7 +65,7 @@ async def call_handler( elif inspect.iscoroutine(ready_to_call): # 如果只是一个协程, 直接执行 ret = await ready_to_call - if isinstance(ret, (MessageEventResult, CommandResult)): + if isinstance(ret, MessageEventResult | CommandResult): event.set_result(ret) yield else: diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index a69d07ffb..6544f85c1 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -52,7 +52,7 @@ async def process( message_chain = event.get_messages() for idx, component in enumerate(message_chain): - if isinstance(component, (Record, Image)) and component.url: + if isinstance(component, Record | Image) and component.url: for mapping in mappings: from_, to_ = mapping.split(":") from_ = from_.removesuffix("/") diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 43d88c5ad..a424b1731 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -510,7 +510,7 @@ async def process( has_valid_message = bool(event.message_str and event.message_str.strip()) # 检查是否有图片或其他媒体内容 has_media_content = any( - isinstance(comp, (Image, File)) for comp in event.message_obj.message + isinstance(comp, Image | File) for comp in event.message_obj.message ) if ( diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 5fb3034f5..8569f945a 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -82,7 +82,7 @@ async def execute(self, event: AstrMessageEvent): await self._process_stages(event) # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 - if isinstance(event, (WebChatMessageEvent, WecomAIBotMessageEvent)): + if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): await event.send(None) logger.debug("pipeline 执行完毕。") diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 293b462d3..3d84cbd44 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -33,7 +33,7 @@ def __init__( @staticmethod async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: """修复部分字段""" - if isinstance(segment, (Image, Record)): + if isinstance(segment, Image | Record): # For Image and Record segments, we convert them to base64 bs64 = await segment.convert_to_base64() return { @@ -110,7 +110,7 @@ async def send_message( """ # 转发消息、文件消息不能和普通消息混在一起发送 send_one_by_one = any( - isinstance(seg, (Node, Nodes, File)) for seg in message_chain.chain + isinstance(seg, Node | Nodes | File) for seg in message_chain.chain ) if not send_one_by_one: ret = await cls._parse_onebot_json(message_chain) @@ -119,7 +119,7 @@ async def send_message( await cls._dispatch_send(bot, event, is_group, session_id, ret) return for seg in message_chain.chain: - if isinstance(seg, (Node, Nodes)): + if isinstance(seg, Node | Nodes): # 合并转发消息 if isinstance(seg, Node): nodes = Nodes([seg]) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index d693c4206..ee0898d2a 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -90,12 +90,7 @@ async def _post_send(self, stream: dict | None = None): if not isinstance( source, - ( - botpy.message.Message, - botpy.message.GroupMessage, - botpy.message.DirectMessage, - botpy.message.C2CMessage, - ), + botpy.message.Message | botpy.message.GroupMessage | botpy.message.DirectMessage | botpy.message.C2CMessage, ): logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}") return None @@ -120,7 +115,7 @@ async def _post_send(self, stream: dict | None = None): "msg_id": self.message_obj.message_id, } - if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)): + if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage): payload["msg_seq"] = random.randint(1, 10000) ret = None diff --git a/astrbot/core/star/command_management.py b/astrbot/core/star/command_management.py index 3801932b0..ba3e39017 100644 --- a/astrbot/core/star/command_management.py +++ b/astrbot/core/star/command_management.py @@ -303,7 +303,7 @@ def _locate_primary_filter( handler: StarHandlerMetadata, ) -> CommandFilter | CommandGroupFilter | None: for filter_ref in handler.event_filters: - if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)): + if isinstance(filter_ref, CommandFilter | CommandGroupFilter): return filter_ref return None diff --git a/astrbot/core/star/config.py b/astrbot/core/star/config.py index a9af974c5..2b590921d 100644 --- a/astrbot/core/star/config.py +++ b/astrbot/core/star/config.py @@ -38,7 +38,7 @@ def put_config(namespace: str, name: str, key: str, value, description: str): raise ValueError("namespace 不能以 internal_ 开头。") if not isinstance(key, str): raise ValueError("key 只支持 str 类型。") - if not isinstance(value, (str, int, float, bool, list)): + if not isinstance(value, str | int | float | bool | list): raise ValueError("value 只支持 str, int, float, bool, list 类型。") config_dir = os.path.join(get_astrbot_data_path(), "config") diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index 51ad5f089..e86ee85af 100755 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -115,7 +115,7 @@ def validate_and_convert_params( # 没有 GreedyStr 的情况 if i >= len(params): if ( - isinstance(param_type_or_default_val, (type, types.UnionType)) + isinstance(param_type_or_default_val, type | types.UnionType) or typing.get_origin(param_type_or_default_val) is typing.Union or param_type_or_default_val is inspect.Parameter.empty ): diff --git a/astrbot/core/star/filter/custom_filter.py b/astrbot/core/star/filter/custom_filter.py index d57b5cac0..ff2df9bcd 100644 --- a/astrbot/core/star/filter/custom_filter.py +++ b/astrbot/core/star/filter/custom_filter.py @@ -37,7 +37,7 @@ def __and__(self, other): class CustomFilterOr(CustomFilter): def __init__(self, filter1: CustomFilter, filter2: CustomFilter): super().__init__() - if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)): + if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr): raise ValueError( "CustomFilter lass can only operate with other CustomFilter.", ) @@ -51,7 +51,7 @@ def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: class CustomFilterAnd(CustomFilter): def __init__(self, filter1: CustomFilter, filter2: CustomFilter): super().__init__() - if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)): + if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr): raise ValueError( "CustomFilter lass can only operate with other CustomFilter.", ) diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index a2644feef..024aaf2d0 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -150,7 +150,7 @@ def register_custom_filter(custom_type_filter, *args, **kwargs): if args: raise_error = args[0] - if not isinstance(custom_filter, (CustomFilterAnd, CustomFilterOr)): + if not isinstance(custom_filter, CustomFilterAnd | CustomFilterOr): custom_filter = custom_filter(raise_error) def decorator(awaitable): diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 54d44b3a4..ef3c4466e 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -25,8 +25,8 @@ get_astrbot_plugin_data_path, get_astrbot_temp_path, ) -from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.io import remove_dir +from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from .route import Response, Route, RouteContext @@ -895,7 +895,6 @@ async def post_plugin_configs(self): except Exception as e: return Response().error(str(e)).__dict__ - def _get_plugin_metadata_by_name(self, plugin_name: str): for plugin_md in star_registry: if plugin_md.name == plugin_name: @@ -936,10 +935,16 @@ def _sanitize_filename(name: str) -> str: def _sanitize_path_segment(segment: str) -> str: cleaned = [] for ch in segment: - if ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch.isdigit() or ch in { - "-", - "_", - }: + if ( + ("a" <= ch <= "z") + or ("A" <= ch <= "Z") + or ch.isdigit() + or ch + in { + "-", + "_", + } + ): cleaned.append(ch) else: cleaned.append("_") @@ -1115,9 +1120,13 @@ async def upload_plugin_file(self): md = self._get_plugin_metadata_by_name(plugin_name) if not md or not md.config: - return Response().error( - f"Plugin {plugin_name} not found or has no config", - ).__dict__ + return ( + Response() + .error( + f"Plugin {plugin_name} not found or has no config", + ) + .__dict__ + ) meta = self._get_schema_item(md.config.schema, key_path) if not meta or meta.get("type") != "file": @@ -1126,7 +1135,9 @@ async def upload_plugin_file(self): file_types = meta.get("file_types") allowed_exts = [] if isinstance(file_types, list): - allowed_exts = [str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()] + allowed_exts = [ + str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip() + ] files = await request.files if not files: @@ -1162,16 +1173,25 @@ async def upload_plugin_file(self): save_path = os.path.join(staging_root, rel_path) os.makedirs(os.path.dirname(save_path), exist_ok=True) await file.save(save_path) - if os.path.isfile(save_path) and os.path.getsize(save_path) > MAX_FILE_BYTES: + if ( + os.path.isfile(save_path) + and os.path.getsize(save_path) > MAX_FILE_BYTES + ): os.remove(save_path) errors.append(f"File too large: {filename}") continue uploaded.append(rel_path) if not uploaded: - return Response().error( - "Upload failed: " + ", ".join(errors) if errors else "Upload failed", - ).__dict__ + return ( + Response() + .error( + "Upload failed: " + ", ".join(errors) + if errors + else "Upload failed", + ) + .__dict__ + ) return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ @@ -1197,7 +1217,9 @@ async def delete_plugin_file(self): staged_path = os.path.abspath( os.path.normpath(os.path.join(staging_root, rel_path)), ) - if staged_path.startswith(staging_root + os.sep) and os.path.isfile(staged_path): + if staged_path.startswith(staging_root + os.sep) and os.path.isfile( + staged_path + ): os.remove(staged_path) return Response().ok(None, "Deletion staged").__dict__ From fe16c0634c8e7219aa5932f17fefbc10da7df233 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 24 Jan 2026 20:38:19 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=BAlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/shared/FileConfigItem.vue | 161 +++++++----------- 1 file changed, 65 insertions(+), 96 deletions(-) diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue index f6bc41806..917f852b0 100644 --- a/dashboard/src/components/shared/FileConfigItem.vue +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -23,43 +23,54 @@ {{ tm('fileUpload.empty') }}
-
-
- {{ getDisplayName(filePath) }} - -
- -
+ + + + {{ getDisplayName(filePath) }} + + + + + + + -
- mdi-plus -
-
{{ tm('fileUpload.dropzone') }}
-
+ + {{ tm('fileUpload.dropzone') }} + {{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }} -
- -
-
+ + + + + @@ -188,7 +199,7 @@ const uploadFiles = async (files) => { }) const response = await axios.post( - `/api/config/plugin/file/upload?plugin_name=${encodeURIComponent( + `/api/config/file/upload?scope=plugin&name=${encodeURIComponent( props.pluginName )}&key=${encodeURIComponent(props.configKey)}`, formData, @@ -230,7 +241,7 @@ const deleteFile = (filePath) => { if (props.pluginName) { axios .post( - `/api/config/plugin/file/delete?plugin_name=${encodeURIComponent( + `/api/config/file/delete?scope=plugin&name=${encodeURIComponent( props.pluginName )}`, { path: filePath } @@ -275,43 +286,6 @@ const getDisplayName = (path) => { padding: 16px 24px 20px; } -.upload-tile { - border: 2px dashed rgba(var(--v-theme-on-surface), 0.2); - border-radius: 18px; - width: 240px; - height: 200px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - cursor: pointer; - background: rgba(var(--v-theme-surface-variant), 0.35); - transition: border-color 0.2s ease, background 0.2s ease; -} - -.upload-tile:hover, -.upload-tile.dragover { - border-color: rgba(var(--v-theme-primary), 0.6); - background: rgba(var(--v-theme-primary), 0.06); -} - -.upload-icon { - width: 48px; - height: 48px; - border-radius: 16px; - background: rgba(var(--v-theme-primary), 0.08); - display: flex; - align-items: center; - justify-content: center; -} - -.upload-text { - font-size: 14px; - font-weight: 600; - color: rgba(var(--v-theme-on-surface), 0.7); -} - .upload-hint { font-size: 12px; color: rgba(var(--v-theme-on-surface), 0.5); @@ -322,45 +296,40 @@ const getDisplayName = (path) => { color: rgba(var(--v-theme-on-surface), 0.5); } -.file-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; -} - -.file-pill { - position: relative; - min-height: 84px; - padding: 12px 32px 12px 12px; +.file-list { border-radius: 16px; background: rgba(var(--v-theme-surface), 0.95); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); - display: flex; - align-items: center; - justify-content: center; + overflow: hidden; } -.file-pill-name { +.file-name { font-weight: 600; - text-align: center; word-break: break-word; } -.file-pill-delete { - position: absolute; - top: 6px; - right: 6px; +.upload-item { + border: 2px dashed rgba(var(--v-theme-on-surface), 0.2); + border-radius: 14px; + margin: 12px; + cursor: pointer; + background: rgba(var(--v-theme-surface-variant), 0.22); + transition: border-color 0.2s ease, background 0.2s ease; } -@media (max-width: 1400px) { - .file-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } +.upload-item:hover, +.upload-item.dragover { + border-color: rgba(var(--v-theme-primary), 0.6); + background: rgba(var(--v-theme-primary), 0.06); } -@media (max-width: 960px) { - .file-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +.upload-icon { + width: 32px; + height: 32px; + border-radius: 10px; + background: rgba(var(--v-theme-primary), 0.08); + display: flex; + align-items: center; + justify-content: center; } From 721fcadd510823dd7fef9546de3a892aa4247488 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 24 Jan 2026 20:38:38 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9B=B4=E5=8A=A0?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/config.py | 367 ++++++++--------------------- 1 file changed, 103 insertions(+), 264 deletions(-) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index ef3c4466e..ef9b3fab6 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -20,16 +20,22 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry from astrbot.core.provider import Provider from astrbot.core.provider.register import provider_registry -from astrbot.core.star.star import star_registry +from astrbot.core.star.star import StarMetadata, star_registry from astrbot.core.utils.astrbot_path import ( get_astrbot_plugin_data_path, get_astrbot_temp_path, ) -from astrbot.core.utils.io import remove_dir from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from .route import Response, Route, RouteContext +from .util import ( + apply_config_file_ops, + config_key_to_folder, + get_schema_item, + normalize_rel_path, + sanitize_filename, +) MAX_FILE_BYTES = 500 * 1024 * 1024 @@ -235,8 +241,8 @@ def __init__( "/config/default": ("GET", self.get_default_config), "/config/astrbot/update": ("POST", self.post_astrbot_configs), "/config/plugin/update": ("POST", self.post_plugin_configs), - "/config/plugin/file/upload": ("POST", self.upload_plugin_file), - "/config/plugin/file/delete": ("POST", self.delete_plugin_file), + "/config/file/upload": ("POST", self.upload_config_file), + "/config/file/delete": ("POST", self.delete_config_file), "/config/platform/new": ("POST", self.post_new_platform), "/config/platform/update": ("POST", self.post_update_platform), "/config/platform/delete": ("POST", self.post_delete_platform), @@ -895,266 +901,83 @@ async def post_plugin_configs(self): except Exception as e: return Response().error(str(e)).__dict__ - def _get_plugin_metadata_by_name(self, plugin_name: str): + def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: for plugin_md in star_registry: if plugin_md.name == plugin_name: return plugin_md return None - @staticmethod - def _get_schema_item(schema: dict | None, key_path: str) -> dict | None: - if not isinstance(schema, dict) or not key_path: - return None - if key_path in schema: - return schema.get(key_path) - - current = schema - parts = key_path.split(".") - for idx, part in enumerate(parts): - if part not in current: - return None - meta = current.get(part) - if idx == len(parts) - 1: - return meta - if not isinstance(meta, dict) or meta.get("type") != "object": - return None - current = meta.get("items", {}) - return None + def _resolve_config_file_scope( + self, + ) -> tuple[str, str, str, StarMetadata, AstrBotConfig]: + """将请求参数解析为一个明确的配置作用域。 - @staticmethod - def _sanitize_filename(name: str) -> str: - cleaned = os.path.basename(name).strip() - if not cleaned or cleaned in {".", ".."}: - return "" - for sep in (os.sep, os.altsep): - if sep: - cleaned = cleaned.replace(sep, "_") - return cleaned - - @staticmethod - def _sanitize_path_segment(segment: str) -> str: - cleaned = [] - for ch in segment: - if ( - ("a" <= ch <= "z") - or ("A" <= ch <= "Z") - or ch.isdigit() - or ch - in { - "-", - "_", - } - ): - cleaned.append(ch) - else: - cleaned.append("_") - result = "".join(cleaned).strip("_") - return result or "_" - - @classmethod - def _config_key_to_folder(cls, key_path: str) -> str: - parts = [cls._sanitize_path_segment(p) for p in key_path.split(".") if p] - return "/".join(parts) if parts else "_" - - @staticmethod - def _normalize_rel_path(rel_path: str | None) -> str | None: - if not isinstance(rel_path, str): - return None - rel = rel_path.replace("\\", "/").lstrip("/") - if not rel: - return None - parts = [p for p in rel.split("/") if p] - if any(part in {".", ".."} for part in parts): - return None - if rel.startswith("../") or "/../" in rel: - return None - return "/".join(parts) - - @staticmethod - def _get_value_by_path(data: dict, key_path: str): - if key_path in data: - return data.get(key_path) - current = data - for part in key_path.split("."): - if not isinstance(current, dict) or part not in current: - return None - current = current.get(part) - return current - - @staticmethod - def _set_value_by_path(data: dict, key_path: str, value) -> None: - if key_path in data: - data[key_path] = value - return - current = data - parts = key_path.split(".") - for part in parts[:-1]: - if part not in current or not isinstance(current[part], dict): - current[part] = {} - current = current[part] - current[parts[-1]] = value - - @classmethod - def _collect_file_keys(cls, schema: dict, prefix: str = "") -> list[str]: - keys = [] - for key, meta in schema.items(): - if not isinstance(meta, dict): - continue - meta_type = meta.get("type") - if meta_type == "file": - keys.append(f"{prefix}{key}" if prefix else key) - elif meta_type == "object": - child_prefix = f"{prefix}{key}." if prefix else f"{key}." - keys.extend(cls._collect_file_keys(meta.get("items", {}), child_prefix)) - return keys - - def _normalize_file_list(self, value, key_path: str) -> tuple[list[str], bool]: - if value is None: - return [], False - if not isinstance(value, list): - raise ValueError(f"Invalid file list for {key_path}") - folder = self._config_key_to_folder(key_path) - expected_prefix = f"files/{folder}/" - results = [] - changed = False - for item in value: - if not isinstance(item, str): - raise ValueError(f"Invalid file entry for {key_path}") - rel = self._normalize_rel_path(item) - if not rel or not rel.startswith("files/"): - raise ValueError(f"Invalid file path: {item}") - if rel.startswith(expected_prefix): - results.append(rel) - continue - if rel.count("/") == 1: - filename = rel.split("/", 1)[1] - if not filename: - raise ValueError(f"Invalid file path: {item}") - results.append(f"{expected_prefix}{filename}") - changed = True - continue - raise ValueError(f"Invalid file path: {item}") - return results, changed + 当前支持的 scope: + - scope=plugin:name=,key= + """ - def _apply_plugin_file_ops(self, plugin_name: str, md, post_configs: dict) -> None: - schema = getattr(md.config, "schema", None) if md and md.config else None - if not isinstance(schema, dict): - return + scope = request.args.get("scope") or "plugin" + name = request.args.get("name") + key_path = request.args.get("key") - file_keys = self._collect_file_keys(schema) - if not file_keys: - return + if scope != "plugin": + raise ValueError(f"Unsupported scope: {scope}") + if not name or not key_path: + raise ValueError("Missing name or key parameter") - old_config = dict(md.config) - new_file_set = set() - old_file_set = set() + md = self._get_plugin_metadata_by_name(name) + if not md or not md.config: + raise ValueError(f"Plugin {name} not found or has no config") - for key_path in file_keys: - new_list, new_changed = self._normalize_file_list( - self._get_value_by_path(post_configs, key_path), - key_path, - ) - if new_changed: - self._set_value_by_path(post_configs, key_path, new_list) - old_list, _ = self._normalize_file_list( - self._get_value_by_path(old_config, key_path), - key_path, - ) - new_file_set.update(new_list) - old_file_set.update(old_list) + return scope, name, key_path, md, md.config + + def _get_config_file_staging_roots(self, scope: str, name: str) -> tuple[str, str]: + """获取某个 scope 的(主、旧)暂存目录。""" - plugin_data_dir = os.path.abspath( - os.path.join(get_astrbot_plugin_data_path(), plugin_name), + primary = os.path.abspath( + os.path.join(get_astrbot_temp_path(), "config_file_uploads", scope, name), ) - staging_root = os.path.abspath( - os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", plugin_name), + legacy = os.path.abspath( + os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", name), ) + return primary, legacy - for rel_path in sorted(new_file_set): - final_path = os.path.abspath(os.path.join(plugin_data_dir, rel_path)) - if not final_path.startswith(plugin_data_dir + os.sep): - raise ValueError(f"Invalid file path: {rel_path}") - staged_path = os.path.abspath(os.path.join(staging_root, rel_path)) - if not staged_path.startswith(staging_root + os.sep): - raise ValueError(f"Invalid staged path: {rel_path}") - if os.path.exists(staged_path): - os.makedirs(os.path.dirname(final_path), exist_ok=True) - os.replace(staged_path, final_path) - continue - legacy_path = os.path.join( - plugin_data_dir, - "files", - os.path.basename(rel_path), - ) - if os.path.isfile(legacy_path): - os.makedirs(os.path.dirname(final_path), exist_ok=True) - os.replace(legacy_path, final_path) - continue - if not os.path.exists(final_path): - raise ValueError(f"Missing staged file: {rel_path}") - - retained = new_file_set - for rel_path in sorted(old_file_set - retained): - final_path = os.path.abspath(os.path.join(plugin_data_dir, rel_path)) - if not final_path.startswith(plugin_data_dir + os.sep): - continue - if os.path.isfile(final_path): - os.remove(final_path) - continue - legacy_path = os.path.join( - plugin_data_dir, - "files", - os.path.basename(rel_path), - ) - if os.path.isfile(legacy_path): - os.remove(legacy_path) - - if os.path.isdir(staging_root): - remove_dir(staging_root) + async def upload_config_file(self): + """上传文件到暂存区(用于某个 file 类型配置项)。 - async def upload_plugin_file(self): - plugin_name = request.args.get("plugin_name") - key_path = request.args.get("key") - if not plugin_name or not key_path: - return Response().error("Missing plugin_name or key parameter").__dict__ + 文件会先保存到临时目录,只有在“保存配置”时才会被移动到最终目录。 + """ - md = self._get_plugin_metadata_by_name(plugin_name) - if not md or not md.config: - return ( - Response() - .error( - f"Plugin {plugin_name} not found or has no config", - ) - .__dict__ - ) + try: + scope, name, key_path, md, config = self._resolve_config_file_scope() + except ValueError as e: + return Response().error(str(e)).__dict__ - meta = self._get_schema_item(md.config.schema, key_path) + meta = get_schema_item(getattr(config, "schema", None), key_path) if not meta or meta.get("type") != "file": return Response().error("Config item not found or not file type").__dict__ file_types = meta.get("file_types") - allowed_exts = [] + allowed_exts: list[str] = [] if isinstance(file_types, list): allowed_exts = [ - str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip() + str(ext).lstrip(".").lower() + for ext in file_types + if str(ext).strip() ] files = await request.files if not files: return Response().error("No files uploaded").__dict__ - staging_root = os.path.join( - get_astrbot_temp_path(), - "plugin_file_uploads", - plugin_name, - ) + staging_root, _legacy_root = self._get_config_file_staging_roots(scope, name) os.makedirs(staging_root, exist_ok=True) - uploaded = [] - folder = self._config_key_to_folder(key_path) - errors = [] + uploaded: list[str] = [] + folder = config_key_to_folder(key_path) + errors: list[str] = [] for file in files.values(): - filename = self._sanitize_filename(file.filename or "") + filename = sanitize_filename(file.filename or "") if not filename: errors.append("Invalid filename") continue @@ -1173,54 +996,51 @@ async def upload_plugin_file(self): save_path = os.path.join(staging_root, rel_path) os.makedirs(os.path.dirname(save_path), exist_ok=True) await file.save(save_path) - if ( - os.path.isfile(save_path) - and os.path.getsize(save_path) > MAX_FILE_BYTES - ): + if os.path.isfile(save_path) and os.path.getsize(save_path) > MAX_FILE_BYTES: os.remove(save_path) errors.append(f"File too large: {filename}") continue uploaded.append(rel_path) if not uploaded: - return ( - Response() - .error( - "Upload failed: " + ", ".join(errors) - if errors - else "Upload failed", - ) - .__dict__ - ) + return Response().error( + "Upload failed: " + ", ".join(errors) if errors else "Upload failed", + ).__dict__ return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ - async def delete_plugin_file(self): - """Delete a staged upload under temp; final deletion happens on config save.""" - plugin_name = request.args.get("plugin_name") - if not plugin_name: - return Response().error("Missing plugin_name parameter").__dict__ + async def delete_config_file(self): + """删除暂存区中的文件。 + + 最终的数据目录删除是在保存配置时,根据更新后的配置值统一应用。 + """ + + scope = request.args.get("scope") or "plugin" + name = request.args.get("name") + if not name: + return Response().error("Missing name parameter").__dict__ + if scope != "plugin": + return Response().error(f"Unsupported scope: {scope}").__dict__ data = await request.get_json() rel_path = data.get("path") if isinstance(data, dict) else None - rel_path = self._normalize_rel_path(rel_path) + rel_path = normalize_rel_path(rel_path) if not rel_path or not rel_path.startswith("files/"): return Response().error("Invalid path parameter").__dict__ - md = self._get_plugin_metadata_by_name(plugin_name) + md = self._get_plugin_metadata_by_name(name) if not md: - return Response().error(f"Plugin {plugin_name} not found").__dict__ + return Response().error(f"Plugin {name} not found").__dict__ - staging_root = os.path.abspath( - os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", plugin_name), - ) - staged_path = os.path.abspath( - os.path.normpath(os.path.join(staging_root, rel_path)), - ) - if staged_path.startswith(staging_root + os.sep) and os.path.isfile( - staged_path - ): - os.remove(staged_path) + primary_root, legacy_root = self._get_config_file_staging_roots(scope, name) + for staging_root in (primary_root, legacy_root): + staged_path = os.path.abspath( + os.path.normpath(os.path.join(staging_root, rel_path)), + ) + if staged_path.startswith(staging_root + os.sep) and os.path.isfile( + staged_path + ): + os.remove(staged_path) return Response().ok(None, "Deletion staged").__dict__ @@ -1478,6 +1298,7 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): raise ValueError(f"插件 {plugin_name} 不存在") if not md.config: raise ValueError(f"插件 {plugin_name} 没有注册配置") + assert md.config is not None try: errors, post_configs = validate_config( @@ -1486,7 +1307,25 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): if errors: raise ValueError(f"格式校验未通过: {errors}") - self._apply_plugin_file_ops(plugin_name, md, post_configs) + storage_root = os.path.abspath( + os.path.join(get_astrbot_plugin_data_path(), plugin_name), + ) + primary_staging, legacy_staging = self._get_config_file_staging_roots( + "plugin", + plugin_name, + ) + staging_root = ( + legacy_staging + if (not os.path.isdir(primary_staging) and os.path.isdir(legacy_staging)) + else primary_staging + ) + apply_config_file_ops( + schema=getattr(md.config, "schema", None), + old_config=dict(md.config), + post_configs=post_configs, + storage_root=storage_root, + staging_root=staging_root, + ) md.config.save_config(post_configs) except Exception as e: raise e From ee0f6795fa7ed1a75ef4a8d4491916b712141261 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 24 Jan 2026 20:39:08 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=BC=BA=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/util.py | 291 +++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 astrbot/dashboard/routes/util.py diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py new file mode 100644 index 000000000..619ef1250 --- /dev/null +++ b/astrbot/dashboard/routes/util.py @@ -0,0 +1,291 @@ +"""Dashboard 路由工具集。 + +这里放一些 dashboard routes 可复用的小工具函数。 + +目前主要用于「配置文件上传(file 类型配置项)」功能: +- 清洗/规范化用户可控的文件名与相对路径 +- 将配置 key 映射到配置项独立子目录 +- 在保存配置时应用暂存区文件变更(移动/删除/迁移旧路径) +""" + +from __future__ import annotations + +import os +from typing import Any + +from astrbot.core.utils.io import remove_dir + + +def get_schema_item(schema: dict | None, key_path: str) -> dict | None: + """按 dot-path 获取 schema 的节点。 + + 同时支持: + - 扁平 schema(直接 key 命中) + - 嵌套 object schema({type: "object", items: {...}}) + """ + + if not isinstance(schema, dict) or not key_path: + return None + if key_path in schema: + return schema.get(key_path) + + current = schema + parts = key_path.split(".") + for idx, part in enumerate(parts): + if part not in current: + return None + meta = current.get(part) + if idx == len(parts) - 1: + return meta + if not isinstance(meta, dict) or meta.get("type") != "object": + return None + current = meta.get("items", {}) + return None + + +def sanitize_filename(name: str) -> str: + """清洗上传文件名,避免路径穿越与非法名称。 + + - 丢弃目录部分,仅保留 basename + - 将路径分隔符替换为下划线 + - 拒绝空字符串 / "." / ".." + """ + + cleaned = os.path.basename(name).strip() + if not cleaned or cleaned in {".", ".."}: + return "" + for sep in (os.sep, os.altsep): + if sep: + cleaned = cleaned.replace(sep, "_") + return cleaned + + +def sanitize_path_segment(segment: str) -> str: + """清洗目录片段(URL/path 安全,避免穿越)。 + + 仅保留 [A-Za-z0-9_-],其余替换为 "_" + """ + + cleaned = [] + for ch in segment: + if ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch.isdigit() or ch in { + "-", + "_", + }: + cleaned.append(ch) + else: + cleaned.append("_") + result = "".join(cleaned).strip("_") + return result or "_" + + +def config_key_to_folder(key_path: str) -> str: + """将 dot-path 的配置 key 转成稳定的文件夹路径。""" + + parts = [sanitize_path_segment(p) for p in key_path.split(".") if p] + return "/".join(parts) if parts else "_" + + +def normalize_rel_path(rel_path: str | None) -> str | None: + """规范化用户传入的相对路径,并阻止路径穿越。""" + + if not isinstance(rel_path, str): + return None + rel = rel_path.replace("\\", "/").lstrip("/") + if not rel: + return None + parts = [p for p in rel.split("/") if p] + if any(part in {".", ".."} for part in parts): + return None + if rel.startswith("../") or "/../" in rel: + return None + return "/".join(parts) + + +def get_value_by_path(data: dict, key_path: str): + """按 dot-path 获取嵌套 dict 的值(也支持直接 key 命中)。""" + + if key_path in data: + return data.get(key_path) + current = data + for part in key_path.split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current.get(part) + return current + + +def set_value_by_path(data: dict, key_path: str, value) -> None: + """按 dot-path 设置嵌套 dict 的值(也支持直接 key 命中)。""" + + if key_path in data: + data[key_path] = value + return + current = data + parts = key_path.split(".") + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + +def collect_file_keys(schema: dict, prefix: str = "") -> list[str]: + """收集 schema 中所有 type == 'file' 的配置项 key_path。""" + + keys: list[str] = [] + for key, meta in schema.items(): + if not isinstance(meta, dict): + continue + meta_type = meta.get("type") + if meta_type == "file": + keys.append(f"{prefix}{key}" if prefix else key) + elif meta_type == "object": + child_prefix = f"{prefix}{key}." if prefix else f"{key}." + keys.extend(collect_file_keys(meta.get("items", {}), child_prefix)) + return keys + + +def normalize_file_list(value: Any, key_path: str) -> tuple[list[str], bool]: + """规范化某个 file 类型配置项的值(list[str])。 + + 强制配置项隔离(每个配置项一个子目录): + files// + + 同时支持迁移旧格式: + files/ + """ + + if value is None: + return [], False + if not isinstance(value, list): + raise ValueError(f"Invalid file list for {key_path}") + + folder = config_key_to_folder(key_path) + expected_prefix = f"files/{folder}/" + + results: list[str] = [] + changed = False + for item in value: + if not isinstance(item, str): + raise ValueError(f"Invalid file entry for {key_path}") + rel = normalize_rel_path(item) + if not rel or not rel.startswith("files/"): + raise ValueError(f"Invalid file path: {item}") + + if rel.startswith(expected_prefix): + results.append(rel) + continue + + # 兼容旧格式:files/ -> files// + if rel.count("/") == 1: + filename = rel.split("/", 1)[1] + if not filename: + raise ValueError(f"Invalid file path: {item}") + results.append(f"{expected_prefix}{filename}") + changed = True + continue + + raise ValueError(f"Invalid file path: {item}") + + return results, changed + + +def apply_config_file_ops( + *, + schema: dict | None, + old_config: dict, + post_configs: dict, + storage_root: str, + staging_root: str, +) -> None: + """根据配置变更应用暂存区文件操作。 + + 对于每个 `type: "file"` 的配置项,配置值为 `list[str]`,每一项是相对路径: + files// + + 该函数会: + 1) 规范化配置中的路径(并迁移旧格式 files/)。 + 2) 将暂存上传从 `staging_root/` 移动到 `storage_root/`。 + 3) 删除配置中已移除的文件(在 `storage_root` 下)。 + 4) 清理暂存目录。 + """ + + if not isinstance(schema, dict): + return + + file_keys = collect_file_keys(schema) + if not file_keys: + return + + storage_root_abs = os.path.abspath(storage_root) + staging_root_abs = os.path.abspath(staging_root) + + new_file_set: set[str] = set() + old_file_set: set[str] = set() + + for key_path in file_keys: + new_list, new_changed = normalize_file_list( + get_value_by_path(post_configs, key_path), + key_path, + ) + if new_changed: + set_value_by_path(post_configs, key_path, new_list) + + old_list, _ = normalize_file_list( + get_value_by_path(old_config, key_path), + key_path, + ) + + new_file_set.update(new_list) + old_file_set.update(old_list) + + # 1) Materialize referenced files (staged -> final, or keep existing). + for rel_path in sorted(new_file_set): + final_path = os.path.abspath(os.path.join(storage_root_abs, rel_path)) + if not final_path.startswith(storage_root_abs + os.sep): + raise ValueError(f"Invalid file path: {rel_path}") + + staged_path = os.path.abspath(os.path.join(staging_root_abs, rel_path)) + if not staged_path.startswith(staging_root_abs + os.sep): + raise ValueError(f"Invalid staged path: {rel_path}") + + if os.path.exists(staged_path): + os.makedirs(os.path.dirname(final_path), exist_ok=True) + os.replace(staged_path, final_path) + continue + + # 兼容旧路径:storage_root/files/ -> storage_root/ + legacy_path = os.path.join( + storage_root_abs, + "files", + os.path.basename(rel_path), + ) + if os.path.isfile(legacy_path): + os.makedirs(os.path.dirname(final_path), exist_ok=True) + os.replace(legacy_path, final_path) + continue + + if not os.path.exists(final_path): + raise ValueError(f"Missing staged file: {rel_path}") + + # 2) 删除配置里被移除的文件。 + for rel_path in sorted(old_file_set - new_file_set): + final_path = os.path.abspath(os.path.join(storage_root_abs, rel_path)) + if not final_path.startswith(storage_root_abs + os.sep): + continue + if os.path.isfile(final_path): + os.remove(final_path) + continue + + legacy_path = os.path.join( + storage_root_abs, + "files", + os.path.basename(rel_path), + ) + if os.path.isfile(legacy_path): + os.remove(legacy_path) + + # 3) 保存后清理该 scope 的暂存目录。 + if os.path.isdir(staging_root_abs): + remove_dir(staging_root_abs) From 188a35d9cbba27c29efa6a2f13383449437a030e Mon Sep 17 00:00:00 2001 From: xunxi Date: Sat, 24 Jan 2026 20:45:10 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqofficial/qqofficial_message_event.py | 5 +++- astrbot/dashboard/routes/config.py | 25 +++++++++++++------ astrbot/dashboard/routes/util.py | 14 ++++++++--- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index ee0898d2a..6076bfc1b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -90,7 +90,10 @@ async def _post_send(self, stream: dict | None = None): if not isinstance( source, - botpy.message.Message | botpy.message.GroupMessage | botpy.message.DirectMessage | botpy.message.C2CMessage, + botpy.message.Message + | botpy.message.GroupMessage + | botpy.message.DirectMessage + | botpy.message.C2CMessage, ): logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}") return None diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index ef9b3fab6..0c3feff58 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -961,9 +961,7 @@ async def upload_config_file(self): allowed_exts: list[str] = [] if isinstance(file_types, list): allowed_exts = [ - str(ext).lstrip(".").lower() - for ext in file_types - if str(ext).strip() + str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip() ] files = await request.files @@ -996,16 +994,25 @@ async def upload_config_file(self): save_path = os.path.join(staging_root, rel_path) os.makedirs(os.path.dirname(save_path), exist_ok=True) await file.save(save_path) - if os.path.isfile(save_path) and os.path.getsize(save_path) > MAX_FILE_BYTES: + if ( + os.path.isfile(save_path) + and os.path.getsize(save_path) > MAX_FILE_BYTES + ): os.remove(save_path) errors.append(f"File too large: {filename}") continue uploaded.append(rel_path) if not uploaded: - return Response().error( - "Upload failed: " + ", ".join(errors) if errors else "Upload failed", - ).__dict__ + return ( + Response() + .error( + "Upload failed: " + ", ".join(errors) + if errors + else "Upload failed", + ) + .__dict__ + ) return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ @@ -1316,7 +1323,9 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): ) staging_root = ( legacy_staging - if (not os.path.isdir(primary_staging) and os.path.isdir(legacy_staging)) + if ( + not os.path.isdir(primary_staging) and os.path.isdir(legacy_staging) + ) else primary_staging ) apply_config_file_ops( diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py index 619ef1250..18c1277f8 100644 --- a/astrbot/dashboard/routes/util.py +++ b/astrbot/dashboard/routes/util.py @@ -68,10 +68,16 @@ def sanitize_path_segment(segment: str) -> str: cleaned = [] for ch in segment: - if ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch.isdigit() or ch in { - "-", - "_", - }: + if ( + ("a" <= ch <= "z") + or ("A" <= ch <= "Z") + or ch.isdigit() + or ch + in { + "-", + "_", + } + ): cleaned.append(ch) else: cleaned.append("_") From 361acdad337c0dccbaf8297c469f7743933547e0 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sun, 25 Jan 2026 13:31:29 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E5=8E=BB=E9=99=A4=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/config.py | 38 ++++++++++++------------------ 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 0c3feff58..b5c2168b6 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -2,6 +2,7 @@ import inspect import os import traceback +from pathlib import Path from typing import Any from quart import request @@ -932,15 +933,12 @@ def _resolve_config_file_scope( return scope, name, key_path, md, md.config def _get_config_file_staging_roots(self, scope: str, name: str) -> tuple[str, str]: - """获取某个 scope 的(主、旧)暂存目录。""" + """获取某个 scope 的暂存目录。""" primary = os.path.abspath( os.path.join(get_astrbot_temp_path(), "config_file_uploads", scope, name), ) - legacy = os.path.abspath( - os.path.join(get_astrbot_temp_path(), "plugin_file_uploads", name), - ) - return primary, legacy + return primary, "" async def upload_config_file(self): """上传文件到暂存区(用于某个 file 类型配置项)。 @@ -1039,15 +1037,16 @@ async def delete_config_file(self): if not md: return Response().error(f"Plugin {name} not found").__dict__ - primary_root, legacy_root = self._get_config_file_staging_roots(scope, name) - for staging_root in (primary_root, legacy_root): - staged_path = os.path.abspath( - os.path.normpath(os.path.join(staging_root, rel_path)), - ) - if staged_path.startswith(staging_root + os.sep) and os.path.isfile( - staged_path - ): - os.remove(staged_path) + primary_root, _legacy_root = self._get_config_file_staging_roots(scope, name) + for staging_root in (primary_root,): + staging_root_path = Path(staging_root).resolve(strict=False) + staged_path = (staging_root_path / rel_path).resolve(strict=False) + try: + staged_path.relative_to(staging_root_path) + except ValueError: + continue + if staged_path.is_file(): + staged_path.unlink() return Response().ok(None, "Deletion staged").__dict__ @@ -1317,23 +1316,16 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): storage_root = os.path.abspath( os.path.join(get_astrbot_plugin_data_path(), plugin_name), ) - primary_staging, legacy_staging = self._get_config_file_staging_roots( + primary_staging, _legacy_staging = self._get_config_file_staging_roots( "plugin", plugin_name, ) - staging_root = ( - legacy_staging - if ( - not os.path.isdir(primary_staging) and os.path.isdir(legacy_staging) - ) - else primary_staging - ) apply_config_file_ops( schema=getattr(md.config, "schema", None), old_config=dict(md.config), post_configs=post_configs, storage_root=storage_root, - staging_root=staging_root, + staging_root=primary_staging, ) md.config.save_config(post_configs) except Exception as e: From b144884f09cf40e98b7170cee58ccb9204bc4a46 Mon Sep 17 00:00:00 2001 From: xunxi Date: Sun, 25 Jan 2026 13:54:37 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E5=88=A0=E6=8E=89stage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/dashboard/routes/config.py | 104 ++++++++------- astrbot/dashboard/routes/util.py | 195 ----------------------------- 2 files changed, 50 insertions(+), 249 deletions(-) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index b5c2168b6..e8c19ea8d 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -24,14 +24,12 @@ from astrbot.core.star.star import StarMetadata, star_registry from astrbot.core.utils.astrbot_path import ( get_astrbot_plugin_data_path, - get_astrbot_temp_path, ) from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from .route import Response, Route, RouteContext from .util import ( - apply_config_file_ops, config_key_to_folder, get_schema_item, normalize_rel_path, @@ -128,6 +126,22 @@ def validate(data: dict, metadata: dict = schema, path=""): errors.append( f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}", ) + continue + normalized = normalize_rel_path(item) + if not normalized or not normalized.startswith("files/"): + errors.append( + f"Invalid file path {path}{key}[{idx}]: {item}", + ) + continue + key_path = f"{path}{key}" + expected_folder = config_key_to_folder(key_path) + expected_prefix = f"files/{expected_folder}/" + if not normalized.startswith(expected_prefix): + errors.append( + f"Invalid file path {path}{key}[{idx}]: {item}", + ) + continue + value[idx] = normalized continue if meta["type"] == "list" and not isinstance(value, list): @@ -932,19 +946,8 @@ def _resolve_config_file_scope( return scope, name, key_path, md, md.config - def _get_config_file_staging_roots(self, scope: str, name: str) -> tuple[str, str]: - """获取某个 scope 的暂存目录。""" - - primary = os.path.abspath( - os.path.join(get_astrbot_temp_path(), "config_file_uploads", scope, name), - ) - return primary, "" - async def upload_config_file(self): - """上传文件到暂存区(用于某个 file 类型配置项)。 - - 文件会先保存到临时目录,只有在“保存配置”时才会被移动到最终目录。 - """ + """上传文件到插件数据目录(用于某个 file 类型配置项)。""" try: scope, name, key_path, md, config = self._resolve_config_file_scope() @@ -966,8 +969,13 @@ async def upload_config_file(self): if not files: return Response().error("No files uploaded").__dict__ - staging_root, _legacy_root = self._get_config_file_staging_roots(scope, name) - os.makedirs(staging_root, exist_ok=True) + storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) + plugin_root_path = (storage_root_path / name).resolve(strict=False) + try: + plugin_root_path.relative_to(storage_root_path) + except ValueError: + return Response().error("Invalid name parameter").__dict__ + plugin_root_path.mkdir(parents=True, exist_ok=True) uploaded: list[str] = [] folder = config_key_to_folder(key_path) @@ -989,14 +997,17 @@ async def upload_config_file(self): continue rel_path = f"files/{folder}/{filename}" - save_path = os.path.join(staging_root, rel_path) - os.makedirs(os.path.dirname(save_path), exist_ok=True) - await file.save(save_path) - if ( - os.path.isfile(save_path) - and os.path.getsize(save_path) > MAX_FILE_BYTES - ): - os.remove(save_path) + save_path = (plugin_root_path / rel_path).resolve(strict=False) + try: + save_path.relative_to(plugin_root_path) + except ValueError: + errors.append(f"Invalid path: {filename}") + continue + + save_path.parent.mkdir(parents=True, exist_ok=True) + await file.save(str(save_path)) + if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES: + save_path.unlink() errors.append(f"File too large: {filename}") continue uploaded.append(rel_path) @@ -1015,10 +1026,7 @@ async def upload_config_file(self): return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ async def delete_config_file(self): - """删除暂存区中的文件。 - - 最终的数据目录删除是在保存配置时,根据更新后的配置值统一应用。 - """ + """删除插件数据目录中的文件。""" scope = request.args.get("scope") or "plugin" name = request.args.get("name") @@ -1037,18 +1045,21 @@ async def delete_config_file(self): if not md: return Response().error(f"Plugin {name} not found").__dict__ - primary_root, _legacy_root = self._get_config_file_staging_roots(scope, name) - for staging_root in (primary_root,): - staging_root_path = Path(staging_root).resolve(strict=False) - staged_path = (staging_root_path / rel_path).resolve(strict=False) - try: - staged_path.relative_to(staging_root_path) - except ValueError: - continue - if staged_path.is_file(): - staged_path.unlink() + storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) + plugin_root_path = (storage_root_path / name).resolve(strict=False) + try: + plugin_root_path.relative_to(storage_root_path) + except ValueError: + return Response().error("Invalid name parameter").__dict__ + target_path = (plugin_root_path / rel_path).resolve(strict=False) + try: + target_path.relative_to(plugin_root_path) + except ValueError: + return Response().error("Invalid path parameter").__dict__ + if target_path.is_file(): + target_path.unlink() - return Response().ok(None, "Deletion staged").__dict__ + return Response().ok(None, "Deleted").__dict__ async def post_new_platform(self): new_platform_config = await request.json @@ -1312,21 +1323,6 @@ async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): ) if errors: raise ValueError(f"格式校验未通过: {errors}") - - storage_root = os.path.abspath( - os.path.join(get_astrbot_plugin_data_path(), plugin_name), - ) - primary_staging, _legacy_staging = self._get_config_file_staging_roots( - "plugin", - plugin_name, - ) - apply_config_file_ops( - schema=getattr(md.config, "schema", None), - old_config=dict(md.config), - post_configs=post_configs, - storage_root=storage_root, - staging_root=primary_staging, - ) md.config.save_config(post_configs) except Exception as e: raise e diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py index 18c1277f8..105619815 100644 --- a/astrbot/dashboard/routes/util.py +++ b/astrbot/dashboard/routes/util.py @@ -5,15 +5,9 @@ 目前主要用于「配置文件上传(file 类型配置项)」功能: - 清洗/规范化用户可控的文件名与相对路径 - 将配置 key 映射到配置项独立子目录 -- 在保存配置时应用暂存区文件变更(移动/删除/迁移旧路径) """ -from __future__ import annotations - import os -from typing import Any - -from astrbot.core.utils.io import remove_dir def get_schema_item(schema: dict | None, key_path: str) -> dict | None: @@ -106,192 +100,3 @@ def normalize_rel_path(rel_path: str | None) -> str | None: if rel.startswith("../") or "/../" in rel: return None return "/".join(parts) - - -def get_value_by_path(data: dict, key_path: str): - """按 dot-path 获取嵌套 dict 的值(也支持直接 key 命中)。""" - - if key_path in data: - return data.get(key_path) - current = data - for part in key_path.split("."): - if not isinstance(current, dict) or part not in current: - return None - current = current.get(part) - return current - - -def set_value_by_path(data: dict, key_path: str, value) -> None: - """按 dot-path 设置嵌套 dict 的值(也支持直接 key 命中)。""" - - if key_path in data: - data[key_path] = value - return - current = data - parts = key_path.split(".") - for part in parts[:-1]: - if part not in current or not isinstance(current[part], dict): - current[part] = {} - current = current[part] - current[parts[-1]] = value - - -def collect_file_keys(schema: dict, prefix: str = "") -> list[str]: - """收集 schema 中所有 type == 'file' 的配置项 key_path。""" - - keys: list[str] = [] - for key, meta in schema.items(): - if not isinstance(meta, dict): - continue - meta_type = meta.get("type") - if meta_type == "file": - keys.append(f"{prefix}{key}" if prefix else key) - elif meta_type == "object": - child_prefix = f"{prefix}{key}." if prefix else f"{key}." - keys.extend(collect_file_keys(meta.get("items", {}), child_prefix)) - return keys - - -def normalize_file_list(value: Any, key_path: str) -> tuple[list[str], bool]: - """规范化某个 file 类型配置项的值(list[str])。 - - 强制配置项隔离(每个配置项一个子目录): - files// - - 同时支持迁移旧格式: - files/ - """ - - if value is None: - return [], False - if not isinstance(value, list): - raise ValueError(f"Invalid file list for {key_path}") - - folder = config_key_to_folder(key_path) - expected_prefix = f"files/{folder}/" - - results: list[str] = [] - changed = False - for item in value: - if not isinstance(item, str): - raise ValueError(f"Invalid file entry for {key_path}") - rel = normalize_rel_path(item) - if not rel or not rel.startswith("files/"): - raise ValueError(f"Invalid file path: {item}") - - if rel.startswith(expected_prefix): - results.append(rel) - continue - - # 兼容旧格式:files/ -> files// - if rel.count("/") == 1: - filename = rel.split("/", 1)[1] - if not filename: - raise ValueError(f"Invalid file path: {item}") - results.append(f"{expected_prefix}{filename}") - changed = True - continue - - raise ValueError(f"Invalid file path: {item}") - - return results, changed - - -def apply_config_file_ops( - *, - schema: dict | None, - old_config: dict, - post_configs: dict, - storage_root: str, - staging_root: str, -) -> None: - """根据配置变更应用暂存区文件操作。 - - 对于每个 `type: "file"` 的配置项,配置值为 `list[str]`,每一项是相对路径: - files// - - 该函数会: - 1) 规范化配置中的路径(并迁移旧格式 files/)。 - 2) 将暂存上传从 `staging_root/` 移动到 `storage_root/`。 - 3) 删除配置中已移除的文件(在 `storage_root` 下)。 - 4) 清理暂存目录。 - """ - - if not isinstance(schema, dict): - return - - file_keys = collect_file_keys(schema) - if not file_keys: - return - - storage_root_abs = os.path.abspath(storage_root) - staging_root_abs = os.path.abspath(staging_root) - - new_file_set: set[str] = set() - old_file_set: set[str] = set() - - for key_path in file_keys: - new_list, new_changed = normalize_file_list( - get_value_by_path(post_configs, key_path), - key_path, - ) - if new_changed: - set_value_by_path(post_configs, key_path, new_list) - - old_list, _ = normalize_file_list( - get_value_by_path(old_config, key_path), - key_path, - ) - - new_file_set.update(new_list) - old_file_set.update(old_list) - - # 1) Materialize referenced files (staged -> final, or keep existing). - for rel_path in sorted(new_file_set): - final_path = os.path.abspath(os.path.join(storage_root_abs, rel_path)) - if not final_path.startswith(storage_root_abs + os.sep): - raise ValueError(f"Invalid file path: {rel_path}") - - staged_path = os.path.abspath(os.path.join(staging_root_abs, rel_path)) - if not staged_path.startswith(staging_root_abs + os.sep): - raise ValueError(f"Invalid staged path: {rel_path}") - - if os.path.exists(staged_path): - os.makedirs(os.path.dirname(final_path), exist_ok=True) - os.replace(staged_path, final_path) - continue - - # 兼容旧路径:storage_root/files/ -> storage_root/ - legacy_path = os.path.join( - storage_root_abs, - "files", - os.path.basename(rel_path), - ) - if os.path.isfile(legacy_path): - os.makedirs(os.path.dirname(final_path), exist_ok=True) - os.replace(legacy_path, final_path) - continue - - if not os.path.exists(final_path): - raise ValueError(f"Missing staged file: {rel_path}") - - # 2) 删除配置里被移除的文件。 - for rel_path in sorted(old_file_set - new_file_set): - final_path = os.path.abspath(os.path.join(storage_root_abs, rel_path)) - if not final_path.startswith(storage_root_abs + os.sep): - continue - if os.path.isfile(final_path): - os.remove(final_path) - continue - - legacy_path = os.path.join( - storage_root_abs, - "files", - os.path.basename(rel_path), - ) - if os.path.isfile(legacy_path): - os.remove(legacy_path) - - # 3) 保存后清理该 scope 的暂存目录。 - if os.path.isdir(staging_root_abs): - remove_dir(staging_root_abs) From b7d41e798160cb0d9a68f34b4bee64adb772d5d5 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 27 Jan 2026 14:18:33 +0800 Subject: [PATCH 14/17] perf: ui --- .../src/components/shared/FileConfigItem.vue | 125 ++++++------------ 1 file changed, 38 insertions(+), 87 deletions(-) diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue index 917f852b0..c94993714 100644 --- a/dashboard/src/components/shared/FileConfigItem.vue +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -4,74 +4,52 @@ {{ tm('fileUpload.button') }} - + {{ fileCountText }} - - + + - {{ tm('fileUpload.dialogTitle') }} + {{ tm('fileUpload.dialogTitle') }} -
-
- {{ tm('fileUpload.empty') }} -
- - - - - - {{ getDisplayName(filePath) }} - - - - - - - - - {{ tm('fileUpload.dropzone') }} - - {{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }} - - - - - +
+ {{ tm('fileUpload.empty') }}
+ + + + + + {{ getDisplayName(filePath) }} + + + + + + + + + {{ tm('fileUpload.dropzone') }} + + {{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }} + + + + + @@ -267,14 +245,9 @@ const getDisplayName = (path) => { width: 100%; } -.file-dialog { - display: flex; - flex-direction: column; - gap: 16px; -} - .file-dialog-card { height: 70vh; + box-shadow: none; } .file-dialog-body { @@ -296,40 +269,18 @@ const getDisplayName = (path) => { color: rgba(var(--v-theme-on-surface), 0.5); } -.file-list { - border-radius: 16px; - background: rgba(var(--v-theme-surface), 0.95); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); - overflow: hidden; -} - .file-name { font-weight: 600; word-break: break-word; } .upload-item { - border: 2px dashed rgba(var(--v-theme-on-surface), 0.2); - border-radius: 14px; - margin: 12px; cursor: pointer; - background: rgba(var(--v-theme-surface-variant), 0.22); - transition: border-color 0.2s ease, background 0.2s ease; + transition: background 0.2s ease; } .upload-item:hover, .upload-item.dragover { - border-color: rgba(var(--v-theme-primary), 0.6); - background: rgba(var(--v-theme-primary), 0.06); -} - -.upload-icon { - width: 32px; - height: 32px; - border-radius: 10px; - background: rgba(var(--v-theme-primary), 0.08); - display: flex; - align-items: center; - justify-content: center; + background: rgba(var(--v-theme-on-surface), 0.04); } From eb5c2ce73011a47c2133d8b85d24a3d1d67bcfd4 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 27 Jan 2026 14:26:02 +0800 Subject: [PATCH 15/17] fix: adjust config dialog layout for better usability --- dashboard/src/views/ExtensionPage.vue | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index 3ec6fca37..a1bb81a69 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -2127,20 +2127,22 @@ watch(isListView, (newVal) => { - + - {{ + {{ tm("dialogs.config.title") }} - -

{{ tm("dialogs.config.noConfig") }}

+
+ +

{{ tm("dialogs.config.noConfig") }}

+
From 69b176a227cd92619dbb0ef3a8a0339b1a52606b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 27 Jan 2026 14:54:15 +0800 Subject: [PATCH 16/17] feat: add file upload functionality and status indicators to plugin config --- astrbot/dashboard/routes/config.py | 43 ++++++ .../src/components/shared/FileConfigItem.vue | 133 +++++++++++++++++- .../i18n/locales/en-US/features/config.json | 4 + .../i18n/locales/zh-CN/features/config.json | 4 + 4 files changed, 178 insertions(+), 6 deletions(-) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index e8c19ea8d..c5998682c 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -258,6 +258,7 @@ def __init__( "/config/plugin/update": ("POST", self.post_plugin_configs), "/config/file/upload": ("POST", self.upload_config_file), "/config/file/delete": ("POST", self.delete_config_file), + "/config/file/get": ("GET", self.get_config_file_list), "/config/platform/new": ("POST", self.post_new_platform), "/config/platform/update": ("POST", self.post_update_platform), "/config/platform/delete": ("POST", self.post_delete_platform), @@ -1061,6 +1062,48 @@ async def delete_config_file(self): return Response().ok(None, "Deleted").__dict__ + async def get_config_file_list(self): + """获取配置项对应目录下的文件列表。""" + + try: + _, name, key_path, _, config = self._resolve_config_file_scope() + except ValueError as e: + return Response().error(str(e)).__dict__ + + meta = get_schema_item(getattr(config, "schema", None), key_path) + if not meta or meta.get("type") != "file": + return Response().error("Config item not found or not file type").__dict__ + + storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) + plugin_root_path = (storage_root_path / name).resolve(strict=False) + try: + plugin_root_path.relative_to(storage_root_path) + except ValueError: + return Response().error("Invalid name parameter").__dict__ + + folder = config_key_to_folder(key_path) + target_dir = (plugin_root_path / "files" / folder).resolve(strict=False) + try: + target_dir.relative_to(plugin_root_path) + except ValueError: + return Response().error("Invalid path parameter").__dict__ + + if not target_dir.exists() or not target_dir.is_dir(): + return Response().ok({"files": []}).__dict__ + + files: list[str] = [] + for path in target_dir.rglob("*"): + if not path.is_file(): + continue + try: + rel_path = path.relative_to(plugin_root_path).as_posix() + except ValueError: + continue + if rel_path.startswith("files/"): + files.append(rel_path) + + return Response().ok({"files": files}).__dict__ + async def post_new_platform(self): new_platform_config = await request.json diff --git a/dashboard/src/components/shared/FileConfigItem.vue b/dashboard/src/components/shared/FileConfigItem.vue index c94993714..e7c5716b9 100644 --- a/dashboard/src/components/shared/FileConfigItem.vue +++ b/dashboard/src/components/shared/FileConfigItem.vue @@ -18,24 +18,33 @@
-
+
{{ tm('fileUpload.empty') }}
- + - {{ getDisplayName(filePath) }} + {{ getDisplayName(item.path) }} - + @@ -64,7 +73,7 @@