AstrBot 4.12.4__py3-none-any.whl → 4.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
- astrbot/core/agent/tool.py +61 -20
- astrbot/core/astr_agent_tool_exec.py +2 -2
- astrbot/core/{sandbox → computer}/booters/base.py +4 -4
- astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
- astrbot/core/computer/booters/local.py +234 -0
- astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
- astrbot/core/computer/computer_client.py +102 -0
- astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
- astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
- astrbot/core/computer/tools/python.py +94 -0
- astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
- astrbot/core/config/default.py +61 -9
- astrbot/core/db/__init__.py +3 -0
- astrbot/core/db/po.py +4 -0
- astrbot/core/db/sqlite.py +19 -1
- astrbot/core/message/components.py +2 -2
- astrbot/core/persona_mgr.py +8 -0
- astrbot/core/pipeline/context_utils.py +2 -2
- astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +16 -2
- astrbot/core/pipeline/process_stage/utils.py +19 -4
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
- astrbot/core/provider/manager.py +31 -0
- astrbot/core/provider/sources/gemini_source.py +12 -9
- astrbot/core/skills/__init__.py +3 -0
- astrbot/core/skills/skill_manager.py +237 -0
- astrbot/core/star/command_management.py +1 -1
- astrbot/core/star/config.py +1 -1
- astrbot/core/star/filter/command.py +1 -1
- astrbot/core/star/filter/custom_filter.py +2 -2
- astrbot/core/star/register/star_handler.py +1 -1
- astrbot/core/utils/astrbot_path.py +6 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +236 -2
- astrbot/dashboard/routes/persona.py +7 -0
- astrbot/dashboard/routes/skills.py +148 -0
- astrbot/dashboard/routes/util.py +102 -0
- astrbot/dashboard/server.py +19 -5
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/RECORD +52 -47
- astrbot/core/sandbox/sandbox_client.py +0 -52
- astrbot/core/sandbox/tools/python.py +0 -74
- /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -46,6 +46,7 @@ from ...utils import (
|
|
|
46
46
|
PYTHON_TOOL,
|
|
47
47
|
SANDBOX_MODE_PROMPT,
|
|
48
48
|
TOOL_CALL_PROMPT,
|
|
49
|
+
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
|
|
49
50
|
decoded_blocked,
|
|
50
51
|
retrieve_knowledge_base,
|
|
51
52
|
)
|
|
@@ -62,6 +63,13 @@ class InternalAgentSubStage(Stage):
|
|
|
62
63
|
]
|
|
63
64
|
self.max_step: int = settings.get("max_agent_step", 30)
|
|
64
65
|
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
|
|
66
|
+
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
|
|
67
|
+
if self.tool_schema_mode not in ("skills_like", "full"):
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Unsupported tool_schema_mode: %s, fallback to skills_like",
|
|
70
|
+
self.tool_schema_mode,
|
|
71
|
+
)
|
|
72
|
+
self.tool_schema_mode = "full"
|
|
65
73
|
if isinstance(self.max_step, bool): # workaround: #2622
|
|
66
74
|
self.max_step = 30
|
|
67
75
|
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
|
|
@@ -517,7 +525,7 @@ class InternalAgentSubStage(Stage):
|
|
|
517
525
|
has_valid_message = bool(event.message_str and event.message_str.strip())
|
|
518
526
|
# 检查是否有图片或其他媒体内容
|
|
519
527
|
has_media_content = any(
|
|
520
|
-
isinstance(comp,
|
|
528
|
+
isinstance(comp, Image | File) for comp in event.message_obj.message
|
|
521
529
|
)
|
|
522
530
|
|
|
523
531
|
if (
|
|
@@ -672,7 +680,12 @@ class InternalAgentSubStage(Stage):
|
|
|
672
680
|
|
|
673
681
|
# 注入基本 prompt
|
|
674
682
|
if req.func_tool and req.func_tool.tools:
|
|
675
|
-
|
|
683
|
+
tool_prompt = (
|
|
684
|
+
TOOL_CALL_PROMPT
|
|
685
|
+
if self.tool_schema_mode == "full"
|
|
686
|
+
else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
|
|
687
|
+
)
|
|
688
|
+
req.system_prompt += f"\n{tool_prompt}\n"
|
|
676
689
|
|
|
677
690
|
action_type = event.get_extra("action_type")
|
|
678
691
|
if action_type == "live":
|
|
@@ -693,6 +706,7 @@ class InternalAgentSubStage(Stage):
|
|
|
693
706
|
llm_compress_provider=self._get_compress_provider(),
|
|
694
707
|
truncate_turns=self.dequeue_context_length,
|
|
695
708
|
enforce_max_turns=self.max_context_length,
|
|
709
|
+
tool_schema_mode=self.tool_schema_mode,
|
|
696
710
|
)
|
|
697
711
|
|
|
698
712
|
# 检测 Live Mode
|
|
@@ -7,10 +7,11 @@ from astrbot.api import logger, sp
|
|
|
7
7
|
from astrbot.core.agent.run_context import ContextWrapper
|
|
8
8
|
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
|
9
9
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
10
|
-
from astrbot.core.
|
|
10
|
+
from astrbot.core.computer.tools import (
|
|
11
11
|
ExecuteShellTool,
|
|
12
12
|
FileDownloadTool,
|
|
13
13
|
FileUploadTool,
|
|
14
|
+
LocalPythonTool,
|
|
14
15
|
PythonTool,
|
|
15
16
|
)
|
|
16
17
|
from astrbot.core.star.context import Context
|
|
@@ -39,11 +40,23 @@ SANDBOX_MODE_PROMPT = (
|
|
|
39
40
|
|
|
40
41
|
TOOL_CALL_PROMPT = (
|
|
41
42
|
"You MUST NOT return an empty response, especially after invoking a tool."
|
|
42
|
-
"Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
|
|
43
|
-
"
|
|
44
|
-
"
|
|
43
|
+
" Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
|
|
44
|
+
" Use the provided tool schema to format arguments and do not guess parameters that are not defined."
|
|
45
|
+
" After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
|
|
46
|
+
" Keep the role-play and style consistent throughout the conversation."
|
|
45
47
|
)
|
|
46
48
|
|
|
49
|
+
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE = (
|
|
50
|
+
"You MUST NOT return an empty response, especially after invoking a tool."
|
|
51
|
+
" Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
|
|
52
|
+
" Tool schemas are provided in two stages: first only name and description; "
|
|
53
|
+
"if you decide to use a tool, the full parameter schema will be provided in "
|
|
54
|
+
"a follow-up step. Do not guess arguments before you see the schema."
|
|
55
|
+
" After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
|
|
56
|
+
" Keep the role-play and style consistent throughout the conversation."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
|
|
48
61
|
"You are a calm, patient friend with a systems-oriented way of thinking.\n"
|
|
49
62
|
"When someone expresses strong emotional needs, you begin by offering a concise, grounding response "
|
|
@@ -194,7 +207,9 @@ async def retrieve_knowledge_base(
|
|
|
194
207
|
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
|
195
208
|
|
|
196
209
|
EXECUTE_SHELL_TOOL = ExecuteShellTool()
|
|
210
|
+
LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
|
|
197
211
|
PYTHON_TOOL = PythonTool()
|
|
212
|
+
LOCAL_PYTHON_TOOL = LocalPythonTool()
|
|
198
213
|
FILE_UPLOAD_TOOL = FileUploadTool()
|
|
199
214
|
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
|
200
215
|
|
|
@@ -82,7 +82,7 @@ class PipelineScheduler:
|
|
|
82
82
|
await self._process_stages(event)
|
|
83
83
|
|
|
84
84
|
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
|
85
|
-
if isinstance(event,
|
|
85
|
+
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
|
86
86
|
await event.send(None)
|
|
87
87
|
|
|
88
88
|
logger.debug("pipeline 执行完毕。")
|
|
@@ -33,7 +33,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
|
|
33
33
|
@staticmethod
|
|
34
34
|
async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict:
|
|
35
35
|
"""修复部分字段"""
|
|
36
|
-
if isinstance(segment,
|
|
36
|
+
if isinstance(segment, Image | Record):
|
|
37
37
|
# For Image and Record segments, we convert them to base64
|
|
38
38
|
bs64 = await segment.convert_to_base64()
|
|
39
39
|
return {
|
|
@@ -110,7 +110,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
|
|
110
110
|
"""
|
|
111
111
|
# 转发消息、文件消息不能和普通消息混在一起发送
|
|
112
112
|
send_one_by_one = any(
|
|
113
|
-
isinstance(seg,
|
|
113
|
+
isinstance(seg, Node | Nodes | File) for seg in message_chain.chain
|
|
114
114
|
)
|
|
115
115
|
if not send_one_by_one:
|
|
116
116
|
ret = await cls._parse_onebot_json(message_chain)
|
|
@@ -119,7 +119,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
|
|
119
119
|
await cls._dispatch_send(bot, event, is_group, session_id, ret)
|
|
120
120
|
return
|
|
121
121
|
for seg in message_chain.chain:
|
|
122
|
-
if isinstance(seg,
|
|
122
|
+
if isinstance(seg, Node | Nodes):
|
|
123
123
|
# 合并转发消息
|
|
124
124
|
if isinstance(seg, Node):
|
|
125
125
|
nodes = Nodes([seg])
|
|
@@ -90,12 +90,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|
|
90
90
|
|
|
91
91
|
if not isinstance(
|
|
92
92
|
source,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
botpy.message.C2CMessage,
|
|
98
|
-
),
|
|
93
|
+
botpy.message.Message
|
|
94
|
+
| botpy.message.GroupMessage
|
|
95
|
+
| botpy.message.DirectMessage
|
|
96
|
+
| botpy.message.C2CMessage,
|
|
99
97
|
):
|
|
100
98
|
logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}")
|
|
101
99
|
return None
|
|
@@ -120,7 +118,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|
|
120
118
|
"msg_id": self.message_obj.message_id,
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
if not isinstance(source,
|
|
121
|
+
if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
|
|
124
122
|
payload["msg_seq"] = random.randint(1, 10000)
|
|
125
123
|
|
|
126
124
|
ret = None
|
astrbot/core/provider/manager.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import copy
|
|
3
|
+
import os
|
|
3
4
|
import traceback
|
|
4
5
|
from typing import Protocol, runtime_checkable
|
|
5
6
|
|
|
@@ -406,10 +407,40 @@ class ProviderManager:
|
|
|
406
407
|
pc = merged_config
|
|
407
408
|
return pc
|
|
408
409
|
|
|
410
|
+
def _resolve_env_key_list(self, provider_config: dict) -> dict:
|
|
411
|
+
keys = provider_config.get("key", [])
|
|
412
|
+
if not isinstance(keys, list):
|
|
413
|
+
return provider_config
|
|
414
|
+
resolved_keys = []
|
|
415
|
+
for idx, key in enumerate(keys):
|
|
416
|
+
if isinstance(key, str) and key.startswith("$"):
|
|
417
|
+
env_key = key[1:]
|
|
418
|
+
if env_key.startswith("{") and env_key.endswith("}"):
|
|
419
|
+
env_key = env_key[1:-1]
|
|
420
|
+
if env_key:
|
|
421
|
+
env_val = os.getenv(env_key)
|
|
422
|
+
if env_val is None:
|
|
423
|
+
provider_id = provider_config.get("id")
|
|
424
|
+
logger.warning(
|
|
425
|
+
f"Provider {provider_id} 配置项 key[{idx}] 使用环境变量 {env_key} 但未设置。",
|
|
426
|
+
)
|
|
427
|
+
resolved_keys.append("")
|
|
428
|
+
else:
|
|
429
|
+
resolved_keys.append(env_val)
|
|
430
|
+
else:
|
|
431
|
+
resolved_keys.append(key)
|
|
432
|
+
else:
|
|
433
|
+
resolved_keys.append(key)
|
|
434
|
+
provider_config["key"] = resolved_keys
|
|
435
|
+
return provider_config
|
|
436
|
+
|
|
409
437
|
async def load_provider(self, provider_config: dict):
|
|
410
438
|
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
|
|
411
439
|
provider_config = self.get_merged_provider_config(provider_config)
|
|
412
440
|
|
|
441
|
+
if provider_config.get("provider_type", "") == "chat_completion":
|
|
442
|
+
provider_config = self._resolve_env_key_list(provider_config)
|
|
443
|
+
|
|
413
444
|
if not provider_config["enable"]:
|
|
414
445
|
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
|
|
415
446
|
return
|
|
@@ -382,15 +382,18 @@ class ProviderGoogleGenAI(Provider):
|
|
|
382
382
|
append_or_extend(gemini_contents, parts, types.ModelContent)
|
|
383
383
|
|
|
384
384
|
elif role == "tool" and not native_tool_enabled:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
385
|
+
func_name = message.get("name", message["tool_call_id"])
|
|
386
|
+
part = types.Part.from_function_response(
|
|
387
|
+
name=func_name,
|
|
388
|
+
response={
|
|
389
|
+
"name": func_name,
|
|
390
|
+
"content": message["content"],
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
if part.function_response:
|
|
394
|
+
part.function_response.id = message["tool_call_id"]
|
|
395
|
+
|
|
396
|
+
parts = [part]
|
|
394
397
|
append_or_extend(gemini_contents, parts, types.UserContent)
|
|
395
398
|
|
|
396
399
|
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from astrbot.core.utils.astrbot_path import (
|
|
13
|
+
get_astrbot_data_path,
|
|
14
|
+
get_astrbot_skills_path,
|
|
15
|
+
get_astrbot_temp_path,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SKILLS_CONFIG_FILENAME = "skills.json"
|
|
19
|
+
DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
|
|
20
|
+
SANDBOX_SKILLS_ROOT = "/home/shared/skills"
|
|
21
|
+
|
|
22
|
+
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SkillInfo:
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
path: str
|
|
30
|
+
active: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_frontmatter_description(text: str) -> str:
|
|
34
|
+
if not text.startswith("---"):
|
|
35
|
+
return ""
|
|
36
|
+
lines = text.splitlines()
|
|
37
|
+
if not lines or lines[0].strip() != "---":
|
|
38
|
+
return ""
|
|
39
|
+
end_idx = None
|
|
40
|
+
for i in range(1, len(lines)):
|
|
41
|
+
if lines[i].strip() == "---":
|
|
42
|
+
end_idx = i
|
|
43
|
+
break
|
|
44
|
+
if end_idx is None:
|
|
45
|
+
return ""
|
|
46
|
+
for line in lines[1:end_idx]:
|
|
47
|
+
if ":" not in line:
|
|
48
|
+
continue
|
|
49
|
+
key, value = line.split(":", 1)
|
|
50
|
+
if key.strip().lower() == "description":
|
|
51
|
+
return value.strip().strip('"').strip("'")
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
|
56
|
+
skills_lines = []
|
|
57
|
+
for skill in skills:
|
|
58
|
+
description = skill.description or "No description"
|
|
59
|
+
skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})")
|
|
60
|
+
skills_block = "\n".join(skills_lines)
|
|
61
|
+
# Based on openai/codex
|
|
62
|
+
return (
|
|
63
|
+
"## Skills\n"
|
|
64
|
+
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
|
|
65
|
+
"### Available skills\n"
|
|
66
|
+
f"{skills_block}\n"
|
|
67
|
+
"### Skill Rules\n"
|
|
68
|
+
"\n"
|
|
69
|
+
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
|
|
70
|
+
"- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n"
|
|
71
|
+
"- Unavailable: If a skill is missing or unreadable, say so and fallback.\n"
|
|
72
|
+
"### How to use a skill (progressive disclosure):\n"
|
|
73
|
+
" 1) After deciding to use a skill, open its `SKILL.md` and read only what is necessary to follow the workflow.\n"
|
|
74
|
+
" 2) Load only directly referenced files, DO NOT bulk-load everything.\n"
|
|
75
|
+
" 3) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
|
|
76
|
+
" 4) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
|
|
77
|
+
"- Coordination:\n"
|
|
78
|
+
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
|
|
79
|
+
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
|
|
80
|
+
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
|
|
81
|
+
"- Context hygiene:\n"
|
|
82
|
+
" - Keep context small: summarize long sections instead of pasting them, and load extra files only when necessary.\n"
|
|
83
|
+
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
|
|
84
|
+
" - When variants exist (frameworks, providers, domains), select only the relevant reference file(s) and note that choice.\n"
|
|
85
|
+
"- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SkillManager:
|
|
90
|
+
def __init__(self, skills_root: str | None = None) -> None:
|
|
91
|
+
self.skills_root = skills_root or get_astrbot_skills_path()
|
|
92
|
+
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
|
|
93
|
+
os.makedirs(self.skills_root, exist_ok=True)
|
|
94
|
+
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
|
|
95
|
+
|
|
96
|
+
def _load_config(self) -> dict:
|
|
97
|
+
if not os.path.exists(self.config_path):
|
|
98
|
+
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
|
|
99
|
+
return DEFAULT_SKILLS_CONFIG.copy()
|
|
100
|
+
with open(self.config_path, encoding="utf-8") as f:
|
|
101
|
+
data = json.load(f)
|
|
102
|
+
if not isinstance(data, dict) or "skills" not in data:
|
|
103
|
+
return DEFAULT_SKILLS_CONFIG.copy()
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
def _save_config(self, config: dict) -> None:
|
|
107
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
108
|
+
json.dump(config, f, ensure_ascii=False, indent=4)
|
|
109
|
+
|
|
110
|
+
def list_skills(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
active_only: bool = False,
|
|
114
|
+
runtime: str = "local",
|
|
115
|
+
show_sandbox_path: bool = True,
|
|
116
|
+
) -> list[SkillInfo]:
|
|
117
|
+
"""List all skills.
|
|
118
|
+
|
|
119
|
+
show_sandbox_path: If True and runtime is "sandbox",
|
|
120
|
+
return the path as it would appear in the sandbox environment,
|
|
121
|
+
otherwise return the local filesystem path.
|
|
122
|
+
"""
|
|
123
|
+
config = self._load_config()
|
|
124
|
+
skill_configs = config.get("skills", {})
|
|
125
|
+
modified = False
|
|
126
|
+
skills: list[SkillInfo] = []
|
|
127
|
+
|
|
128
|
+
for entry in sorted(Path(self.skills_root).iterdir()):
|
|
129
|
+
if not entry.is_dir():
|
|
130
|
+
continue
|
|
131
|
+
skill_name = entry.name
|
|
132
|
+
skill_md = entry / "SKILL.md"
|
|
133
|
+
if not skill_md.exists():
|
|
134
|
+
continue
|
|
135
|
+
active = skill_configs.get(skill_name, {}).get("active", True)
|
|
136
|
+
if skill_name not in skill_configs:
|
|
137
|
+
skill_configs[skill_name] = {"active": active}
|
|
138
|
+
modified = True
|
|
139
|
+
if active_only and not active:
|
|
140
|
+
continue
|
|
141
|
+
description = ""
|
|
142
|
+
try:
|
|
143
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
144
|
+
description = _parse_frontmatter_description(content)
|
|
145
|
+
except Exception:
|
|
146
|
+
description = ""
|
|
147
|
+
if runtime == "sandbox" and show_sandbox_path:
|
|
148
|
+
path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
|
149
|
+
else:
|
|
150
|
+
path_str = str(skill_md)
|
|
151
|
+
path_str = path_str.replace("\\", "/")
|
|
152
|
+
skills.append(
|
|
153
|
+
SkillInfo(
|
|
154
|
+
name=skill_name,
|
|
155
|
+
description=description,
|
|
156
|
+
path=path_str,
|
|
157
|
+
active=active,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if modified:
|
|
162
|
+
config["skills"] = skill_configs
|
|
163
|
+
self._save_config(config)
|
|
164
|
+
|
|
165
|
+
return skills
|
|
166
|
+
|
|
167
|
+
def set_skill_active(self, name: str, active: bool) -> None:
|
|
168
|
+
config = self._load_config()
|
|
169
|
+
config.setdefault("skills", {})
|
|
170
|
+
config["skills"][name] = {"active": bool(active)}
|
|
171
|
+
self._save_config(config)
|
|
172
|
+
|
|
173
|
+
def delete_skill(self, name: str) -> None:
|
|
174
|
+
skill_dir = Path(self.skills_root) / name
|
|
175
|
+
if skill_dir.exists():
|
|
176
|
+
shutil.rmtree(skill_dir)
|
|
177
|
+
config = self._load_config()
|
|
178
|
+
if name in config.get("skills", {}):
|
|
179
|
+
config["skills"].pop(name, None)
|
|
180
|
+
self._save_config(config)
|
|
181
|
+
|
|
182
|
+
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
|
|
183
|
+
zip_path_obj = Path(zip_path)
|
|
184
|
+
if not zip_path_obj.exists():
|
|
185
|
+
raise FileNotFoundError(f"Zip file not found: {zip_path}")
|
|
186
|
+
if not zipfile.is_zipfile(zip_path):
|
|
187
|
+
raise ValueError("Uploaded file is not a valid zip archive.")
|
|
188
|
+
|
|
189
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
190
|
+
names = [name.replace("\\", "/") for name in zf.namelist()]
|
|
191
|
+
file_names = [name for name in names if name and not name.endswith("/")]
|
|
192
|
+
if not file_names:
|
|
193
|
+
raise ValueError("Zip archive is empty.")
|
|
194
|
+
|
|
195
|
+
top_dirs = {
|
|
196
|
+
PurePosixPath(name).parts[0] for name in file_names if name.strip()
|
|
197
|
+
}
|
|
198
|
+
print(top_dirs)
|
|
199
|
+
if len(top_dirs) != 1:
|
|
200
|
+
raise ValueError("Zip archive must contain a single top-level folder.")
|
|
201
|
+
skill_name = next(iter(top_dirs))
|
|
202
|
+
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
|
|
203
|
+
raise ValueError("Invalid skill folder name.")
|
|
204
|
+
|
|
205
|
+
for name in names:
|
|
206
|
+
if not name:
|
|
207
|
+
continue
|
|
208
|
+
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
|
209
|
+
raise ValueError("Zip archive contains absolute paths.")
|
|
210
|
+
parts = PurePosixPath(name).parts
|
|
211
|
+
if ".." in parts:
|
|
212
|
+
raise ValueError("Zip archive contains invalid relative paths.")
|
|
213
|
+
if parts and parts[0] != skill_name:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"Zip archive contains unexpected top-level entries."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
f"{skill_name}/SKILL.md" not in file_names
|
|
220
|
+
and f"{skill_name}/skill.md" not in file_names
|
|
221
|
+
):
|
|
222
|
+
raise ValueError("SKILL.md not found in the skill folder.")
|
|
223
|
+
|
|
224
|
+
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
|
225
|
+
zf.extractall(tmp_dir)
|
|
226
|
+
src_dir = Path(tmp_dir) / skill_name
|
|
227
|
+
if not src_dir.exists():
|
|
228
|
+
raise ValueError("Skill folder not found after extraction.")
|
|
229
|
+
dest_dir = Path(self.skills_root) / skill_name
|
|
230
|
+
if dest_dir.exists():
|
|
231
|
+
if not overwrite:
|
|
232
|
+
raise FileExistsError("Skill already exists.")
|
|
233
|
+
shutil.rmtree(dest_dir)
|
|
234
|
+
shutil.move(str(src_dir), str(dest_dir))
|
|
235
|
+
|
|
236
|
+
self.set_skill_active(skill_name, True)
|
|
237
|
+
return skill_name
|
|
@@ -303,7 +303,7 @@ def _locate_primary_filter(
|
|
|
303
303
|
handler: StarHandlerMetadata,
|
|
304
304
|
) -> CommandFilter | CommandGroupFilter | None:
|
|
305
305
|
for filter_ref in handler.event_filters:
|
|
306
|
-
if isinstance(filter_ref,
|
|
306
|
+
if isinstance(filter_ref, CommandFilter | CommandGroupFilter):
|
|
307
307
|
return filter_ref
|
|
308
308
|
return None
|
|
309
309
|
|
astrbot/core/star/config.py
CHANGED
|
@@ -38,7 +38,7 @@ def put_config(namespace: str, name: str, key: str, value, description: str):
|
|
|
38
38
|
raise ValueError("namespace 不能以 internal_ 开头。")
|
|
39
39
|
if not isinstance(key, str):
|
|
40
40
|
raise ValueError("key 只支持 str 类型。")
|
|
41
|
-
if not isinstance(value,
|
|
41
|
+
if not isinstance(value, str | int | float | bool | list):
|
|
42
42
|
raise ValueError("value 只支持 str, int, float, bool, list 类型。")
|
|
43
43
|
|
|
44
44
|
config_dir = os.path.join(get_astrbot_data_path(), "config")
|
|
@@ -115,7 +115,7 @@ class CommandFilter(HandlerFilter):
|
|
|
115
115
|
# 没有 GreedyStr 的情况
|
|
116
116
|
if i >= len(params):
|
|
117
117
|
if (
|
|
118
|
-
isinstance(param_type_or_default_val,
|
|
118
|
+
isinstance(param_type_or_default_val, type | types.UnionType)
|
|
119
119
|
or typing.get_origin(param_type_or_default_val) is typing.Union
|
|
120
120
|
or param_type_or_default_val is inspect.Parameter.empty
|
|
121
121
|
):
|
|
@@ -37,7 +37,7 @@ class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
|
|
|
37
37
|
class CustomFilterOr(CustomFilter):
|
|
38
38
|
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
|
|
39
39
|
super().__init__()
|
|
40
|
-
if not isinstance(filter1,
|
|
40
|
+
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
|
|
41
41
|
raise ValueError(
|
|
42
42
|
"CustomFilter lass can only operate with other CustomFilter.",
|
|
43
43
|
)
|
|
@@ -51,7 +51,7 @@ class CustomFilterOr(CustomFilter):
|
|
|
51
51
|
class CustomFilterAnd(CustomFilter):
|
|
52
52
|
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
|
|
53
53
|
super().__init__()
|
|
54
|
-
if not isinstance(filter1,
|
|
54
|
+
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
|
|
55
55
|
raise ValueError(
|
|
56
56
|
"CustomFilter lass can only operate with other CustomFilter.",
|
|
57
57
|
)
|
|
@@ -150,7 +150,7 @@ def register_custom_filter(custom_type_filter, *args, **kwargs):
|
|
|
150
150
|
if args:
|
|
151
151
|
raise_error = args[0]
|
|
152
152
|
|
|
153
|
-
if not isinstance(custom_filter,
|
|
153
|
+
if not isinstance(custom_filter, CustomFilterAnd | CustomFilterOr):
|
|
154
154
|
custom_filter = custom_filter(raise_error)
|
|
155
155
|
|
|
156
156
|
def decorator(awaitable):
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
T2I 模板目录路径:固定为数据目录下的 t2i_templates 目录
|
|
10
10
|
WebChat 数据目录路径:固定为数据目录下的 webchat 目录
|
|
11
11
|
临时文件目录路径:固定为数据目录下的 temp 目录
|
|
12
|
+
Skills 目录路径:固定为数据目录下的 skills 目录
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
import os
|
|
@@ -63,6 +64,11 @@ def get_astrbot_temp_path() -> str:
|
|
|
63
64
|
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
def get_astrbot_skills_path() -> str:
|
|
68
|
+
"""获取Astrbot Skills 目录路径"""
|
|
69
|
+
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
|
|
70
|
+
|
|
71
|
+
|
|
66
72
|
def get_astrbot_knowledge_base_path() -> str:
|
|
67
73
|
"""获取Astrbot知识库根目录路径"""
|
|
68
74
|
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
|
|
@@ -12,6 +12,7 @@ from .persona import PersonaRoute
|
|
|
12
12
|
from .platform import PlatformRoute
|
|
13
13
|
from .plugin import PluginRoute
|
|
14
14
|
from .session_management import SessionManagementRoute
|
|
15
|
+
from .skills import SkillsRoute
|
|
15
16
|
from .stat import StatRoute
|
|
16
17
|
from .static_file import StaticFileRoute
|
|
17
18
|
from .tools import ToolsRoute
|
|
@@ -35,5 +36,6 @@ __all__ = [
|
|
|
35
36
|
"StatRoute",
|
|
36
37
|
"StaticFileRoute",
|
|
37
38
|
"ToolsRoute",
|
|
39
|
+
"SkillsRoute",
|
|
38
40
|
"UpdateRoute",
|
|
39
41
|
]
|