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.
- astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
- astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
- astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
- astrbot/builtin_stars/builtin_commands/main.py +0 -26
- 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_hooks.py +3 -1
- astrbot/core/astr_agent_run_util.py +243 -1
- 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 +90 -9
- astrbot/core/db/__init__.py +94 -1
- astrbot/core/db/po.py +46 -0
- astrbot/core/db/sqlite.py +248 -0
- astrbot/core/message/components.py +2 -2
- astrbot/core/persona_mgr.py +162 -2
- 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 +73 -6
- astrbot/core/pipeline/process_stage/utils.py +31 -4
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/pipeline/waking_check/stage.py +0 -1
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
- astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
- astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
- astrbot/core/provider/manager.py +38 -0
- astrbot/core/provider/provider.py +54 -0
- astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
- astrbot/core/provider/sources/gemini_source.py +12 -9
- astrbot/core/provider/sources/genie_tts.py +128 -0
- astrbot/core/provider/sources/openai_embedding_source.py +1 -1
- 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/context.py +9 -8
- astrbot/core/star/filter/command.py +1 -1
- astrbot/core/star/filter/custom_filter.py +2 -2
- astrbot/core/star/register/star_handler.py +2 -4
- 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/live_chat.py +423 -0
- astrbot/dashboard/routes/persona.py +265 -1
- astrbot/dashboard/routes/skills.py +148 -0
- astrbot/dashboard/routes/util.py +102 -0
- astrbot/dashboard/server.py +21 -5
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/RECORD +69 -63
- astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
- 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.3.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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)
|
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
|
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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()
|