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
@@ -0,0 +1,102 @@
1
+ import os
2
+ import shutil
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from astrbot.api import logger
7
+ from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
8
+ from astrbot.core.star.context import Context
9
+ from astrbot.core.utils.astrbot_path import (
10
+ get_astrbot_skills_path,
11
+ get_astrbot_temp_path,
12
+ )
13
+
14
+ from .booters.base import ComputerBooter
15
+ from .booters.local import LocalBooter
16
+
17
+ session_booter: dict[str, ComputerBooter] = {}
18
+ local_booter: ComputerBooter | None = None
19
+
20
+
21
+ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
22
+ skills_root = get_astrbot_skills_path()
23
+ if not os.path.isdir(skills_root):
24
+ return
25
+ if not any(Path(skills_root).iterdir()):
26
+ return
27
+
28
+ temp_dir = get_astrbot_temp_path()
29
+ os.makedirs(temp_dir, exist_ok=True)
30
+ zip_base = os.path.join(temp_dir, "skills_bundle")
31
+ zip_path = f"{zip_base}.zip"
32
+
33
+ try:
34
+ if os.path.exists(zip_path):
35
+ os.remove(zip_path)
36
+ shutil.make_archive(zip_base, "zip", skills_root)
37
+ remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
38
+ await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
39
+ upload_result = await booter.upload_file(zip_path, str(remote_zip))
40
+ if not upload_result.get("success", False):
41
+ raise RuntimeError("Failed to upload skills bundle to sandbox.")
42
+ await booter.shell.exec(
43
+ f"unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} && rm -f {remote_zip}"
44
+ )
45
+ finally:
46
+ if os.path.exists(zip_path):
47
+ try:
48
+ os.remove(zip_path)
49
+ except Exception:
50
+ logger.warning(f"Failed to remove temp skills zip: {zip_path}")
51
+
52
+
53
+ async def get_booter(
54
+ context: Context,
55
+ session_id: str,
56
+ ) -> ComputerBooter:
57
+ config = context.get_config(umo=session_id)
58
+
59
+ sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
60
+ booter_type = sandbox_cfg.get("booter", "shipyard")
61
+
62
+ if session_id in session_booter:
63
+ booter = session_booter[session_id]
64
+ if not await booter.available():
65
+ # rebuild
66
+ session_booter.pop(session_id, None)
67
+ if session_id not in session_booter:
68
+ uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
69
+ if booter_type == "shipyard":
70
+ from .booters.shipyard import ShipyardBooter
71
+
72
+ ep = sandbox_cfg.get("shipyard_endpoint", "")
73
+ token = sandbox_cfg.get("shipyard_access_token", "")
74
+ ttl = sandbox_cfg.get("shipyard_ttl", 3600)
75
+ max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
76
+
77
+ client = ShipyardBooter(
78
+ endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
79
+ )
80
+ elif booter_type == "boxlite":
81
+ from .booters.boxlite import BoxliteBooter
82
+
83
+ client = BoxliteBooter()
84
+ else:
85
+ raise ValueError(f"Unknown booter type: {booter_type}")
86
+
87
+ try:
88
+ await client.boot(uuid_str)
89
+ await _sync_skills_to_sandbox(client)
90
+ except Exception as e:
91
+ logger.error(f"Error booting sandbox for session {session_id}: {e}")
92
+ raise e
93
+
94
+ session_booter[session_id] = client
95
+ return session_booter[session_id]
96
+
97
+
98
+ def get_local_booter() -> ComputerBooter:
99
+ global local_booter
100
+ if local_booter is None:
101
+ local_booter = LocalBooter()
102
+ return local_booter
@@ -1,10 +1,11 @@
1
1
  from .fs import FileDownloadTool, FileUploadTool
2
- from .python import PythonTool
2
+ from .python import LocalPythonTool, PythonTool
3
3
  from .shell import ExecuteShellTool
4
4
 
5
5
  __all__ = [
6
6
  "FileUploadTool",
7
7
  "PythonTool",
8
+ "LocalPythonTool",
8
9
  "ExecuteShellTool",
9
10
  "FileDownloadTool",
10
11
  ]
@@ -9,7 +9,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
9
9
  from astrbot.core.message.components import File
10
10
  from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
11
11
 
12
- from ..sandbox_client import get_booter
12
+ from ..computer_client import get_booter
13
13
 
14
14
  # @dataclass
15
15
  # class CreateFileTool(FunctionTool):
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ import mcp
4
+
5
+ from astrbot.api import FunctionTool
6
+ from astrbot.core.agent.run_context import ContextWrapper
7
+ from astrbot.core.agent.tool import ToolExecResult
8
+ from astrbot.core.astr_agent_context import AstrAgentContext
9
+ from astrbot.core.computer.computer_client import get_booter, get_local_booter
10
+
11
+ param_schema = {
12
+ "type": "object",
13
+ "properties": {
14
+ "code": {
15
+ "type": "string",
16
+ "description": "The Python code to execute.",
17
+ },
18
+ "silent": {
19
+ "type": "boolean",
20
+ "description": "Whether to suppress the output of the code execution.",
21
+ "default": False,
22
+ },
23
+ },
24
+ "required": ["code"],
25
+ }
26
+
27
+
28
+ def handle_result(result: dict) -> ToolExecResult:
29
+ data = result.get("data", {})
30
+ output = data.get("output", {})
31
+ error = data.get("error", "")
32
+ images: list[dict] = output.get("images", [])
33
+ text: str = output.get("text", "")
34
+
35
+ resp = mcp.types.CallToolResult(content=[])
36
+
37
+ if error:
38
+ resp.content.append(mcp.types.TextContent(type="text", text=f"error: {error}"))
39
+
40
+ if images:
41
+ for img in images:
42
+ resp.content.append(
43
+ mcp.types.ImageContent(
44
+ type="image", data=img["image/png"], mimeType="image/png"
45
+ )
46
+ )
47
+ if text:
48
+ resp.content.append(mcp.types.TextContent(type="text", text=text))
49
+
50
+ if not resp.content:
51
+ resp.content.append(mcp.types.TextContent(type="text", text="No output."))
52
+
53
+ return resp
54
+
55
+
56
+ @dataclass
57
+ class PythonTool(FunctionTool):
58
+ name: str = "astrbot_execute_ipython"
59
+ description: str = "Run codes in an IPython shell."
60
+ parameters: dict = field(default_factory=lambda: param_schema)
61
+
62
+ async def call(
63
+ self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
64
+ ) -> ToolExecResult:
65
+ sb = await get_booter(
66
+ context.context.context,
67
+ context.context.event.unified_msg_origin,
68
+ )
69
+ try:
70
+ result = await sb.python.exec(code, silent=silent)
71
+ return handle_result(result)
72
+ except Exception as e:
73
+ return f"Error executing code: {str(e)}"
74
+
75
+
76
+ @dataclass
77
+ class LocalPythonTool(FunctionTool):
78
+ name: str = "astrbot_execute_python"
79
+ description: str = "Execute codes in a Python environment."
80
+
81
+ parameters: dict = field(default_factory=lambda: param_schema)
82
+
83
+ async def call(
84
+ self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
85
+ ) -> ToolExecResult:
86
+ if context.context.event.role != "admin":
87
+ return "error: Permission denied. Local Python execution is only allowed for admin users. Set admins in AstrBot WebUI."
88
+
89
+ sb = get_local_booter()
90
+ try:
91
+ result = await sb.python.exec(code, silent=silent)
92
+ return handle_result(result)
93
+ except Exception as e:
94
+ return f"Error executing code: {str(e)}"
@@ -6,7 +6,7 @@ from astrbot.core.agent.run_context import ContextWrapper
6
6
  from astrbot.core.agent.tool import ToolExecResult
7
7
  from astrbot.core.astr_agent_context import AstrAgentContext
8
8
 
9
- from ..sandbox_client import get_booter
9
+ from ..computer_client import get_booter, get_local_booter
10
10
 
11
11
 
12
12
  @dataclass
@@ -37,6 +37,8 @@ class ExecuteShellTool(FunctionTool):
37
37
  }
38
38
  )
39
39
 
40
+ is_local: bool = False
41
+
40
42
  async def call(
41
43
  self,
42
44
  context: ContextWrapper[AstrAgentContext],
@@ -44,10 +46,16 @@ class ExecuteShellTool(FunctionTool):
44
46
  background: bool = False,
45
47
  env: dict = {},
46
48
  ) -> ToolExecResult:
47
- sb = await get_booter(
48
- context.context.context,
49
- context.context.event.unified_msg_origin,
50
- )
49
+ if context.context.event.role != "admin":
50
+ return "error: Permission denied. Shell execution is only allowed for admin users. Set admins in AstrBot WebUI."
51
+
52
+ if self.is_local:
53
+ sb = get_local_booter()
54
+ else:
55
+ sb = await get_booter(
56
+ context.context.context,
57
+ context.context.event.unified_msg_origin,
58
+ )
51
59
  try:
52
60
  result = await sb.shell.exec(command, background=background, env=env)
53
61
  return json.dumps(result)
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
5
5
 
6
6
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
7
7
 
8
- VERSION = "4.12.4"
8
+ VERSION = "4.13.0"
9
9
  DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
10
10
 
11
11
  WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -106,6 +106,7 @@ DEFAULT_CONFIG = {
106
106
  "reachability_check": False,
107
107
  "max_agent_step": 30,
108
108
  "tool_call_timeout": 60,
109
+ "tool_schema_mode": "full",
109
110
  "llm_safety_mode": True,
110
111
  "safety_mode_strategy": "system_prompt", # TODO: llm judge
111
112
  "file_extract": {
@@ -121,6 +122,7 @@ DEFAULT_CONFIG = {
121
122
  "shipyard_ttl": 3600,
122
123
  "shipyard_max_sessions": 10,
123
124
  },
125
+ "skills": {"runtime": "sandbox"},
124
126
  },
125
127
  "provider_stt_settings": {
126
128
  "enable": False,
@@ -166,6 +168,7 @@ DEFAULT_CONFIG = {
166
168
  "jwt_secret": "",
167
169
  "host": "0.0.0.0",
168
170
  "port": 6185,
171
+ "disable_access_log": True,
169
172
  },
170
173
  "platform": [],
171
174
  "platform_specific": {
@@ -773,27 +776,21 @@ CONFIG_METADATA_2 = {
773
776
  "interval_method": {
774
777
  "type": "string",
775
778
  "options": ["random", "log"],
776
- "hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$,x为字数,y的单位为秒。",
777
779
  },
778
780
  "interval": {
779
781
  "type": "string",
780
- "hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
781
782
  },
782
783
  "log_base": {
783
784
  "type": "float",
784
- "hint": "`log` 方法用。对数函数的底数。默认为 2.6",
785
785
  },
786
786
  "words_count_threshold": {
787
787
  "type": "int",
788
- "hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
789
788
  },
790
789
  "regex": {
791
790
  "type": "string",
792
- "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'<regex>', text)",
793
791
  },
794
792
  "content_cleanup_rule": {
795
793
  "type": "string",
796
- "hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'<regex>', '', text)",
797
794
  },
798
795
  },
799
796
  },
@@ -2187,6 +2184,9 @@ CONFIG_METADATA_2 = {
2187
2184
  "tool_call_timeout": {
2188
2185
  "type": "int",
2189
2186
  },
2187
+ "tool_schema_mode": {
2188
+ "type": "string",
2189
+ },
2190
2190
  "file_extract": {
2191
2191
  "type": "object",
2192
2192
  "items": {
@@ -2201,6 +2201,17 @@ CONFIG_METADATA_2 = {
2201
2201
  },
2202
2202
  },
2203
2203
  },
2204
+ "skills": {
2205
+ "type": "object",
2206
+ "items": {
2207
+ "enable": {
2208
+ "type": "bool",
2209
+ },
2210
+ "runtime": {
2211
+ "type": "string",
2212
+ },
2213
+ },
2214
+ },
2204
2215
  },
2205
2216
  },
2206
2217
  "provider_stt_settings": {
@@ -2578,6 +2589,7 @@ CONFIG_METADATA_3 = {
2578
2589
  # },
2579
2590
  "sandbox": {
2580
2591
  "description": "Agent 沙箱环境",
2592
+ "hint": "",
2581
2593
  "type": "object",
2582
2594
  "items": {
2583
2595
  "provider_settings.sandbox.enable": {
@@ -2589,6 +2601,7 @@ CONFIG_METADATA_3 = {
2589
2601
  "description": "沙箱环境驱动器",
2590
2602
  "type": "string",
2591
2603
  "options": ["shipyard"],
2604
+ "labels": ["Shipyard"],
2592
2605
  "condition": {
2593
2606
  "provider_settings.sandbox.enable": True,
2594
2607
  },
@@ -2631,6 +2644,27 @@ CONFIG_METADATA_3 = {
2631
2644
  },
2632
2645
  },
2633
2646
  },
2647
+ "condition": {
2648
+ "provider_settings.agent_runner_type": "local",
2649
+ "provider_settings.enable": True,
2650
+ },
2651
+ },
2652
+ "skills": {
2653
+ "description": "Skills",
2654
+ "type": "object",
2655
+ "items": {
2656
+ "provider_settings.skills.runtime": {
2657
+ "description": "Skill Runtime",
2658
+ "type": "string",
2659
+ "options": ["local", "sandbox"],
2660
+ "labels": ["本地", "沙箱"],
2661
+ "hint": "选择 Skills 运行环境。使用沙箱时需先启用沙箱环境。",
2662
+ },
2663
+ },
2664
+ "condition": {
2665
+ "provider_settings.agent_runner_type": "local",
2666
+ "provider_settings.enable": True,
2667
+ },
2634
2668
  },
2635
2669
  "truncate_and_compress": {
2636
2670
  "description": "上下文管理策略",
@@ -2691,6 +2725,10 @@ CONFIG_METADATA_3 = {
2691
2725
  },
2692
2726
  },
2693
2727
  },
2728
+ "condition": {
2729
+ "provider_settings.agent_runner_type": "local",
2730
+ "provider_settings.enable": True,
2731
+ },
2694
2732
  },
2695
2733
  "others": {
2696
2734
  "description": "其他配置",
@@ -2778,6 +2816,16 @@ CONFIG_METADATA_3 = {
2778
2816
  "provider_settings.agent_runner_type": "local",
2779
2817
  },
2780
2818
  },
2819
+ "provider_settings.tool_schema_mode": {
2820
+ "description": "工具调用模式",
2821
+ "type": "string",
2822
+ "options": ["skills_like", "full"],
2823
+ "labels": ["Skills-like(两阶段)", "Full(完整参数)"],
2824
+ "hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
2825
+ "condition": {
2826
+ "provider_settings.agent_runner_type": "local",
2827
+ },
2828
+ },
2781
2829
  "provider_settings.wake_prefix": {
2782
2830
  "description": "LLM 聊天额外唤醒前缀 ",
2783
2831
  "type": "string",
@@ -3045,7 +3093,8 @@ CONFIG_METADATA_3 = {
3045
3093
  "type": "bool",
3046
3094
  },
3047
3095
  "platform_settings.segmented_reply.interval_method": {
3048
- "description": "间隔方法",
3096
+ "description": "间隔方法。",
3097
+ "hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$,x为字数,y的单位为秒。",
3049
3098
  "type": "string",
3050
3099
  "options": ["random", "log"],
3051
3100
  },
@@ -3060,13 +3109,14 @@ CONFIG_METADATA_3 = {
3060
3109
  "platform_settings.segmented_reply.log_base": {
3061
3110
  "description": "对数底数",
3062
3111
  "type": "float",
3063
- "hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。",
3112
+ "hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。",
3064
3113
  "condition": {
3065
3114
  "platform_settings.segmented_reply.interval_method": "log",
3066
3115
  },
3067
3116
  },
3068
3117
  "platform_settings.segmented_reply.words_count_threshold": {
3069
3118
  "description": "分段回复字数阈值",
3119
+ "hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
3070
3120
  "type": "int",
3071
3121
  },
3072
3122
  "platform_settings.segmented_reply.split_mode": {
@@ -3077,6 +3127,7 @@ CONFIG_METADATA_3 = {
3077
3127
  },
3078
3128
  "platform_settings.segmented_reply.regex": {
3079
3129
  "description": "分段正则表达式",
3130
+ "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
3080
3131
  "type": "string",
3081
3132
  "condition": {
3082
3133
  "platform_settings.segmented_reply.split_mode": "regex",
@@ -3246,6 +3297,7 @@ DEFAULT_VALUE_MAP = {
3246
3297
  "string": "",
3247
3298
  "text": "",
3248
3299
  "list": [],
3300
+ "file": [],
3249
3301
  "object": {},
3250
3302
  "template_list": [],
3251
3303
  }
@@ -254,6 +254,7 @@ class BaseDatabase(abc.ABC):
254
254
  system_prompt: str,
255
255
  begin_dialogs: list[str] | None = None,
256
256
  tools: list[str] | None = None,
257
+ skills: list[str] | None = None,
257
258
  folder_id: str | None = None,
258
259
  sort_order: int = 0,
259
260
  ) -> Persona:
@@ -264,6 +265,7 @@ class BaseDatabase(abc.ABC):
264
265
  system_prompt: System prompt for the persona
265
266
  begin_dialogs: Optional list of initial dialog strings
266
267
  tools: Optional list of tool names (None means all tools, [] means no tools)
268
+ skills: Optional list of skill names (None means all skills, [] means no skills)
267
269
  folder_id: Optional folder ID to place the persona in (None means root)
268
270
  sort_order: Sort order within the folder (default 0)
269
271
  """
@@ -286,6 +288,7 @@ class BaseDatabase(abc.ABC):
286
288
  system_prompt: str | None = None,
287
289
  begin_dialogs: list[str] | None = None,
288
290
  tools: list[str] | None = None,
291
+ skills: list[str] | None = None,
289
292
  ) -> Persona | None:
290
293
  """Update a persona's system prompt or begin dialogs."""
291
294
  ...
astrbot/core/db/po.py CHANGED
@@ -125,6 +125,8 @@ class Persona(SQLModel, table=True):
125
125
  """a list of strings, each representing a dialog to start with"""
126
126
  tools: list | None = Field(default=None, sa_type=JSON)
127
127
  """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
128
+ skills: list | None = Field(default=None, sa_type=JSON)
129
+ """None means use ALL skills for default, empty list means no skills, otherwise a list of skill names."""
128
130
  folder_id: str | None = Field(default=None, max_length=36)
129
131
  """所属文件夹ID,NULL 表示在根目录"""
130
132
  sort_order: int = Field(default=0)
@@ -442,6 +444,8 @@ class Personality(TypedDict):
442
444
  """情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
443
445
  tools: list[str] | None
444
446
  """工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
447
+ skills: list[str] | None
448
+ """Skills 列表。None 表示使用所有 Skills,空列表表示不使用任何 Skills"""
445
449
 
446
450
  # cache
447
451
  _begin_dialogs_processed: list[dict]
astrbot/core/db/sqlite.py CHANGED
@@ -52,8 +52,9 @@ class SQLiteDatabase(BaseDatabase):
52
52
  await conn.execute(text("PRAGMA temp_store=MEMORY"))
53
53
  await conn.execute(text("PRAGMA mmap_size=134217728"))
54
54
  await conn.execute(text("PRAGMA optimize"))
55
- # 确保 personas 表有 folder_idsort_order 列(前向兼容)
55
+ # 确保 personas 表有 folder_idsort_order、skills 列(前向兼容)
56
56
  await self._ensure_persona_folder_columns(conn)
57
+ await self._ensure_persona_skills_column(conn)
57
58
  await conn.commit()
58
59
 
59
60
  async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -76,6 +77,18 @@ class SQLiteDatabase(BaseDatabase):
76
77
  text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0")
77
78
  )
78
79
 
80
+ async def _ensure_persona_skills_column(self, conn) -> None:
81
+ """确保 personas 表有 skills 列。
82
+
83
+ 这是为了支持旧版数据库的平滑升级。新版数据库通过 SQLModel
84
+ 的 metadata.create_all 自动创建这些列。
85
+ """
86
+ result = await conn.execute(text("PRAGMA table_info(personas)"))
87
+ columns = {row[1] for row in result.fetchall()}
88
+
89
+ if "skills" not in columns:
90
+ await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON"))
91
+
79
92
  # ====
80
93
  # Platform Statistics
81
94
  # ====
@@ -564,6 +577,7 @@ class SQLiteDatabase(BaseDatabase):
564
577
  system_prompt,
565
578
  begin_dialogs=None,
566
579
  tools=None,
580
+ skills=None,
567
581
  folder_id=None,
568
582
  sort_order=0,
569
583
  ):
@@ -576,6 +590,7 @@ class SQLiteDatabase(BaseDatabase):
576
590
  system_prompt=system_prompt,
577
591
  begin_dialogs=begin_dialogs or [],
578
592
  tools=tools,
593
+ skills=skills,
579
594
  folder_id=folder_id,
580
595
  sort_order=sort_order,
581
596
  )
@@ -606,6 +621,7 @@ class SQLiteDatabase(BaseDatabase):
606
621
  system_prompt=None,
607
622
  begin_dialogs=None,
608
623
  tools=NOT_GIVEN,
624
+ skills=NOT_GIVEN,
609
625
  ):
610
626
  """Update a persona's system prompt or begin dialogs."""
611
627
  async with self.get_db() as session:
@@ -619,6 +635,8 @@ class SQLiteDatabase(BaseDatabase):
619
635
  values["begin_dialogs"] = begin_dialogs
620
636
  if tools is not NOT_GIVEN:
621
637
  values["tools"] = tools
638
+ if skills is not NOT_GIVEN:
639
+ values["skills"] = skills
622
640
  if not values:
623
641
  return None
624
642
  query = query.values(**values)
@@ -567,7 +567,7 @@ class Node(BaseMessageComponent):
567
567
  async def to_dict(self):
568
568
  data_content = []
569
569
  for comp in self.content:
570
- if isinstance(comp, (Image, Record)):
570
+ if isinstance(comp, Image | Record):
571
571
  # For Image and Record segments, we convert them to base64
572
572
  bs64 = await comp.convert_to_base64()
573
573
  data_content.append(
@@ -584,7 +584,7 @@ class Node(BaseMessageComponent):
584
584
  # For File segments, we need to handle the file differently
585
585
  d = await comp.to_dict()
586
586
  data_content.append(d)
587
- elif isinstance(comp, (Node, Nodes)):
587
+ elif isinstance(comp, Node | Nodes):
588
588
  # For Node segments, we recursively convert them to dict
589
589
  d = await comp.to_dict()
590
590
  data_content.append(d)
@@ -10,6 +10,7 @@ DEFAULT_PERSONALITY = Personality(
10
10
  begin_dialogs=[],
11
11
  mood_imitation_dialogs=[],
12
12
  tools=None,
13
+ skills=None,
13
14
  _begin_dialogs_processed=[],
14
15
  _mood_imitation_dialogs_processed="",
15
16
  )
@@ -71,6 +72,7 @@ class PersonaManager:
71
72
  system_prompt: str | None = None,
72
73
  begin_dialogs: list[str] | None = None,
73
74
  tools: list[str] | None = None,
75
+ skills: list[str] | None = None,
74
76
  ):
75
77
  """更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
76
78
  existing_persona = await self.db.get_persona_by_id(persona_id)
@@ -81,6 +83,7 @@ class PersonaManager:
81
83
  system_prompt,
82
84
  begin_dialogs,
83
85
  tools=tools,
86
+ skills=skills,
84
87
  )
85
88
  if persona:
86
89
  for i, p in enumerate(self.personas):
@@ -239,6 +242,7 @@ class PersonaManager:
239
242
  system_prompt: str,
240
243
  begin_dialogs: list[str] | None = None,
241
244
  tools: list[str] | None = None,
245
+ skills: list[str] | None = None,
242
246
  folder_id: str | None = None,
243
247
  sort_order: int = 0,
244
248
  ) -> Persona:
@@ -249,6 +253,7 @@ class PersonaManager:
249
253
  system_prompt: 系统提示词
250
254
  begin_dialogs: 预设对话列表
251
255
  tools: 工具列表,None 表示使用所有工具,空列表表示不使用任何工具
256
+ skills: Skills 列表,None 表示使用所有 Skills,空列表表示不使用任何 Skills
252
257
  folder_id: 所属文件夹 ID,None 表示根目录
253
258
  sort_order: 排序顺序
254
259
  """
@@ -259,6 +264,7 @@ class PersonaManager:
259
264
  system_prompt,
260
265
  begin_dialogs,
261
266
  tools=tools,
267
+ skills=skills,
262
268
  folder_id=folder_id,
263
269
  sort_order=sort_order,
264
270
  )
@@ -284,6 +290,7 @@ class PersonaManager:
284
290
  "begin_dialogs": persona.begin_dialogs or [],
285
291
  "mood_imitation_dialogs": [], # deprecated
286
292
  "tools": persona.tools,
293
+ "skills": persona.skills,
287
294
  }
288
295
  for persona in self.personas
289
296
  ]
@@ -339,6 +346,7 @@ class PersonaManager:
339
346
  system_prompt=selected_default_persona["prompt"],
340
347
  begin_dialogs=selected_default_persona["begin_dialogs"],
341
348
  tools=selected_default_persona["tools"] or None,
349
+ skills=selected_default_persona["skills"] or None,
342
350
  )
343
351
 
344
352
  return v3_persona_config, personas_v3, selected_default_persona
@@ -48,7 +48,7 @@ async def call_handler(
48
48
  # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
49
49
  # 返回值只能是 MessageEventResult 或者 None(无返回值)
50
50
  _has_yielded = True
51
- if isinstance(ret, (MessageEventResult, CommandResult)):
51
+ if isinstance(ret, MessageEventResult | CommandResult):
52
52
  # 如果返回值是 MessageEventResult, 设置结果并继续
53
53
  event.set_result(ret)
54
54
  yield
@@ -65,7 +65,7 @@ async def call_handler(
65
65
  elif inspect.iscoroutine(ready_to_call):
66
66
  # 如果只是一个协程, 直接执行
67
67
  ret = await ready_to_call
68
- if isinstance(ret, (MessageEventResult, CommandResult)):
68
+ if isinstance(ret, MessageEventResult | CommandResult):
69
69
  event.set_result(ret)
70
70
  yield
71
71
  else:
@@ -52,7 +52,7 @@ class PreProcessStage(Stage):
52
52
  message_chain = event.get_messages()
53
53
 
54
54
  for idx, component in enumerate(message_chain):
55
- if isinstance(component, (Record, Image)) and component.url:
55
+ if isinstance(component, Record | Image) and component.url:
56
56
  for mapping in mappings:
57
57
  from_, to_ = mapping.split(":")
58
58
  from_ = from_.removesuffix("/")