AstrBot 4.12.3__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 (72) hide show
  1. astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
  2. astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
  3. astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
  4. astrbot/builtin_stars/builtin_commands/main.py +0 -26
  5. astrbot/cli/__init__.py +1 -1
  6. astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
  7. astrbot/core/agent/tool.py +61 -20
  8. astrbot/core/astr_agent_hooks.py +3 -1
  9. astrbot/core/astr_agent_run_util.py +243 -1
  10. astrbot/core/astr_agent_tool_exec.py +2 -2
  11. astrbot/core/{sandbox → computer}/booters/base.py +4 -4
  12. astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
  13. astrbot/core/computer/booters/local.py +234 -0
  14. astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
  15. astrbot/core/computer/computer_client.py +102 -0
  16. astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
  17. astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
  18. astrbot/core/computer/tools/python.py +94 -0
  19. astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
  20. astrbot/core/config/default.py +90 -9
  21. astrbot/core/db/__init__.py +94 -1
  22. astrbot/core/db/po.py +46 -0
  23. astrbot/core/db/sqlite.py +248 -0
  24. astrbot/core/message/components.py +2 -2
  25. astrbot/core/persona_mgr.py +162 -2
  26. astrbot/core/pipeline/context_utils.py +2 -2
  27. astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
  28. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +73 -6
  29. astrbot/core/pipeline/process_stage/utils.py +31 -4
  30. astrbot/core/pipeline/scheduler.py +1 -1
  31. astrbot/core/pipeline/waking_check/stage.py +0 -1
  32. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
  33. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
  34. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
  35. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
  36. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
  37. astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
  38. astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
  39. astrbot/core/provider/manager.py +38 -0
  40. astrbot/core/provider/provider.py +54 -0
  41. astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
  42. astrbot/core/provider/sources/gemini_source.py +12 -9
  43. astrbot/core/provider/sources/genie_tts.py +128 -0
  44. astrbot/core/provider/sources/openai_embedding_source.py +1 -1
  45. astrbot/core/skills/__init__.py +3 -0
  46. astrbot/core/skills/skill_manager.py +237 -0
  47. astrbot/core/star/command_management.py +1 -1
  48. astrbot/core/star/config.py +1 -1
  49. astrbot/core/star/context.py +9 -8
  50. astrbot/core/star/filter/command.py +1 -1
  51. astrbot/core/star/filter/custom_filter.py +2 -2
  52. astrbot/core/star/register/star_handler.py +2 -4
  53. astrbot/core/utils/astrbot_path.py +6 -0
  54. astrbot/dashboard/routes/__init__.py +2 -0
  55. astrbot/dashboard/routes/config.py +236 -2
  56. astrbot/dashboard/routes/live_chat.py +423 -0
  57. astrbot/dashboard/routes/persona.py +265 -1
  58. astrbot/dashboard/routes/skills.py +148 -0
  59. astrbot/dashboard/routes/util.py +102 -0
  60. astrbot/dashboard/server.py +21 -5
  61. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
  62. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/RECORD +69 -63
  63. astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
  64. astrbot/core/sandbox/sandbox_client.py +0 -52
  65. astrbot/core/sandbox/tools/python.py +0 -74
  66. /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
  67. /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
  68. /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
  69. /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
  70. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
  71. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
  72. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -23,6 +23,15 @@ class PersonaRoute(Route):
23
23
  "/persona/create": ("POST", self.create_persona),
24
24
  "/persona/update": ("POST", self.update_persona),
25
25
  "/persona/delete": ("POST", self.delete_persona),
26
+ "/persona/move": ("POST", self.move_persona),
27
+ "/persona/reorder": ("POST", self.reorder_items),
28
+ # Folder routes
29
+ "/persona/folder/list": ("GET", self.list_folders),
30
+ "/persona/folder/tree": ("GET", self.get_folder_tree),
31
+ "/persona/folder/detail": ("POST", self.get_folder_detail),
32
+ "/persona/folder/create": ("POST", self.create_folder),
33
+ "/persona/folder/update": ("POST", self.update_folder),
34
+ "/persona/folder/delete": ("POST", self.delete_folder),
26
35
  }
27
36
  self.db_helper = db_helper
28
37
  self.persona_mgr = core_lifecycle.persona_mgr
@@ -31,7 +40,14 @@ class PersonaRoute(Route):
31
40
  async def list_personas(self):
32
41
  """获取所有人格列表"""
33
42
  try:
34
- personas = await self.persona_mgr.get_all_personas()
43
+ # 支持按文件夹筛选
44
+ folder_id = request.args.get("folder_id")
45
+ if folder_id is not None:
46
+ personas = await self.persona_mgr.get_personas_by_folder(
47
+ folder_id if folder_id else None
48
+ )
49
+ else:
50
+ personas = await self.persona_mgr.get_all_personas()
35
51
  return (
36
52
  Response()
37
53
  .ok(
@@ -41,6 +57,9 @@ class PersonaRoute(Route):
41
57
  "system_prompt": persona.system_prompt,
42
58
  "begin_dialogs": persona.begin_dialogs or [],
43
59
  "tools": persona.tools,
60
+ "skills": persona.skills,
61
+ "folder_id": persona.folder_id,
62
+ "sort_order": persona.sort_order,
44
63
  "created_at": persona.created_at.isoformat()
45
64
  if persona.created_at
46
65
  else None,
@@ -78,6 +97,9 @@ class PersonaRoute(Route):
78
97
  "system_prompt": persona.system_prompt,
79
98
  "begin_dialogs": persona.begin_dialogs or [],
80
99
  "tools": persona.tools,
100
+ "skills": persona.skills,
101
+ "folder_id": persona.folder_id,
102
+ "sort_order": persona.sort_order,
81
103
  "created_at": persona.created_at.isoformat()
82
104
  if persona.created_at
83
105
  else None,
@@ -100,6 +122,9 @@ class PersonaRoute(Route):
100
122
  system_prompt = data.get("system_prompt", "").strip()
101
123
  begin_dialogs = data.get("begin_dialogs", [])
102
124
  tools = data.get("tools")
125
+ skills = data.get("skills")
126
+ folder_id = data.get("folder_id") # None 表示根目录
127
+ sort_order = data.get("sort_order", 0)
103
128
 
104
129
  if not persona_id:
105
130
  return Response().error("人格ID不能为空").__dict__
@@ -120,6 +145,9 @@ class PersonaRoute(Route):
120
145
  system_prompt=system_prompt,
121
146
  begin_dialogs=begin_dialogs if begin_dialogs else None,
122
147
  tools=tools if tools else None,
148
+ skills=skills if skills else None,
149
+ folder_id=folder_id,
150
+ sort_order=sort_order,
123
151
  )
124
152
 
125
153
  return (
@@ -132,6 +160,9 @@ class PersonaRoute(Route):
132
160
  "system_prompt": persona.system_prompt,
133
161
  "begin_dialogs": persona.begin_dialogs or [],
134
162
  "tools": persona.tools or [],
163
+ "skills": persona.skills or [],
164
+ "folder_id": persona.folder_id,
165
+ "sort_order": persona.sort_order,
135
166
  "created_at": persona.created_at.isoformat()
136
167
  if persona.created_at
137
168
  else None,
@@ -157,6 +188,7 @@ class PersonaRoute(Route):
157
188
  system_prompt = data.get("system_prompt")
158
189
  begin_dialogs = data.get("begin_dialogs")
159
190
  tools = data.get("tools")
191
+ skills = data.get("skills")
160
192
 
161
193
  if not persona_id:
162
194
  return Response().error("缺少必要参数: persona_id").__dict__
@@ -174,6 +206,7 @@ class PersonaRoute(Route):
174
206
  system_prompt=system_prompt,
175
207
  begin_dialogs=begin_dialogs,
176
208
  tools=tools,
209
+ skills=skills,
177
210
  )
178
211
 
179
212
  return Response().ok({"message": "人格更新成功"}).__dict__
@@ -200,3 +233,234 @@ class PersonaRoute(Route):
200
233
  except Exception as e:
201
234
  logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}")
202
235
  return Response().error(f"删除人格失败: {e!s}").__dict__
236
+
237
+ async def move_persona(self):
238
+ """移动人格到指定文件夹"""
239
+ try:
240
+ data = await request.get_json()
241
+ persona_id = data.get("persona_id")
242
+ folder_id = data.get("folder_id") # None 表示移动到根目录
243
+
244
+ if not persona_id:
245
+ return Response().error("缺少必要参数: persona_id").__dict__
246
+
247
+ await self.persona_mgr.move_persona_to_folder(persona_id, folder_id)
248
+
249
+ return Response().ok({"message": "人格移动成功"}).__dict__
250
+ except ValueError as e:
251
+ return Response().error(str(e)).__dict__
252
+ except Exception as e:
253
+ logger.error(f"移动人格失败: {e!s}\n{traceback.format_exc()}")
254
+ return Response().error(f"移动人格失败: {e!s}").__dict__
255
+
256
+ # ====
257
+ # Folder Routes
258
+ # ====
259
+
260
+ async def list_folders(self):
261
+ """获取文件夹列表"""
262
+ try:
263
+ parent_id = request.args.get("parent_id")
264
+ # 空字符串视为 None(根目录)
265
+ if parent_id == "":
266
+ parent_id = None
267
+ folders = await self.persona_mgr.get_folders(parent_id)
268
+ return (
269
+ Response()
270
+ .ok(
271
+ [
272
+ {
273
+ "folder_id": folder.folder_id,
274
+ "name": folder.name,
275
+ "parent_id": folder.parent_id,
276
+ "description": folder.description,
277
+ "sort_order": folder.sort_order,
278
+ "created_at": folder.created_at.isoformat()
279
+ if folder.created_at
280
+ else None,
281
+ "updated_at": folder.updated_at.isoformat()
282
+ if folder.updated_at
283
+ else None,
284
+ }
285
+ for folder in folders
286
+ ],
287
+ )
288
+ .__dict__
289
+ )
290
+ except Exception as e:
291
+ logger.error(f"获取文件夹列表失败: {e!s}\n{traceback.format_exc()}")
292
+ return Response().error(f"获取文件夹列表失败: {e!s}").__dict__
293
+
294
+ async def get_folder_tree(self):
295
+ """获取文件夹树形结构"""
296
+ try:
297
+ tree = await self.persona_mgr.get_folder_tree()
298
+ return Response().ok(tree).__dict__
299
+ except Exception as e:
300
+ logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}")
301
+ return Response().error(f"获取文件夹树失败: {e!s}").__dict__
302
+
303
+ async def get_folder_detail(self):
304
+ """获取指定文件夹的详细信息"""
305
+ try:
306
+ data = await request.get_json()
307
+ folder_id = data.get("folder_id")
308
+
309
+ if not folder_id:
310
+ return Response().error("缺少必要参数: folder_id").__dict__
311
+
312
+ folder = await self.persona_mgr.get_folder(folder_id)
313
+ if not folder:
314
+ return Response().error("文件夹不存在").__dict__
315
+
316
+ return (
317
+ Response()
318
+ .ok(
319
+ {
320
+ "folder_id": folder.folder_id,
321
+ "name": folder.name,
322
+ "parent_id": folder.parent_id,
323
+ "description": folder.description,
324
+ "sort_order": folder.sort_order,
325
+ "created_at": folder.created_at.isoformat()
326
+ if folder.created_at
327
+ else None,
328
+ "updated_at": folder.updated_at.isoformat()
329
+ if folder.updated_at
330
+ else None,
331
+ },
332
+ )
333
+ .__dict__
334
+ )
335
+ except Exception as e:
336
+ logger.error(f"获取文件夹详情失败: {e!s}\n{traceback.format_exc()}")
337
+ return Response().error(f"获取文件夹详情失败: {e!s}").__dict__
338
+
339
+ async def create_folder(self):
340
+ """创建文件夹"""
341
+ try:
342
+ data = await request.get_json()
343
+ name = data.get("name", "").strip()
344
+ parent_id = data.get("parent_id")
345
+ description = data.get("description")
346
+ sort_order = data.get("sort_order", 0)
347
+
348
+ if not name:
349
+ return Response().error("文件夹名称不能为空").__dict__
350
+
351
+ folder = await self.persona_mgr.create_folder(
352
+ name=name,
353
+ parent_id=parent_id,
354
+ description=description,
355
+ sort_order=sort_order,
356
+ )
357
+
358
+ return (
359
+ Response()
360
+ .ok(
361
+ {
362
+ "message": "文件夹创建成功",
363
+ "folder": {
364
+ "folder_id": folder.folder_id,
365
+ "name": folder.name,
366
+ "parent_id": folder.parent_id,
367
+ "description": folder.description,
368
+ "sort_order": folder.sort_order,
369
+ "created_at": folder.created_at.isoformat()
370
+ if folder.created_at
371
+ else None,
372
+ "updated_at": folder.updated_at.isoformat()
373
+ if folder.updated_at
374
+ else None,
375
+ },
376
+ },
377
+ )
378
+ .__dict__
379
+ )
380
+ except Exception as e:
381
+ logger.error(f"创建文件夹失败: {e!s}\n{traceback.format_exc()}")
382
+ return Response().error(f"创建文件夹失败: {e!s}").__dict__
383
+
384
+ async def update_folder(self):
385
+ """更新文件夹信息"""
386
+ try:
387
+ data = await request.get_json()
388
+ folder_id = data.get("folder_id")
389
+ name = data.get("name")
390
+ parent_id = data.get("parent_id")
391
+ description = data.get("description")
392
+ sort_order = data.get("sort_order")
393
+
394
+ if not folder_id:
395
+ return Response().error("缺少必要参数: folder_id").__dict__
396
+
397
+ await self.persona_mgr.update_folder(
398
+ folder_id=folder_id,
399
+ name=name,
400
+ parent_id=parent_id,
401
+ description=description,
402
+ sort_order=sort_order,
403
+ )
404
+
405
+ return Response().ok({"message": "文件夹更新成功"}).__dict__
406
+ except Exception as e:
407
+ logger.error(f"更新文件夹失败: {e!s}\n{traceback.format_exc()}")
408
+ return Response().error(f"更新文件夹失败: {e!s}").__dict__
409
+
410
+ async def delete_folder(self):
411
+ """删除文件夹"""
412
+ try:
413
+ data = await request.get_json()
414
+ folder_id = data.get("folder_id")
415
+
416
+ if not folder_id:
417
+ return Response().error("缺少必要参数: folder_id").__dict__
418
+
419
+ await self.persona_mgr.delete_folder(folder_id)
420
+
421
+ return Response().ok({"message": "文件夹删除成功"}).__dict__
422
+ except Exception as e:
423
+ logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}")
424
+ return Response().error(f"删除文件夹失败: {e!s}").__dict__
425
+
426
+ async def reorder_items(self):
427
+ """批量更新排序顺序
428
+
429
+ 请求体格式:
430
+ {
431
+ "items": [
432
+ {"id": "persona_id_1", "type": "persona", "sort_order": 0},
433
+ {"id": "persona_id_2", "type": "persona", "sort_order": 1},
434
+ {"id": "folder_id_1", "type": "folder", "sort_order": 0},
435
+ ...
436
+ ]
437
+ }
438
+ """
439
+ try:
440
+ data = await request.get_json()
441
+ items = data.get("items", [])
442
+
443
+ if not items:
444
+ return Response().error("items 不能为空").__dict__
445
+
446
+ # 验证每个 item 的格式
447
+ for item in items:
448
+ if not all(k in item for k in ("id", "type", "sort_order")):
449
+ return (
450
+ Response()
451
+ .error("每个 item 必须包含 id, type, sort_order 字段")
452
+ .__dict__
453
+ )
454
+ if item["type"] not in ("persona", "folder"):
455
+ return (
456
+ Response()
457
+ .error("type 字段必须是 'persona' 或 'folder'")
458
+ .__dict__
459
+ )
460
+
461
+ await self.persona_mgr.batch_update_sort_order(items)
462
+
463
+ return Response().ok({"message": "排序更新成功"}).__dict__
464
+ except Exception as e:
465
+ logger.error(f"更新排序失败: {e!s}\n{traceback.format_exc()}")
466
+ return Response().error(f"更新排序失败: {e!s}").__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
@@ -20,6 +22,7 @@ from astrbot.core.utils.io import get_local_ip_addresses
20
22
 
21
23
  from .routes import *
22
24
  from .routes.backup import BackupRoute
25
+ from .routes.live_chat import LiveChatRoute
23
26
  from .routes.platform import PlatformRoute
24
27
  from .routes.route import Response, RouteContext
25
28
  from .routes.session_management import SessionManagementRoute
@@ -76,6 +79,7 @@ class AstrBotDashboard:
76
79
  self.chat_route = ChatRoute(self.context, db, core_lifecycle)
77
80
  self.chatui_project_route = ChatUIProjectRoute(self.context, db)
78
81
  self.tools_root = ToolsRoute(self.context, core_lifecycle)
82
+ self.skills_route = SkillsRoute(self.context, core_lifecycle)
79
83
  self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
80
84
  self.file_route = FileRoute(self.context)
81
85
  self.session_management_route = SessionManagementRoute(
@@ -88,6 +92,7 @@ class AstrBotDashboard:
88
92
  self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
89
93
  self.platform_route = PlatformRoute(self.context, core_lifecycle)
90
94
  self.backup_route = BackupRoute(self.context, db, core_lifecycle)
95
+ self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
91
96
 
92
97
  self.app.add_url_rule(
93
98
  "/api/plug/<path:subpath>",
@@ -242,11 +247,22 @@ class AstrBotDashboard:
242
247
 
243
248
  logger.info(display)
244
249
 
245
- return self.app.run_task(
246
- host=host,
247
- port=port,
248
- shutdown_trigger=self.shutdown_trigger,
249
- )
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)
250
266
 
251
267
  async def shutdown_trigger(self):
252
268
  await self.shutdown_event.wait()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AstrBot
3
- Version: 4.12.3
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