feat: add file upload to plugin config (#4539)
Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -3244,6 +3244,7 @@ DEFAULT_VALUE_MAP = {
|
||||
"string": "",
|
||||
"text": "",
|
||||
"list": [],
|
||||
"file": [],
|
||||
"object": {},
|
||||
"template_list": [],
|
||||
}
|
||||
|
||||
@@ -567,7 +567,7 @@ class Node(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 @@ class Node(BaseMessageComponent):
|
||||
# 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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -52,7 +52,7 @@ class PreProcessStage(Stage):
|
||||
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("/")
|
||||
|
||||
@@ -517,7 +517,7 @@ class InternalAgentSubStage(Stage):
|
||||
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 (
|
||||
|
||||
@@ -82,7 +82,7 @@ class PipelineScheduler:
|
||||
await self._process_stages(event)
|
||||
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, (WebChatMessageEvent, WecomAIBotMessageEvent)):
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
|
||||
@@ -33,7 +33,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
@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 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""
|
||||
# 转发消息、文件消息不能和普通消息混在一起发送
|
||||
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 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
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])
|
||||
|
||||
@@ -90,12 +90,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
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 +118,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -115,7 +115,7 @@ class CommandFilter(HandlerFilter):
|
||||
# 没有 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
|
||||
):
|
||||
|
||||
@@ -37,7 +37,7 @@ class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
|
||||
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 @@ class CustomFilterOr(CustomFilter):
|
||||
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.",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from quart import request
|
||||
@@ -20,11 +21,22 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
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,
|
||||
)
|
||||
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 (
|
||||
config_key_to_folder,
|
||||
get_schema_item,
|
||||
normalize_rel_path,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
MAX_FILE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
|
||||
def try_cast(value: Any, type_: str):
|
||||
@@ -106,6 +118,32 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
|
||||
_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
|
||||
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):
|
||||
errors.append(
|
||||
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
|
||||
@@ -218,6 +256,9 @@ class ConfigRoute(Route):
|
||||
"/config/default": ("GET", self.get_default_config),
|
||||
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
|
||||
"/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),
|
||||
@@ -876,6 +917,193 @@ class ConfigRoute(Route):
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
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
|
||||
|
||||
def _resolve_config_file_scope(
|
||||
self,
|
||||
) -> tuple[str, str, str, StarMetadata, AstrBotConfig]:
|
||||
"""将请求参数解析为一个明确的配置作用域。
|
||||
|
||||
当前支持的 scope:
|
||||
- scope=plugin:name=<plugin_name>,key=<config_key_path>
|
||||
"""
|
||||
|
||||
scope = request.args.get("scope") or "plugin"
|
||||
name = request.args.get("name")
|
||||
key_path = request.args.get("key")
|
||||
|
||||
if scope != "plugin":
|
||||
raise ValueError(f"Unsupported scope: {scope}")
|
||||
if not name or not key_path:
|
||||
raise ValueError("Missing name or key parameter")
|
||||
|
||||
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")
|
||||
|
||||
return scope, name, key_path, md, md.config
|
||||
|
||||
async def upload_config_file(self):
|
||||
"""上传文件到插件数据目录(用于某个 file 类型配置项)。"""
|
||||
|
||||
try:
|
||||
scope, name, key_path, md, 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__
|
||||
|
||||
file_types = meta.get("file_types")
|
||||
allowed_exts: list[str] = []
|
||||
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__
|
||||
|
||||
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)
|
||||
errors: list[str] = []
|
||||
for file in files.values():
|
||||
filename = sanitize_filename(file.filename or "")
|
||||
if not filename:
|
||||
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}")
|
||||
continue
|
||||
|
||||
rel_path = f"files/{folder}/{filename}"
|
||||
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)
|
||||
|
||||
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_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 = 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(name)
|
||||
if not md:
|
||||
return Response().error(f"Plugin {name} not found").__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__
|
||||
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, "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
|
||||
|
||||
@@ -1130,8 +1358,14 @@ class ConfigRoute(Route):
|
||||
raise ValueError(f"插件 {plugin_name} 不存在")
|
||||
if not md.config:
|
||||
raise ValueError(f"插件 {plugin_name} 没有注册配置")
|
||||
assert md.config is not None
|
||||
|
||||
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}")
|
||||
md.config.save_config(post_configs)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Dashboard 路由工具集。
|
||||
|
||||
这里放一些 dashboard routes 可复用的小工具函数。
|
||||
|
||||
目前主要用于「配置文件上传(file 类型配置项)」功能:
|
||||
- 清洗/规范化用户可控的文件名与相对路径
|
||||
- 将配置 key 映射到配置项独立子目录
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
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)
|
||||
@@ -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) {
|
||||
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
|
||||
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="nested-container">
|
||||
<v-expand-transition>
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
|
||||
<AstrBotConfig
|
||||
:metadata="metadata[metadataKey].items"
|
||||
:iterable="iterable[key]"
|
||||
:metadataKey="key"
|
||||
:pluginName="pluginName"
|
||||
:pathPrefix="getItemPath(key)"
|
||||
>
|
||||
</AstrBotConfig>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
@@ -205,6 +223,8 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<ConfigItemRenderer
|
||||
v-model="iterable[key]"
|
||||
:item-meta="metadata[metadataKey].items[key] || null"
|
||||
:plugin-name="pluginName"
|
||||
:config-key="getItemPath(key)"
|
||||
:loading="loadingEmbeddingDim"
|
||||
:show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode"
|
||||
@get-embedding-dim="getEmbeddingDimensions(iterable)"
|
||||
@@ -249,6 +269,8 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
v-else
|
||||
v-model="iterable[metadataKey]"
|
||||
:item-meta="metadata[metadataKey]"
|
||||
:plugin-name="pluginName"
|
||||
:config-key="getItemPath(metadataKey)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -178,6 +178,16 @@
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<FileConfigItem
|
||||
v-else-if="itemMeta?.type === 'file'"
|
||||
:model-value="modelValue"
|
||||
:item-meta="itemMeta"
|
||||
:plugin-name="pluginName"
|
||||
:config-key="configKey"
|
||||
@update:model-value="emitUpdate"
|
||||
class="config-field"
|
||||
/>
|
||||
|
||||
<ListConfigItem
|
||||
v-else-if="itemMeta?.type === 'list'"
|
||||
:model-value="modelValue"
|
||||
@@ -208,6 +218,7 @@
|
||||
<script setup>
|
||||
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
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="file-config-item">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="dialog = true">
|
||||
{{ tm('fileUpload.button') }}
|
||||
</v-btn>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
{{ fileCountText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="700">
|
||||
<v-card class="file-dialog-card" variant="flat">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<span class="text-h3">{{ tm('fileUpload.dialogTitle') }}</span>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" variant="text" @click="dialog = false" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="file-dialog-body">
|
||||
<div v-if="mergedFileItems.length === 0" class="empty-text">
|
||||
{{ tm('fileUpload.empty') }}
|
||||
</div>
|
||||
|
||||
<v-list density="compact" lines="one">
|
||||
<v-list-item v-for="item in mergedFileItems" :key="item.path">
|
||||
<template #prepend>
|
||||
<v-icon size="18">mdi-file</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="file-name">
|
||||
{{ getDisplayName(item.path) }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<v-chip v-if="item.status !== 'ok'" size="x-small" :color="getStatusColor(item.status)"
|
||||
variant="tonal">
|
||||
{{ getStatusText(item.status) }}
|
||||
</v-chip>
|
||||
<v-btn v-if="item.status === 'unconfigured'" icon="mdi-plus" size="x-small" variant="text"
|
||||
@click="addToConfig(item.path)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||
@click="item.status === 'unconfigured' ? deletePhysicalFile(item.path) : deleteFile(item.path)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider v-if="mergedFileItems.length > 0" class="my-2" />
|
||||
|
||||
<v-list-item class="upload-item" :class="{ dragover: isDragging }" @drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="openFilePicker">
|
||||
<template #prepend>
|
||||
<v-icon size="18" color="primary">mdi-plus</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('fileUpload.dropzone') }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="allowedTypesText" class="upload-hint">
|
||||
{{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<input ref="fileInput" type="file" multiple hidden :accept="acceptAttr" @change="handleFileSelect" />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="file-dialog-actions">
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="elevated" @click="dialog = false">
|
||||
{{ tm('fileUpload.done') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from '@/utils/toast'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
itemMeta: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
pluginName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
configKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { tm } = useModuleI18n('features/config')
|
||||
const toast = useToast()
|
||||
|
||||
const dialog = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const loadingFiles = ref(false)
|
||||
const MAX_FILE_BYTES = 500 * 1024 * 1024
|
||||
const MAX_FILE_MB = 500
|
||||
const directoryFiles = ref([])
|
||||
|
||||
const fileList = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const mergedFileItems = computed(() => {
|
||||
const configured = new Set(fileList.value)
|
||||
const existing = new Set(directoryFiles.value)
|
||||
const items = []
|
||||
|
||||
for (const path of fileList.value) {
|
||||
items.push({
|
||||
path,
|
||||
status: existing.has(path) ? 'ok' : 'missing'
|
||||
})
|
||||
}
|
||||
|
||||
for (const path of directoryFiles.value) {
|
||||
if (!configured.has(path)) {
|
||||
items.push({
|
||||
path,
|
||||
status: 'unconfigured'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const acceptAttr = computed(() => {
|
||||
const types = props.itemMeta?.file_types
|
||||
if (!Array.isArray(types) || types.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return types
|
||||
.map((ext) => `.${String(ext).replace(/^\\./, '')}`)
|
||||
.join(',')
|
||||
})
|
||||
|
||||
const allowedTypesText = computed(() => {
|
||||
const types = props.itemMeta?.file_types
|
||||
if (!Array.isArray(types) || types.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return types.map((ext) => String(ext).replace(/^\\./, '')).join(', ')
|
||||
})
|
||||
|
||||
const fileCountText = computed(() => {
|
||||
return tm('fileUpload.fileCount', { count: fileList.value.length })
|
||||
})
|
||||
|
||||
const getStatusText = (status) => {
|
||||
if (status === 'missing') {
|
||||
return tm('fileUpload.statusMissing')
|
||||
}
|
||||
if (status === 'unconfigured') {
|
||||
return tm('fileUpload.statusUnconfigured')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (status === 'missing') {
|
||||
return 'error'
|
||||
}
|
||||
if (status === 'unconfigured') {
|
||||
return 'warning'
|
||||
}
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const loadDirectoryFiles = async () => {
|
||||
if (!props.pluginName || !props.configKey || loadingFiles.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingFiles.value = true
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/api/config/file/get?scope=plugin&name=${encodeURIComponent(
|
||||
props.pluginName
|
||||
)}&key=${encodeURIComponent(props.configKey)}`
|
||||
)
|
||||
if (response.data.status === 'ok') {
|
||||
const files = response.data.data?.files || []
|
||||
directoryFiles.value = Array.from(new Set(files))
|
||||
} else {
|
||||
toast.warning(response.data.message || tm('fileUpload.loadFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load file list failed:', error)
|
||||
toast.warning(tm('fileUpload.loadFailed'))
|
||||
} finally {
|
||||
loadingFiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const target = event.target
|
||||
if (target?.files && target.files.length > 0) {
|
||||
uploadFiles(Array.from(target.files))
|
||||
}
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event) => {
|
||||
isDragging.value = false
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
uploadFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
if (!props.pluginName || !props.configKey) {
|
||||
toast.warning('Missing plugin config info')
|
||||
return
|
||||
}
|
||||
if (uploading.value) {
|
||||
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()
|
||||
validFiles.forEach((file, index) => {
|
||||
formData.append(`file${index}`, file)
|
||||
})
|
||||
|
||||
const response = await axios.post(
|
||||
`/api/config/file/upload?scope=plugin&name=${encodeURIComponent(
|
||||
props.pluginName
|
||||
)}&key=${encodeURIComponent(props.configKey)}`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
const uploaded = response.data.data?.uploaded || []
|
||||
const errors = response.data.data?.errors || []
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
const merged = [...fileList.value]
|
||||
for (const path of uploaded) {
|
||||
if (!merged.includes(path)) {
|
||||
merged.push(path)
|
||||
}
|
||||
}
|
||||
fileList.value = merged
|
||||
const updatedDirectory = new Set(directoryFiles.value)
|
||||
uploaded.forEach((path) => updatedDirectory.add(path))
|
||||
directoryFiles.value = Array.from(updatedDirectory)
|
||||
toast.success(tm('fileUpload.uploadSuccess', { count: uploaded.length }))
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.warning(errors.join('\\n'))
|
||||
}
|
||||
} else {
|
||||
toast.error(response.data.message || tm('fileUpload.uploadFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error)
|
||||
toast.error(tm('fileUpload.uploadFailed'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addToConfig = (filePath) => {
|
||||
if (!fileList.value.includes(filePath)) {
|
||||
fileList.value = [...fileList.value, filePath]
|
||||
toast.success(tm('fileUpload.addToConfig'))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (filePath) => {
|
||||
fileList.value = fileList.value.filter((item) => item !== filePath)
|
||||
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
|
||||
|
||||
if (props.pluginName) {
|
||||
axios
|
||||
.post(
|
||||
`/api/config/file/delete?scope=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'))
|
||||
}
|
||||
|
||||
const deletePhysicalFile = (filePath) => {
|
||||
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
|
||||
|
||||
if (props.pluginName) {
|
||||
axios
|
||||
.post(
|
||||
`/api/config/file/delete?scope=plugin&name=${encodeURIComponent(
|
||||
props.pluginName
|
||||
)}`,
|
||||
{ path: filePath }
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('File delete failed:', error)
|
||||
toast.warning(tm('fileUpload.deleteFailed'))
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(tm('fileUpload.deleteSuccess'))
|
||||
}
|
||||
|
||||
const getDisplayName = (path) => {
|
||||
if (!path) return ''
|
||||
const parts = String(path).split('/')
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
watch(
|
||||
() => dialog.value,
|
||||
(value) => {
|
||||
if (value) {
|
||||
loadDirectoryFiles()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-config-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-dialog-card {
|
||||
height: 70vh;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.file-dialog-body {
|
||||
overflow-y: auto;
|
||||
max-height: calc(70vh - 120px);
|
||||
}
|
||||
|
||||
.file-dialog-actions {
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-item:hover,
|
||||
.upload-item.dragover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -89,6 +89,23 @@
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "Edit Configuration File"
|
||||
},
|
||||
"fileUpload": {
|
||||
"button": "Manage Files",
|
||||
"dialogTitle": "Uploaded Files",
|
||||
"dropzone": "Upload new file",
|
||||
"allowedTypes": "Allowed types: {types}",
|
||||
"empty": "No files uploaded",
|
||||
"statusMissing": "Missing file",
|
||||
"statusUnconfigured": "Not in config",
|
||||
"uploadSuccess": "Uploaded {count} files",
|
||||
"uploadFailed": "Upload failed",
|
||||
"loadFailed": "Failed to load file list",
|
||||
"fileTooLarge": "File too large (max {max} MB): {name}",
|
||||
"deleteSuccess": "Deleted file",
|
||||
"deleteFailed": "Delete failed",
|
||||
"addToConfig": "Added to config",
|
||||
"fileCount": "Files: {count}",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,5 +89,23 @@
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "编辑配置文件"
|
||||
},
|
||||
"fileUpload": {
|
||||
"button": "管理文件",
|
||||
"dialogTitle": "已上传文件",
|
||||
"dropzone": "上传新文件",
|
||||
"allowedTypes": "允许类型:{types}",
|
||||
"empty": "暂无已上传文件",
|
||||
"statusMissing": "文件缺失",
|
||||
"statusUnconfigured": "未加入配置",
|
||||
"uploadSuccess": "已上传 {count} 个文件",
|
||||
"uploadFailed": "上传失败",
|
||||
"loadFailed": "获取文件列表失败",
|
||||
"fileTooLarge": "文件过大(上限 {max} MB):{name}",
|
||||
"deleteSuccess": "已删除文件",
|
||||
"deleteFailed": "删除失败",
|
||||
"addToConfig": "已加入配置",
|
||||
"fileCount": "文件:{count}",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2149,19 +2149,22 @@ watch(isListView, (newVal) => {
|
||||
</v-row>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="configDialog" width="1000">
|
||||
<v-dialog v-model="configDialog" max-width="900">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{
|
||||
<v-card-title class="text-h2 pa-4 pl-6 pb-0">{{
|
||||
tm("dialogs.config.title")
|
||||
}}</v-card-title>
|
||||
<v-card-text>
|
||||
<AstrBotConfig
|
||||
v-if="extension_config.metadata"
|
||||
:metadata="extension_config.metadata"
|
||||
:iterable="extension_config.config"
|
||||
:metadataKey="curr_namespace"
|
||||
/>
|
||||
<p v-else>{{ tm("dialogs.config.noConfig") }}</p>
|
||||
<div style="max-height: 60vh; overflow-y: auto; padding-right: 8px">
|
||||
<AstrBotConfig
|
||||
v-if="extension_config.metadata"
|
||||
:metadata="extension_config.metadata"
|
||||
:iterable="extension_config.config"
|
||||
:metadataKey="curr_namespace"
|
||||
:pluginName="curr_namespace"
|
||||
/>
|
||||
<p v-else>{{ tm("dialogs.config.noConfig") }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
Reference in New Issue
Block a user