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.
Files changed (54) hide show
  1. astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
  2. astrbot/cli/__init__.py +1 -1
  3. astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
  4. astrbot/core/agent/tool.py +61 -20
  5. astrbot/core/astr_agent_tool_exec.py +2 -2
  6. astrbot/core/{sandbox → computer}/booters/base.py +4 -4
  7. astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
  8. astrbot/core/computer/booters/local.py +234 -0
  9. astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
  10. astrbot/core/computer/computer_client.py +102 -0
  11. astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
  12. astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
  13. astrbot/core/computer/tools/python.py +94 -0
  14. astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
  15. astrbot/core/config/default.py +61 -9
  16. astrbot/core/db/__init__.py +3 -0
  17. astrbot/core/db/po.py +4 -0
  18. astrbot/core/db/sqlite.py +19 -1
  19. astrbot/core/message/components.py +2 -2
  20. astrbot/core/persona_mgr.py +8 -0
  21. astrbot/core/pipeline/context_utils.py +2 -2
  22. astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
  23. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +16 -2
  24. astrbot/core/pipeline/process_stage/utils.py +19 -4
  25. astrbot/core/pipeline/scheduler.py +1 -1
  26. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
  27. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
  28. astrbot/core/provider/manager.py +31 -0
  29. astrbot/core/provider/sources/gemini_source.py +12 -9
  30. astrbot/core/skills/__init__.py +3 -0
  31. astrbot/core/skills/skill_manager.py +237 -0
  32. astrbot/core/star/command_management.py +1 -1
  33. astrbot/core/star/config.py +1 -1
  34. astrbot/core/star/filter/command.py +1 -1
  35. astrbot/core/star/filter/custom_filter.py +2 -2
  36. astrbot/core/star/register/star_handler.py +1 -1
  37. astrbot/core/utils/astrbot_path.py +6 -0
  38. astrbot/dashboard/routes/__init__.py +2 -0
  39. astrbot/dashboard/routes/config.py +236 -2
  40. astrbot/dashboard/routes/persona.py +7 -0
  41. astrbot/dashboard/routes/skills.py +148 -0
  42. astrbot/dashboard/routes/util.py +102 -0
  43. astrbot/dashboard/server.py +19 -5
  44. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
  45. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/RECORD +52 -47
  46. astrbot/core/sandbox/sandbox_client.py +0 -52
  47. astrbot/core/sandbox/tools/python.py +0 -74
  48. /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
  49. /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
  50. /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
  51. /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
  52. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
  53. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
  54. {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
- save_config(post_configs, md.config)
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)
@@ -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
- return self.app.run_task(
248
- host=host,
249
- port=port,
250
- shutdown_trigger=self.shutdown_trigger,
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AstrBot
3
- Version: 4.12.4
3
+ Version: 4.13.0
4
4
  Summary: Easy-to-use multi-platform LLM chatbot and development framework
5
5
  License-File: LICENSE
6
6
  Keywords: Astrbot,Astrbot Module,Astrbot Plugin