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
|
@@ -221,11 +221,65 @@ class TTSProvider(AbstractProvider):
|
|
|
221
221
|
self.provider_config = provider_config
|
|
222
222
|
self.provider_settings = provider_settings
|
|
223
223
|
|
|
224
|
+
def support_stream(self) -> bool:
|
|
225
|
+
"""是否支持流式 TTS
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
bool: True 表示支持流式处理,False 表示不支持(默认)
|
|
229
|
+
|
|
230
|
+
Notes:
|
|
231
|
+
子类可以重写此方法返回 True 来启用流式 TTS 支持
|
|
232
|
+
"""
|
|
233
|
+
return False
|
|
234
|
+
|
|
224
235
|
@abc.abstractmethod
|
|
225
236
|
async def get_audio(self, text: str) -> str:
|
|
226
237
|
"""获取文本的音频,返回音频文件路径"""
|
|
227
238
|
raise NotImplementedError
|
|
228
239
|
|
|
240
|
+
async def get_audio_stream(
|
|
241
|
+
self,
|
|
242
|
+
text_queue: asyncio.Queue[str | None],
|
|
243
|
+
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
|
244
|
+
) -> None:
|
|
245
|
+
"""流式 TTS 处理方法。
|
|
246
|
+
|
|
247
|
+
从 text_queue 中读取文本片段,将生成的音频数据(WAV 格式的 in-memory bytes)放入 audio_queue。
|
|
248
|
+
当 text_queue 收到 None 时,表示文本输入结束,此时应该处理完所有剩余文本并向 audio_queue 发送 None 表示结束。
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
text_queue: 输入文本队列,None 表示输入结束
|
|
252
|
+
audio_queue: 输出音频队列(bytes 或 (text, bytes)),None 表示输出结束
|
|
253
|
+
|
|
254
|
+
Notes:
|
|
255
|
+
- 默认实现会将文本累积后一次性调用 get_audio 生成完整音频
|
|
256
|
+
- 子类可以重写此方法实现真正的流式 TTS
|
|
257
|
+
- 音频数据应该是 WAV 格式的 bytes
|
|
258
|
+
"""
|
|
259
|
+
accumulated_text = ""
|
|
260
|
+
|
|
261
|
+
while True:
|
|
262
|
+
text_part = await text_queue.get()
|
|
263
|
+
|
|
264
|
+
if text_part is None:
|
|
265
|
+
# 输入结束,处理累积的文本
|
|
266
|
+
if accumulated_text:
|
|
267
|
+
try:
|
|
268
|
+
# 调用原有的 get_audio 方法获取音频文件路径
|
|
269
|
+
audio_path = await self.get_audio(accumulated_text)
|
|
270
|
+
# 读取音频文件内容
|
|
271
|
+
with open(audio_path, "rb") as f:
|
|
272
|
+
audio_data = f.read()
|
|
273
|
+
await audio_queue.put((accumulated_text, audio_data))
|
|
274
|
+
except Exception:
|
|
275
|
+
# 出错时也要发送 None 结束标记
|
|
276
|
+
pass
|
|
277
|
+
# 发送结束标记
|
|
278
|
+
await audio_queue.put(None)
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
accumulated_text += text_part
|
|
282
|
+
|
|
229
283
|
async def test(self):
|
|
230
284
|
await self.get_audio("hi")
|
|
231
285
|
|
|
@@ -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,128 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from astrbot.core import logger
|
|
6
|
+
from astrbot.core.provider.entities import ProviderType
|
|
7
|
+
from astrbot.core.provider.provider import TTSProvider
|
|
8
|
+
from astrbot.core.provider.register import register_provider_adapter
|
|
9
|
+
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import genie_tts as genie # type: ignore
|
|
13
|
+
except ImportError:
|
|
14
|
+
genie = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register_provider_adapter(
|
|
18
|
+
"genie_tts",
|
|
19
|
+
"Genie TTS",
|
|
20
|
+
provider_type=ProviderType.TEXT_TO_SPEECH,
|
|
21
|
+
)
|
|
22
|
+
class GenieTTSProvider(TTSProvider):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
provider_config: dict,
|
|
26
|
+
provider_settings: dict,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(provider_config, provider_settings)
|
|
29
|
+
if not genie:
|
|
30
|
+
raise ImportError("Please install genie_tts first.")
|
|
31
|
+
|
|
32
|
+
self.character_name = provider_config.get("genie_character_name", "mika")
|
|
33
|
+
language = provider_config.get("genie_language", "Japanese")
|
|
34
|
+
model_dir = provider_config.get("genie_onnx_model_dir", "")
|
|
35
|
+
refer_audio_path = provider_config.get("genie_refer_audio_path", "")
|
|
36
|
+
refer_text = provider_config.get("genie_refer_text", "")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
genie.load_character(
|
|
40
|
+
character_name=self.character_name,
|
|
41
|
+
language=language,
|
|
42
|
+
onnx_model_dir=model_dir,
|
|
43
|
+
)
|
|
44
|
+
genie.set_reference_audio(
|
|
45
|
+
character_name=self.character_name,
|
|
46
|
+
audio_path=refer_audio_path,
|
|
47
|
+
audio_text=refer_text,
|
|
48
|
+
language=language,
|
|
49
|
+
)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise RuntimeError(f"Failed to load character {self.character_name}: {e}")
|
|
52
|
+
|
|
53
|
+
def support_stream(self) -> bool:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
async def get_audio(self, text: str) -> str:
|
|
57
|
+
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
|
58
|
+
os.makedirs(temp_dir, exist_ok=True)
|
|
59
|
+
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
|
60
|
+
path = os.path.join(temp_dir, filename)
|
|
61
|
+
|
|
62
|
+
loop = asyncio.get_event_loop()
|
|
63
|
+
|
|
64
|
+
def _generate(save_path: str):
|
|
65
|
+
assert genie is not None
|
|
66
|
+
genie.tts(
|
|
67
|
+
character_name=self.character_name,
|
|
68
|
+
text=text,
|
|
69
|
+
save_path=save_path,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
await loop.run_in_executor(None, _generate, path)
|
|
74
|
+
|
|
75
|
+
if os.path.exists(path):
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
raise RuntimeError("Genie TTS did not save to file.")
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
raise RuntimeError(f"Genie TTS generation failed: {e}")
|
|
82
|
+
|
|
83
|
+
async def get_audio_stream(
|
|
84
|
+
self,
|
|
85
|
+
text_queue: asyncio.Queue[str | None],
|
|
86
|
+
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
|
87
|
+
) -> None:
|
|
88
|
+
loop = asyncio.get_event_loop()
|
|
89
|
+
|
|
90
|
+
while True:
|
|
91
|
+
text = await text_queue.get()
|
|
92
|
+
if text is None:
|
|
93
|
+
await audio_queue.put(None)
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
|
98
|
+
os.makedirs(temp_dir, exist_ok=True)
|
|
99
|
+
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
|
100
|
+
path = os.path.join(temp_dir, filename)
|
|
101
|
+
|
|
102
|
+
def _generate(save_path: str, t: str):
|
|
103
|
+
assert genie is not None
|
|
104
|
+
genie.tts(
|
|
105
|
+
character_name=self.character_name,
|
|
106
|
+
text=t,
|
|
107
|
+
save_path=save_path,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
await loop.run_in_executor(None, _generate, path, text)
|
|
111
|
+
|
|
112
|
+
if os.path.exists(path):
|
|
113
|
+
with open(path, "rb") as f:
|
|
114
|
+
audio_data = f.read()
|
|
115
|
+
|
|
116
|
+
# Put (text, bytes) into queue so frontend can display text
|
|
117
|
+
await audio_queue.put((text, audio_data))
|
|
118
|
+
|
|
119
|
+
# Clean up
|
|
120
|
+
try:
|
|
121
|
+
os.remove(path)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
else:
|
|
125
|
+
logger.error(f"Genie TTS failed to generate audio for: {text}")
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Genie TTS stream error: {e}")
|
|
@@ -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")
|
astrbot/core/star/context.py
CHANGED
|
@@ -328,28 +328,29 @@ class Context:
|
|
|
328
328
|
"""获取所有用于 Embedding 任务的 Provider。"""
|
|
329
329
|
return self.provider_manager.embedding_provider_insts
|
|
330
330
|
|
|
331
|
-
def get_using_provider(self, umo: str | None = None) -> Provider:
|
|
331
|
+
def get_using_provider(self, umo: str | None = None) -> Provider | None:
|
|
332
332
|
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
|
|
333
333
|
|
|
334
334
|
Args:
|
|
335
335
|
umo: unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,
|
|
336
|
-
|
|
336
|
+
则使用该会话偏好的对话模型(提供商)。
|
|
337
337
|
|
|
338
338
|
Returns:
|
|
339
|
-
|
|
339
|
+
当前使用的对话模型(提供商),如果未设置则返回 None。
|
|
340
340
|
|
|
341
341
|
Raises:
|
|
342
|
-
ValueError:
|
|
343
|
-
|
|
344
|
-
Note:
|
|
345
|
-
通过 /provider 指令可以切换提供者。
|
|
342
|
+
ValueError: 该会话来源配置的的对话模型(提供商)的类型不正确。
|
|
346
343
|
"""
|
|
347
344
|
prov = self.provider_manager.get_using_provider(
|
|
348
345
|
provider_type=ProviderType.CHAT_COMPLETION,
|
|
349
346
|
umo=umo,
|
|
350
347
|
)
|
|
348
|
+
if prov is None:
|
|
349
|
+
return None
|
|
351
350
|
if not isinstance(prov, Provider):
|
|
352
|
-
raise ValueError(
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"该会话来源的对话模型(提供商)的类型不正确: {type(prov)}"
|
|
353
|
+
)
|
|
353
354
|
return prov
|
|
354
355
|
|
|
355
356
|
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider | None:
|
|
@@ -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):
|
|
@@ -452,9 +452,7 @@ def register_on_llm_tool_respond(**kwargs):
|
|
|
452
452
|
"""
|
|
453
453
|
|
|
454
454
|
def decorator(awaitable):
|
|
455
|
-
_ = get_handler_or_create(
|
|
456
|
-
awaitable, EventType.OnLLMToolRespondEvent, **kwargs
|
|
457
|
-
)
|
|
455
|
+
_ = get_handler_or_create(awaitable, EventType.OnLLMToolRespondEvent, **kwargs)
|
|
458
456
|
return awaitable
|
|
459
457
|
|
|
460
458
|
return decorator
|
|
@@ -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
|
]
|