AstrBot 4.12.4__py3-none-any.whl → 4.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
- astrbot/core/agent/tool.py +61 -20
- astrbot/core/astr_agent_tool_exec.py +2 -2
- astrbot/core/{sandbox → computer}/booters/base.py +4 -4
- astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
- astrbot/core/computer/booters/local.py +234 -0
- astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
- astrbot/core/computer/computer_client.py +102 -0
- astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
- astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
- astrbot/core/computer/tools/python.py +94 -0
- astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
- astrbot/core/config/default.py +61 -9
- astrbot/core/db/__init__.py +3 -0
- astrbot/core/db/po.py +4 -0
- astrbot/core/db/sqlite.py +19 -1
- astrbot/core/message/components.py +2 -2
- astrbot/core/persona_mgr.py +8 -0
- astrbot/core/pipeline/context_utils.py +2 -2
- astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +16 -2
- astrbot/core/pipeline/process_stage/utils.py +19 -4
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
- astrbot/core/provider/manager.py +31 -0
- astrbot/core/provider/sources/gemini_source.py +12 -9
- astrbot/core/skills/__init__.py +3 -0
- astrbot/core/skills/skill_manager.py +237 -0
- astrbot/core/star/command_management.py +1 -1
- astrbot/core/star/config.py +1 -1
- astrbot/core/star/filter/command.py +1 -1
- astrbot/core/star/filter/custom_filter.py +2 -2
- astrbot/core/star/register/star_handler.py +1 -1
- astrbot/core/utils/astrbot_path.py +6 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +236 -2
- astrbot/dashboard/routes/persona.py +7 -0
- astrbot/dashboard/routes/skills.py +148 -0
- astrbot/dashboard/routes/util.py +102 -0
- astrbot/dashboard/server.py +19 -5
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/RECORD +52 -47
- astrbot/core/sandbox/sandbox_client.py +0 -52
- astrbot/core/sandbox/tools/python.py +0 -74
- /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import inspect
|
|
3
3
|
import os
|
|
4
4
|
import traceback
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from quart import request
|
|
@@ -20,11 +21,22 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
|
20
21
|
from astrbot.core.platform.register import platform_cls_map, platform_registry
|
|
21
22
|
from astrbot.core.provider import Provider
|
|
22
23
|
from astrbot.core.provider.register import provider_registry
|
|
23
|
-
from astrbot.core.star.star import star_registry
|
|
24
|
+
from astrbot.core.star.star import StarMetadata, star_registry
|
|
25
|
+
from astrbot.core.utils.astrbot_path import (
|
|
26
|
+
get_astrbot_plugin_data_path,
|
|
27
|
+
)
|
|
24
28
|
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
|
25
29
|
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
|
26
30
|
|
|
27
31
|
from .route import Response, Route, RouteContext
|
|
32
|
+
from .util import (
|
|
33
|
+
config_key_to_folder,
|
|
34
|
+
get_schema_item,
|
|
35
|
+
normalize_rel_path,
|
|
36
|
+
sanitize_filename,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MAX_FILE_BYTES = 500 * 1024 * 1024
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
def try_cast(value: Any, type_: str):
|
|
@@ -106,6 +118,32 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
|
|
|
106
118
|
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
|
|
107
119
|
continue
|
|
108
120
|
|
|
121
|
+
if meta["type"] == "file":
|
|
122
|
+
if not _expect_type(value, list, f"{path}{key}", errors, "list"):
|
|
123
|
+
continue
|
|
124
|
+
for idx, item in enumerate(value):
|
|
125
|
+
if not isinstance(item, str):
|
|
126
|
+
errors.append(
|
|
127
|
+
f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
normalized = normalize_rel_path(item)
|
|
131
|
+
if not normalized or not normalized.startswith("files/"):
|
|
132
|
+
errors.append(
|
|
133
|
+
f"Invalid file path {path}{key}[{idx}]: {item}",
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
key_path = f"{path}{key}"
|
|
137
|
+
expected_folder = config_key_to_folder(key_path)
|
|
138
|
+
expected_prefix = f"files/{expected_folder}/"
|
|
139
|
+
if not normalized.startswith(expected_prefix):
|
|
140
|
+
errors.append(
|
|
141
|
+
f"Invalid file path {path}{key}[{idx}]: {item}",
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
value[idx] = normalized
|
|
145
|
+
continue
|
|
146
|
+
|
|
109
147
|
if meta["type"] == "list" and not isinstance(value, list):
|
|
110
148
|
errors.append(
|
|
111
149
|
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
|
|
@@ -218,6 +256,9 @@ class ConfigRoute(Route):
|
|
|
218
256
|
"/config/default": ("GET", self.get_default_config),
|
|
219
257
|
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
|
|
220
258
|
"/config/plugin/update": ("POST", self.post_plugin_configs),
|
|
259
|
+
"/config/file/upload": ("POST", self.upload_config_file),
|
|
260
|
+
"/config/file/delete": ("POST", self.delete_config_file),
|
|
261
|
+
"/config/file/get": ("GET", self.get_config_file_list),
|
|
221
262
|
"/config/platform/new": ("POST", self.post_new_platform),
|
|
222
263
|
"/config/platform/update": ("POST", self.post_update_platform),
|
|
223
264
|
"/config/platform/delete": ("POST", self.post_delete_platform),
|
|
@@ -876,6 +917,193 @@ class ConfigRoute(Route):
|
|
|
876
917
|
except Exception as e:
|
|
877
918
|
return Response().error(str(e)).__dict__
|
|
878
919
|
|
|
920
|
+
def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
|
|
921
|
+
for plugin_md in star_registry:
|
|
922
|
+
if plugin_md.name == plugin_name:
|
|
923
|
+
return plugin_md
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
def _resolve_config_file_scope(
|
|
927
|
+
self,
|
|
928
|
+
) -> tuple[str, str, str, StarMetadata, AstrBotConfig]:
|
|
929
|
+
"""将请求参数解析为一个明确的配置作用域。
|
|
930
|
+
|
|
931
|
+
当前支持的 scope:
|
|
932
|
+
- scope=plugin:name=<plugin_name>,key=<config_key_path>
|
|
933
|
+
"""
|
|
934
|
+
|
|
935
|
+
scope = request.args.get("scope") or "plugin"
|
|
936
|
+
name = request.args.get("name")
|
|
937
|
+
key_path = request.args.get("key")
|
|
938
|
+
|
|
939
|
+
if scope != "plugin":
|
|
940
|
+
raise ValueError(f"Unsupported scope: {scope}")
|
|
941
|
+
if not name or not key_path:
|
|
942
|
+
raise ValueError("Missing name or key parameter")
|
|
943
|
+
|
|
944
|
+
md = self._get_plugin_metadata_by_name(name)
|
|
945
|
+
if not md or not md.config:
|
|
946
|
+
raise ValueError(f"Plugin {name} not found or has no config")
|
|
947
|
+
|
|
948
|
+
return scope, name, key_path, md, md.config
|
|
949
|
+
|
|
950
|
+
async def upload_config_file(self):
|
|
951
|
+
"""上传文件到插件数据目录(用于某个 file 类型配置项)。"""
|
|
952
|
+
|
|
953
|
+
try:
|
|
954
|
+
scope, name, key_path, md, config = self._resolve_config_file_scope()
|
|
955
|
+
except ValueError as e:
|
|
956
|
+
return Response().error(str(e)).__dict__
|
|
957
|
+
|
|
958
|
+
meta = get_schema_item(getattr(config, "schema", None), key_path)
|
|
959
|
+
if not meta or meta.get("type") != "file":
|
|
960
|
+
return Response().error("Config item not found or not file type").__dict__
|
|
961
|
+
|
|
962
|
+
file_types = meta.get("file_types")
|
|
963
|
+
allowed_exts: list[str] = []
|
|
964
|
+
if isinstance(file_types, list):
|
|
965
|
+
allowed_exts = [
|
|
966
|
+
str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
files = await request.files
|
|
970
|
+
if not files:
|
|
971
|
+
return Response().error("No files uploaded").__dict__
|
|
972
|
+
|
|
973
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
974
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
975
|
+
try:
|
|
976
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
977
|
+
except ValueError:
|
|
978
|
+
return Response().error("Invalid name parameter").__dict__
|
|
979
|
+
plugin_root_path.mkdir(parents=True, exist_ok=True)
|
|
980
|
+
|
|
981
|
+
uploaded: list[str] = []
|
|
982
|
+
folder = config_key_to_folder(key_path)
|
|
983
|
+
errors: list[str] = []
|
|
984
|
+
for file in files.values():
|
|
985
|
+
filename = sanitize_filename(file.filename or "")
|
|
986
|
+
if not filename:
|
|
987
|
+
errors.append("Invalid filename")
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
file_size = getattr(file, "content_length", None)
|
|
991
|
+
if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
|
|
992
|
+
errors.append(f"File too large: {filename}")
|
|
993
|
+
continue
|
|
994
|
+
|
|
995
|
+
ext = os.path.splitext(filename)[1].lstrip(".").lower()
|
|
996
|
+
if allowed_exts and ext not in allowed_exts:
|
|
997
|
+
errors.append(f"Unsupported file type: {filename}")
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
rel_path = f"files/{folder}/{filename}"
|
|
1001
|
+
save_path = (plugin_root_path / rel_path).resolve(strict=False)
|
|
1002
|
+
try:
|
|
1003
|
+
save_path.relative_to(plugin_root_path)
|
|
1004
|
+
except ValueError:
|
|
1005
|
+
errors.append(f"Invalid path: {filename}")
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1009
|
+
await file.save(str(save_path))
|
|
1010
|
+
if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
|
|
1011
|
+
save_path.unlink()
|
|
1012
|
+
errors.append(f"File too large: {filename}")
|
|
1013
|
+
continue
|
|
1014
|
+
uploaded.append(rel_path)
|
|
1015
|
+
|
|
1016
|
+
if not uploaded:
|
|
1017
|
+
return (
|
|
1018
|
+
Response()
|
|
1019
|
+
.error(
|
|
1020
|
+
"Upload failed: " + ", ".join(errors)
|
|
1021
|
+
if errors
|
|
1022
|
+
else "Upload failed",
|
|
1023
|
+
)
|
|
1024
|
+
.__dict__
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__
|
|
1028
|
+
|
|
1029
|
+
async def delete_config_file(self):
|
|
1030
|
+
"""删除插件数据目录中的文件。"""
|
|
1031
|
+
|
|
1032
|
+
scope = request.args.get("scope") or "plugin"
|
|
1033
|
+
name = request.args.get("name")
|
|
1034
|
+
if not name:
|
|
1035
|
+
return Response().error("Missing name parameter").__dict__
|
|
1036
|
+
if scope != "plugin":
|
|
1037
|
+
return Response().error(f"Unsupported scope: {scope}").__dict__
|
|
1038
|
+
|
|
1039
|
+
data = await request.get_json()
|
|
1040
|
+
rel_path = data.get("path") if isinstance(data, dict) else None
|
|
1041
|
+
rel_path = normalize_rel_path(rel_path)
|
|
1042
|
+
if not rel_path or not rel_path.startswith("files/"):
|
|
1043
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1044
|
+
|
|
1045
|
+
md = self._get_plugin_metadata_by_name(name)
|
|
1046
|
+
if not md:
|
|
1047
|
+
return Response().error(f"Plugin {name} not found").__dict__
|
|
1048
|
+
|
|
1049
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
1050
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
1051
|
+
try:
|
|
1052
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
1053
|
+
except ValueError:
|
|
1054
|
+
return Response().error("Invalid name parameter").__dict__
|
|
1055
|
+
target_path = (plugin_root_path / rel_path).resolve(strict=False)
|
|
1056
|
+
try:
|
|
1057
|
+
target_path.relative_to(plugin_root_path)
|
|
1058
|
+
except ValueError:
|
|
1059
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1060
|
+
if target_path.is_file():
|
|
1061
|
+
target_path.unlink()
|
|
1062
|
+
|
|
1063
|
+
return Response().ok(None, "Deleted").__dict__
|
|
1064
|
+
|
|
1065
|
+
async def get_config_file_list(self):
|
|
1066
|
+
"""获取配置项对应目录下的文件列表。"""
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
_, name, key_path, _, config = self._resolve_config_file_scope()
|
|
1070
|
+
except ValueError as e:
|
|
1071
|
+
return Response().error(str(e)).__dict__
|
|
1072
|
+
|
|
1073
|
+
meta = get_schema_item(getattr(config, "schema", None), key_path)
|
|
1074
|
+
if not meta or meta.get("type") != "file":
|
|
1075
|
+
return Response().error("Config item not found or not file type").__dict__
|
|
1076
|
+
|
|
1077
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
1078
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
1079
|
+
try:
|
|
1080
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
1081
|
+
except ValueError:
|
|
1082
|
+
return Response().error("Invalid name parameter").__dict__
|
|
1083
|
+
|
|
1084
|
+
folder = config_key_to_folder(key_path)
|
|
1085
|
+
target_dir = (plugin_root_path / "files" / folder).resolve(strict=False)
|
|
1086
|
+
try:
|
|
1087
|
+
target_dir.relative_to(plugin_root_path)
|
|
1088
|
+
except ValueError:
|
|
1089
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1090
|
+
|
|
1091
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
1092
|
+
return Response().ok({"files": []}).__dict__
|
|
1093
|
+
|
|
1094
|
+
files: list[str] = []
|
|
1095
|
+
for path in target_dir.rglob("*"):
|
|
1096
|
+
if not path.is_file():
|
|
1097
|
+
continue
|
|
1098
|
+
try:
|
|
1099
|
+
rel_path = path.relative_to(plugin_root_path).as_posix()
|
|
1100
|
+
except ValueError:
|
|
1101
|
+
continue
|
|
1102
|
+
if rel_path.startswith("files/"):
|
|
1103
|
+
files.append(rel_path)
|
|
1104
|
+
|
|
1105
|
+
return Response().ok({"files": files}).__dict__
|
|
1106
|
+
|
|
879
1107
|
async def post_new_platform(self):
|
|
880
1108
|
new_platform_config = await request.json
|
|
881
1109
|
|
|
@@ -1130,8 +1358,14 @@ class ConfigRoute(Route):
|
|
|
1130
1358
|
raise ValueError(f"插件 {plugin_name} 不存在")
|
|
1131
1359
|
if not md.config:
|
|
1132
1360
|
raise ValueError(f"插件 {plugin_name} 没有注册配置")
|
|
1361
|
+
assert md.config is not None
|
|
1133
1362
|
|
|
1134
1363
|
try:
|
|
1135
|
-
|
|
1364
|
+
errors, post_configs = validate_config(
|
|
1365
|
+
post_configs, getattr(md.config, "schema", {}), is_core=False
|
|
1366
|
+
)
|
|
1367
|
+
if errors:
|
|
1368
|
+
raise ValueError(f"格式校验未通过: {errors}")
|
|
1369
|
+
md.config.save_config(post_configs)
|
|
1136
1370
|
except Exception as e:
|
|
1137
1371
|
raise e
|
|
@@ -57,6 +57,7 @@ class PersonaRoute(Route):
|
|
|
57
57
|
"system_prompt": persona.system_prompt,
|
|
58
58
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
59
59
|
"tools": persona.tools,
|
|
60
|
+
"skills": persona.skills,
|
|
60
61
|
"folder_id": persona.folder_id,
|
|
61
62
|
"sort_order": persona.sort_order,
|
|
62
63
|
"created_at": persona.created_at.isoformat()
|
|
@@ -96,6 +97,7 @@ class PersonaRoute(Route):
|
|
|
96
97
|
"system_prompt": persona.system_prompt,
|
|
97
98
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
98
99
|
"tools": persona.tools,
|
|
100
|
+
"skills": persona.skills,
|
|
99
101
|
"folder_id": persona.folder_id,
|
|
100
102
|
"sort_order": persona.sort_order,
|
|
101
103
|
"created_at": persona.created_at.isoformat()
|
|
@@ -120,6 +122,7 @@ class PersonaRoute(Route):
|
|
|
120
122
|
system_prompt = data.get("system_prompt", "").strip()
|
|
121
123
|
begin_dialogs = data.get("begin_dialogs", [])
|
|
122
124
|
tools = data.get("tools")
|
|
125
|
+
skills = data.get("skills")
|
|
123
126
|
folder_id = data.get("folder_id") # None 表示根目录
|
|
124
127
|
sort_order = data.get("sort_order", 0)
|
|
125
128
|
|
|
@@ -142,6 +145,7 @@ class PersonaRoute(Route):
|
|
|
142
145
|
system_prompt=system_prompt,
|
|
143
146
|
begin_dialogs=begin_dialogs if begin_dialogs else None,
|
|
144
147
|
tools=tools if tools else None,
|
|
148
|
+
skills=skills if skills else None,
|
|
145
149
|
folder_id=folder_id,
|
|
146
150
|
sort_order=sort_order,
|
|
147
151
|
)
|
|
@@ -156,6 +160,7 @@ class PersonaRoute(Route):
|
|
|
156
160
|
"system_prompt": persona.system_prompt,
|
|
157
161
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
158
162
|
"tools": persona.tools or [],
|
|
163
|
+
"skills": persona.skills or [],
|
|
159
164
|
"folder_id": persona.folder_id,
|
|
160
165
|
"sort_order": persona.sort_order,
|
|
161
166
|
"created_at": persona.created_at.isoformat()
|
|
@@ -183,6 +188,7 @@ class PersonaRoute(Route):
|
|
|
183
188
|
system_prompt = data.get("system_prompt")
|
|
184
189
|
begin_dialogs = data.get("begin_dialogs")
|
|
185
190
|
tools = data.get("tools")
|
|
191
|
+
skills = data.get("skills")
|
|
186
192
|
|
|
187
193
|
if not persona_id:
|
|
188
194
|
return Response().error("缺少必要参数: persona_id").__dict__
|
|
@@ -200,6 +206,7 @@ class PersonaRoute(Route):
|
|
|
200
206
|
system_prompt=system_prompt,
|
|
201
207
|
begin_dialogs=begin_dialogs,
|
|
202
208
|
tools=tools,
|
|
209
|
+
skills=skills,
|
|
203
210
|
)
|
|
204
211
|
|
|
205
212
|
return Response().ok({"message": "人格更新成功"}).__dict__
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
from quart import request
|
|
5
|
+
|
|
6
|
+
from astrbot.core import DEMO_MODE, logger
|
|
7
|
+
from astrbot.core.computer.computer_client import get_booter
|
|
8
|
+
from astrbot.core.skills.skill_manager import SkillManager
|
|
9
|
+
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
|
10
|
+
|
|
11
|
+
from .route import Response, Route, RouteContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SkillsRoute(Route):
|
|
15
|
+
def __init__(self, context: RouteContext, core_lifecycle) -> None:
|
|
16
|
+
super().__init__(context)
|
|
17
|
+
self.core_lifecycle = core_lifecycle
|
|
18
|
+
self.routes = {
|
|
19
|
+
"/skills": ("GET", self.get_skills),
|
|
20
|
+
"/skills/upload": ("POST", self.upload_skill),
|
|
21
|
+
"/skills/update": ("POST", self.update_skill),
|
|
22
|
+
"/skills/delete": ("POST", self.delete_skill),
|
|
23
|
+
}
|
|
24
|
+
self.register_routes()
|
|
25
|
+
|
|
26
|
+
async def get_skills(self):
|
|
27
|
+
try:
|
|
28
|
+
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
|
|
29
|
+
"skills", {}
|
|
30
|
+
)
|
|
31
|
+
runtime = cfg.get("runtime", "local")
|
|
32
|
+
skills = SkillManager().list_skills(
|
|
33
|
+
active_only=False, runtime=runtime, show_sandbox_path=False
|
|
34
|
+
)
|
|
35
|
+
return Response().ok([skill.__dict__ for skill in skills]).__dict__
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(traceback.format_exc())
|
|
38
|
+
return Response().error(str(e)).__dict__
|
|
39
|
+
|
|
40
|
+
async def upload_skill(self):
|
|
41
|
+
if DEMO_MODE:
|
|
42
|
+
return (
|
|
43
|
+
Response()
|
|
44
|
+
.error("You are not permitted to do this operation in demo mode")
|
|
45
|
+
.__dict__
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
temp_path = None
|
|
49
|
+
try:
|
|
50
|
+
files = await request.files
|
|
51
|
+
file = files.get("file")
|
|
52
|
+
if not file:
|
|
53
|
+
return Response().error("Missing file").__dict__
|
|
54
|
+
filename = os.path.basename(file.filename or "skill.zip")
|
|
55
|
+
if not filename.lower().endswith(".zip"):
|
|
56
|
+
return Response().error("Only .zip files are supported").__dict__
|
|
57
|
+
|
|
58
|
+
temp_dir = get_astrbot_temp_path()
|
|
59
|
+
os.makedirs(temp_dir, exist_ok=True)
|
|
60
|
+
temp_path = os.path.join(temp_dir, filename)
|
|
61
|
+
await file.save(temp_path)
|
|
62
|
+
|
|
63
|
+
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
|
|
64
|
+
"skills", {}
|
|
65
|
+
)
|
|
66
|
+
runtime = cfg.get("runtime", "local")
|
|
67
|
+
if runtime == "sandbox":
|
|
68
|
+
sandbox_enabled = (
|
|
69
|
+
self.core_lifecycle.astrbot_config.get("provider_settings", {})
|
|
70
|
+
.get("sandbox", {})
|
|
71
|
+
.get("enable", False)
|
|
72
|
+
)
|
|
73
|
+
if not sandbox_enabled:
|
|
74
|
+
return (
|
|
75
|
+
Response()
|
|
76
|
+
.error(
|
|
77
|
+
"Sandbox is not enabled. Please enable sandbox before using sandbox runtime."
|
|
78
|
+
)
|
|
79
|
+
.__dict__
|
|
80
|
+
)
|
|
81
|
+
skill_mgr = SkillManager()
|
|
82
|
+
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
|
|
83
|
+
|
|
84
|
+
if runtime == "sandbox":
|
|
85
|
+
sb = await get_booter(self.core_lifecycle.star_context, "skills-upload")
|
|
86
|
+
remote_root = "/home/shared/skills"
|
|
87
|
+
remote_zip = f"{remote_root}/{skill_name}.zip"
|
|
88
|
+
await sb.shell.exec(f"mkdir -p {remote_root}")
|
|
89
|
+
upload_result = await sb.upload_file(temp_path, remote_zip)
|
|
90
|
+
if not upload_result.get("success", False):
|
|
91
|
+
return (
|
|
92
|
+
Response().error("Failed to upload skill to sandbox").__dict__
|
|
93
|
+
)
|
|
94
|
+
await sb.shell.exec(
|
|
95
|
+
f"unzip -o {remote_zip} -d {remote_root} && rm -f {remote_zip}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
Response()
|
|
100
|
+
.ok({"name": skill_name}, "Skill uploaded successfully.")
|
|
101
|
+
.__dict__
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error(traceback.format_exc())
|
|
105
|
+
return Response().error(str(e)).__dict__
|
|
106
|
+
finally:
|
|
107
|
+
if temp_path and os.path.exists(temp_path):
|
|
108
|
+
try:
|
|
109
|
+
os.remove(temp_path)
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.warning(f"Failed to remove temp skill file: {temp_path}")
|
|
112
|
+
|
|
113
|
+
async def update_skill(self):
|
|
114
|
+
if DEMO_MODE:
|
|
115
|
+
return (
|
|
116
|
+
Response()
|
|
117
|
+
.error("You are not permitted to do this operation in demo mode")
|
|
118
|
+
.__dict__
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
data = await request.get_json()
|
|
122
|
+
name = data.get("name")
|
|
123
|
+
active = data.get("active", True)
|
|
124
|
+
if not name:
|
|
125
|
+
return Response().error("Missing skill name").__dict__
|
|
126
|
+
SkillManager().set_skill_active(name, bool(active))
|
|
127
|
+
return Response().ok({"name": name, "active": bool(active)}).__dict__
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(traceback.format_exc())
|
|
130
|
+
return Response().error(str(e)).__dict__
|
|
131
|
+
|
|
132
|
+
async def delete_skill(self):
|
|
133
|
+
if DEMO_MODE:
|
|
134
|
+
return (
|
|
135
|
+
Response()
|
|
136
|
+
.error("You are not permitted to do this operation in demo mode")
|
|
137
|
+
.__dict__
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
data = await request.get_json()
|
|
141
|
+
name = data.get("name")
|
|
142
|
+
if not name:
|
|
143
|
+
return Response().error("Missing skill name").__dict__
|
|
144
|
+
SkillManager().delete_skill(name)
|
|
145
|
+
return Response().ok({"name": name}).__dict__
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(traceback.format_exc())
|
|
148
|
+
return Response().error(str(e)).__dict__
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Dashboard 路由工具集。
|
|
2
|
+
|
|
3
|
+
这里放一些 dashboard routes 可复用的小工具函数。
|
|
4
|
+
|
|
5
|
+
目前主要用于「配置文件上传(file 类型配置项)」功能:
|
|
6
|
+
- 清洗/规范化用户可控的文件名与相对路径
|
|
7
|
+
- 将配置 key 映射到配置项独立子目录
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
|
|
14
|
+
"""按 dot-path 获取 schema 的节点。
|
|
15
|
+
|
|
16
|
+
同时支持:
|
|
17
|
+
- 扁平 schema(直接 key 命中)
|
|
18
|
+
- 嵌套 object schema({type: "object", items: {...}})
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
if not isinstance(schema, dict) or not key_path:
|
|
22
|
+
return None
|
|
23
|
+
if key_path in schema:
|
|
24
|
+
return schema.get(key_path)
|
|
25
|
+
|
|
26
|
+
current = schema
|
|
27
|
+
parts = key_path.split(".")
|
|
28
|
+
for idx, part in enumerate(parts):
|
|
29
|
+
if part not in current:
|
|
30
|
+
return None
|
|
31
|
+
meta = current.get(part)
|
|
32
|
+
if idx == len(parts) - 1:
|
|
33
|
+
return meta
|
|
34
|
+
if not isinstance(meta, dict) or meta.get("type") != "object":
|
|
35
|
+
return None
|
|
36
|
+
current = meta.get("items", {})
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sanitize_filename(name: str) -> str:
|
|
41
|
+
"""清洗上传文件名,避免路径穿越与非法名称。
|
|
42
|
+
|
|
43
|
+
- 丢弃目录部分,仅保留 basename
|
|
44
|
+
- 将路径分隔符替换为下划线
|
|
45
|
+
- 拒绝空字符串 / "." / ".."
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
cleaned = os.path.basename(name).strip()
|
|
49
|
+
if not cleaned or cleaned in {".", ".."}:
|
|
50
|
+
return ""
|
|
51
|
+
for sep in (os.sep, os.altsep):
|
|
52
|
+
if sep:
|
|
53
|
+
cleaned = cleaned.replace(sep, "_")
|
|
54
|
+
return cleaned
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sanitize_path_segment(segment: str) -> str:
|
|
58
|
+
"""清洗目录片段(URL/path 安全,避免穿越)。
|
|
59
|
+
|
|
60
|
+
仅保留 [A-Za-z0-9_-],其余替换为 "_"
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
cleaned = []
|
|
64
|
+
for ch in segment:
|
|
65
|
+
if (
|
|
66
|
+
("a" <= ch <= "z")
|
|
67
|
+
or ("A" <= ch <= "Z")
|
|
68
|
+
or ch.isdigit()
|
|
69
|
+
or ch
|
|
70
|
+
in {
|
|
71
|
+
"-",
|
|
72
|
+
"_",
|
|
73
|
+
}
|
|
74
|
+
):
|
|
75
|
+
cleaned.append(ch)
|
|
76
|
+
else:
|
|
77
|
+
cleaned.append("_")
|
|
78
|
+
result = "".join(cleaned).strip("_")
|
|
79
|
+
return result or "_"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def config_key_to_folder(key_path: str) -> str:
|
|
83
|
+
"""将 dot-path 的配置 key 转成稳定的文件夹路径。"""
|
|
84
|
+
|
|
85
|
+
parts = [sanitize_path_segment(p) for p in key_path.split(".") if p]
|
|
86
|
+
return "/".join(parts) if parts else "_"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_rel_path(rel_path: str | None) -> str | None:
|
|
90
|
+
"""规范化用户传入的相对路径,并阻止路径穿越。"""
|
|
91
|
+
|
|
92
|
+
if not isinstance(rel_path, str):
|
|
93
|
+
return None
|
|
94
|
+
rel = rel_path.replace("\\", "/").lstrip("/")
|
|
95
|
+
if not rel:
|
|
96
|
+
return None
|
|
97
|
+
parts = [p for p in rel.split("/") if p]
|
|
98
|
+
if any(part in {".", ".."} for part in parts):
|
|
99
|
+
return None
|
|
100
|
+
if rel.startswith("../") or "/../" in rel:
|
|
101
|
+
return None
|
|
102
|
+
return "/".join(parts)
|
astrbot/dashboard/server.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import cast
|
|
|
7
7
|
import jwt
|
|
8
8
|
import psutil
|
|
9
9
|
from flask.json.provider import DefaultJSONProvider
|
|
10
|
+
from hypercorn.asyncio import serve
|
|
11
|
+
from hypercorn.config import Config as HyperConfig
|
|
10
12
|
from psutil._common import addr as psutil_addr
|
|
11
13
|
from quart import Quart, g, jsonify, request
|
|
12
14
|
from quart.logging import default_handler
|
|
@@ -77,6 +79,7 @@ class AstrBotDashboard:
|
|
|
77
79
|
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
|
78
80
|
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
|
79
81
|
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
|
82
|
+
self.skills_route = SkillsRoute(self.context, core_lifecycle)
|
|
80
83
|
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
|
81
84
|
self.file_route = FileRoute(self.context)
|
|
82
85
|
self.session_management_route = SessionManagementRoute(
|
|
@@ -244,11 +247,22 @@ class AstrBotDashboard:
|
|
|
244
247
|
|
|
245
248
|
logger.info(display)
|
|
246
249
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
# 配置 Hypercorn
|
|
251
|
+
config = HyperConfig()
|
|
252
|
+
config.bind = [f"{host}:{port}"]
|
|
253
|
+
|
|
254
|
+
# 根据配置决定是否禁用访问日志
|
|
255
|
+
disable_access_log = self.core_lifecycle.astrbot_config.get(
|
|
256
|
+
"dashboard", {}
|
|
257
|
+
).get("disable_access_log", True)
|
|
258
|
+
if disable_access_log:
|
|
259
|
+
config.accesslog = None
|
|
260
|
+
else:
|
|
261
|
+
# 启用访问日志,使用简洁格式
|
|
262
|
+
config.accesslog = "-"
|
|
263
|
+
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
|
|
264
|
+
|
|
265
|
+
return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
|
|
252
266
|
|
|
253
267
|
async def shutdown_trigger(self):
|
|
254
268
|
await self.shutdown_event.wait()
|