AstrBot 4.12.4__py3-none-any.whl → 4.13.1__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 +23 -61
- 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 +21 -6
- 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 +238 -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.1.dist-info}/METADATA +2 -2
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.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.1.dist-info}/WHEEL +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/entry_points.txt +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
SANDBOX_SKILLS_ROOT = "skills"
|
|
22
|
+
|
|
23
|
+
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SkillInfo:
|
|
28
|
+
name: str
|
|
29
|
+
description: str
|
|
30
|
+
path: str
|
|
31
|
+
active: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_frontmatter_description(text: str) -> str:
|
|
35
|
+
if not text.startswith("---"):
|
|
36
|
+
return ""
|
|
37
|
+
lines = text.splitlines()
|
|
38
|
+
if not lines or lines[0].strip() != "---":
|
|
39
|
+
return ""
|
|
40
|
+
end_idx = None
|
|
41
|
+
for i in range(1, len(lines)):
|
|
42
|
+
if lines[i].strip() == "---":
|
|
43
|
+
end_idx = i
|
|
44
|
+
break
|
|
45
|
+
if end_idx is None:
|
|
46
|
+
return ""
|
|
47
|
+
for line in lines[1:end_idx]:
|
|
48
|
+
if ":" not in line:
|
|
49
|
+
continue
|
|
50
|
+
key, value = line.split(":", 1)
|
|
51
|
+
if key.strip().lower() == "description":
|
|
52
|
+
return value.strip().strip('"').strip("'")
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
|
57
|
+
skills_lines = []
|
|
58
|
+
for skill in skills:
|
|
59
|
+
description = skill.description or "No description"
|
|
60
|
+
skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})")
|
|
61
|
+
skills_block = "\n".join(skills_lines)
|
|
62
|
+
# Based on openai/codex
|
|
63
|
+
return (
|
|
64
|
+
"## Skills\n"
|
|
65
|
+
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
|
|
66
|
+
"### Available skills\n"
|
|
67
|
+
f"{skills_block}\n"
|
|
68
|
+
"### Skill Rules\n"
|
|
69
|
+
"\n"
|
|
70
|
+
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
|
|
71
|
+
"- 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"
|
|
72
|
+
"- Unavailable: If a skill is missing or unreadable, say so and fallback.\n"
|
|
73
|
+
"### How to use a skill (progressive disclosure):\n"
|
|
74
|
+
" 1) After deciding to use a skill, open its `SKILL.md` and read only what is necessary to follow the workflow.\n"
|
|
75
|
+
" 2) Load only directly referenced files, DO NOT bulk-load everything.\n"
|
|
76
|
+
" 3) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
|
|
77
|
+
" 4) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
|
|
78
|
+
"- Coordination:\n"
|
|
79
|
+
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
|
|
80
|
+
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
|
|
81
|
+
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
|
|
82
|
+
"- Context hygiene:\n"
|
|
83
|
+
" - Keep context small: summarize long sections instead of pasting them, and load extra files only when necessary.\n"
|
|
84
|
+
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
|
|
85
|
+
" - When variants exist (frameworks, providers, domains), select only the relevant reference file(s) and note that choice.\n"
|
|
86
|
+
"- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SkillManager:
|
|
91
|
+
def __init__(self, skills_root: str | None = None) -> None:
|
|
92
|
+
self.skills_root = skills_root or get_astrbot_skills_path()
|
|
93
|
+
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
|
|
94
|
+
os.makedirs(self.skills_root, exist_ok=True)
|
|
95
|
+
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
|
|
96
|
+
|
|
97
|
+
def _load_config(self) -> dict:
|
|
98
|
+
if not os.path.exists(self.config_path):
|
|
99
|
+
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
|
|
100
|
+
return DEFAULT_SKILLS_CONFIG.copy()
|
|
101
|
+
with open(self.config_path, encoding="utf-8") as f:
|
|
102
|
+
data = json.load(f)
|
|
103
|
+
if not isinstance(data, dict) or "skills" not in data:
|
|
104
|
+
return DEFAULT_SKILLS_CONFIG.copy()
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
def _save_config(self, config: dict) -> None:
|
|
108
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
109
|
+
json.dump(config, f, ensure_ascii=False, indent=4)
|
|
110
|
+
|
|
111
|
+
def list_skills(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
active_only: bool = False,
|
|
115
|
+
runtime: str = "local",
|
|
116
|
+
show_sandbox_path: bool = True,
|
|
117
|
+
) -> list[SkillInfo]:
|
|
118
|
+
"""List all skills.
|
|
119
|
+
|
|
120
|
+
show_sandbox_path: If True and runtime is "sandbox",
|
|
121
|
+
return the path as it would appear in the sandbox environment,
|
|
122
|
+
otherwise return the local filesystem path.
|
|
123
|
+
"""
|
|
124
|
+
config = self._load_config()
|
|
125
|
+
skill_configs = config.get("skills", {})
|
|
126
|
+
modified = False
|
|
127
|
+
skills: list[SkillInfo] = []
|
|
128
|
+
|
|
129
|
+
for entry in sorted(Path(self.skills_root).iterdir()):
|
|
130
|
+
if not entry.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
skill_name = entry.name
|
|
133
|
+
skill_md = entry / "SKILL.md"
|
|
134
|
+
if not skill_md.exists():
|
|
135
|
+
continue
|
|
136
|
+
active = skill_configs.get(skill_name, {}).get("active", True)
|
|
137
|
+
if skill_name not in skill_configs:
|
|
138
|
+
skill_configs[skill_name] = {"active": active}
|
|
139
|
+
modified = True
|
|
140
|
+
if active_only and not active:
|
|
141
|
+
continue
|
|
142
|
+
description = ""
|
|
143
|
+
try:
|
|
144
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
145
|
+
description = _parse_frontmatter_description(content)
|
|
146
|
+
except Exception:
|
|
147
|
+
description = ""
|
|
148
|
+
if runtime == "sandbox" and show_sandbox_path:
|
|
149
|
+
path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
|
150
|
+
else:
|
|
151
|
+
path_str = str(skill_md)
|
|
152
|
+
path_str = path_str.replace("\\", "/")
|
|
153
|
+
skills.append(
|
|
154
|
+
SkillInfo(
|
|
155
|
+
name=skill_name,
|
|
156
|
+
description=description,
|
|
157
|
+
path=path_str,
|
|
158
|
+
active=active,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if modified:
|
|
163
|
+
config["skills"] = skill_configs
|
|
164
|
+
self._save_config(config)
|
|
165
|
+
|
|
166
|
+
return skills
|
|
167
|
+
|
|
168
|
+
def set_skill_active(self, name: str, active: bool) -> None:
|
|
169
|
+
config = self._load_config()
|
|
170
|
+
config.setdefault("skills", {})
|
|
171
|
+
config["skills"][name] = {"active": bool(active)}
|
|
172
|
+
self._save_config(config)
|
|
173
|
+
|
|
174
|
+
def delete_skill(self, name: str) -> None:
|
|
175
|
+
skill_dir = Path(self.skills_root) / name
|
|
176
|
+
if skill_dir.exists():
|
|
177
|
+
shutil.rmtree(skill_dir)
|
|
178
|
+
config = self._load_config()
|
|
179
|
+
if name in config.get("skills", {}):
|
|
180
|
+
config["skills"].pop(name, None)
|
|
181
|
+
self._save_config(config)
|
|
182
|
+
|
|
183
|
+
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
|
|
184
|
+
zip_path_obj = Path(zip_path)
|
|
185
|
+
if not zip_path_obj.exists():
|
|
186
|
+
raise FileNotFoundError(f"Zip file not found: {zip_path}")
|
|
187
|
+
if not zipfile.is_zipfile(zip_path):
|
|
188
|
+
raise ValueError("Uploaded file is not a valid zip archive.")
|
|
189
|
+
|
|
190
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
191
|
+
names = [name.replace("\\", "/") for name in zf.namelist()]
|
|
192
|
+
file_names = [name for name in names if name and not name.endswith("/")]
|
|
193
|
+
if not file_names:
|
|
194
|
+
raise ValueError("Zip archive is empty.")
|
|
195
|
+
|
|
196
|
+
top_dirs = {
|
|
197
|
+
PurePosixPath(name).parts[0] for name in file_names if name.strip()
|
|
198
|
+
}
|
|
199
|
+
print(top_dirs)
|
|
200
|
+
if len(top_dirs) != 1:
|
|
201
|
+
raise ValueError("Zip archive must contain a single top-level folder.")
|
|
202
|
+
skill_name = next(iter(top_dirs))
|
|
203
|
+
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
|
|
204
|
+
raise ValueError("Invalid skill folder name.")
|
|
205
|
+
|
|
206
|
+
for name in names:
|
|
207
|
+
if not name:
|
|
208
|
+
continue
|
|
209
|
+
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
|
210
|
+
raise ValueError("Zip archive contains absolute paths.")
|
|
211
|
+
parts = PurePosixPath(name).parts
|
|
212
|
+
if ".." in parts:
|
|
213
|
+
raise ValueError("Zip archive contains invalid relative paths.")
|
|
214
|
+
if parts and parts[0] != skill_name:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"Zip archive contains unexpected top-level entries."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (
|
|
220
|
+
f"{skill_name}/SKILL.md" not in file_names
|
|
221
|
+
and f"{skill_name}/skill.md" not in file_names
|
|
222
|
+
):
|
|
223
|
+
raise ValueError("SKILL.md not found in the skill folder.")
|
|
224
|
+
|
|
225
|
+
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
|
226
|
+
zf.extractall(tmp_dir)
|
|
227
|
+
src_dir = Path(tmp_dir) / skill_name
|
|
228
|
+
if not src_dir.exists():
|
|
229
|
+
raise ValueError("Skill folder not found after extraction.")
|
|
230
|
+
dest_dir = Path(self.skills_root) / skill_name
|
|
231
|
+
if dest_dir.exists():
|
|
232
|
+
if not overwrite:
|
|
233
|
+
raise FileExistsError("Skill already exists.")
|
|
234
|
+
shutil.rmtree(dest_dir)
|
|
235
|
+
shutil.move(str(src_dir), str(dest_dir))
|
|
236
|
+
|
|
237
|
+
self.set_skill_active(skill_name, True)
|
|
238
|
+
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
|
]
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import inspect
|
|
3
3
|
import os
|
|
4
4
|
import traceback
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from quart import request
|
|
@@ -20,11 +21,22 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
|
20
21
|
from astrbot.core.platform.register import platform_cls_map, platform_registry
|
|
21
22
|
from astrbot.core.provider import Provider
|
|
22
23
|
from astrbot.core.provider.register import provider_registry
|
|
23
|
-
from astrbot.core.star.star import star_registry
|
|
24
|
+
from astrbot.core.star.star import StarMetadata, star_registry
|
|
25
|
+
from astrbot.core.utils.astrbot_path import (
|
|
26
|
+
get_astrbot_plugin_data_path,
|
|
27
|
+
)
|
|
24
28
|
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
|
25
29
|
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
|
26
30
|
|
|
27
31
|
from .route import Response, Route, RouteContext
|
|
32
|
+
from .util import (
|
|
33
|
+
config_key_to_folder,
|
|
34
|
+
get_schema_item,
|
|
35
|
+
normalize_rel_path,
|
|
36
|
+
sanitize_filename,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MAX_FILE_BYTES = 500 * 1024 * 1024
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
def try_cast(value: Any, type_: str):
|
|
@@ -106,6 +118,32 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
|
|
|
106
118
|
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
|
|
107
119
|
continue
|
|
108
120
|
|
|
121
|
+
if meta["type"] == "file":
|
|
122
|
+
if not _expect_type(value, list, f"{path}{key}", errors, "list"):
|
|
123
|
+
continue
|
|
124
|
+
for idx, item in enumerate(value):
|
|
125
|
+
if not isinstance(item, str):
|
|
126
|
+
errors.append(
|
|
127
|
+
f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
normalized = normalize_rel_path(item)
|
|
131
|
+
if not normalized or not normalized.startswith("files/"):
|
|
132
|
+
errors.append(
|
|
133
|
+
f"Invalid file path {path}{key}[{idx}]: {item}",
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
key_path = f"{path}{key}"
|
|
137
|
+
expected_folder = config_key_to_folder(key_path)
|
|
138
|
+
expected_prefix = f"files/{expected_folder}/"
|
|
139
|
+
if not normalized.startswith(expected_prefix):
|
|
140
|
+
errors.append(
|
|
141
|
+
f"Invalid file path {path}{key}[{idx}]: {item}",
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
value[idx] = normalized
|
|
145
|
+
continue
|
|
146
|
+
|
|
109
147
|
if meta["type"] == "list" and not isinstance(value, list):
|
|
110
148
|
errors.append(
|
|
111
149
|
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
|
|
@@ -218,6 +256,9 @@ class ConfigRoute(Route):
|
|
|
218
256
|
"/config/default": ("GET", self.get_default_config),
|
|
219
257
|
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
|
|
220
258
|
"/config/plugin/update": ("POST", self.post_plugin_configs),
|
|
259
|
+
"/config/file/upload": ("POST", self.upload_config_file),
|
|
260
|
+
"/config/file/delete": ("POST", self.delete_config_file),
|
|
261
|
+
"/config/file/get": ("GET", self.get_config_file_list),
|
|
221
262
|
"/config/platform/new": ("POST", self.post_new_platform),
|
|
222
263
|
"/config/platform/update": ("POST", self.post_update_platform),
|
|
223
264
|
"/config/platform/delete": ("POST", self.post_delete_platform),
|
|
@@ -876,6 +917,193 @@ class ConfigRoute(Route):
|
|
|
876
917
|
except Exception as e:
|
|
877
918
|
return Response().error(str(e)).__dict__
|
|
878
919
|
|
|
920
|
+
def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
|
|
921
|
+
for plugin_md in star_registry:
|
|
922
|
+
if plugin_md.name == plugin_name:
|
|
923
|
+
return plugin_md
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
def _resolve_config_file_scope(
|
|
927
|
+
self,
|
|
928
|
+
) -> tuple[str, str, str, StarMetadata, AstrBotConfig]:
|
|
929
|
+
"""将请求参数解析为一个明确的配置作用域。
|
|
930
|
+
|
|
931
|
+
当前支持的 scope:
|
|
932
|
+
- scope=plugin:name=<plugin_name>,key=<config_key_path>
|
|
933
|
+
"""
|
|
934
|
+
|
|
935
|
+
scope = request.args.get("scope") or "plugin"
|
|
936
|
+
name = request.args.get("name")
|
|
937
|
+
key_path = request.args.get("key")
|
|
938
|
+
|
|
939
|
+
if scope != "plugin":
|
|
940
|
+
raise ValueError(f"Unsupported scope: {scope}")
|
|
941
|
+
if not name or not key_path:
|
|
942
|
+
raise ValueError("Missing name or key parameter")
|
|
943
|
+
|
|
944
|
+
md = self._get_plugin_metadata_by_name(name)
|
|
945
|
+
if not md or not md.config:
|
|
946
|
+
raise ValueError(f"Plugin {name} not found or has no config")
|
|
947
|
+
|
|
948
|
+
return scope, name, key_path, md, md.config
|
|
949
|
+
|
|
950
|
+
async def upload_config_file(self):
|
|
951
|
+
"""上传文件到插件数据目录(用于某个 file 类型配置项)。"""
|
|
952
|
+
|
|
953
|
+
try:
|
|
954
|
+
scope, name, key_path, md, config = self._resolve_config_file_scope()
|
|
955
|
+
except ValueError as e:
|
|
956
|
+
return Response().error(str(e)).__dict__
|
|
957
|
+
|
|
958
|
+
meta = get_schema_item(getattr(config, "schema", None), key_path)
|
|
959
|
+
if not meta or meta.get("type") != "file":
|
|
960
|
+
return Response().error("Config item not found or not file type").__dict__
|
|
961
|
+
|
|
962
|
+
file_types = meta.get("file_types")
|
|
963
|
+
allowed_exts: list[str] = []
|
|
964
|
+
if isinstance(file_types, list):
|
|
965
|
+
allowed_exts = [
|
|
966
|
+
str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
files = await request.files
|
|
970
|
+
if not files:
|
|
971
|
+
return Response().error("No files uploaded").__dict__
|
|
972
|
+
|
|
973
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
974
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
975
|
+
try:
|
|
976
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
977
|
+
except ValueError:
|
|
978
|
+
return Response().error("Invalid name parameter").__dict__
|
|
979
|
+
plugin_root_path.mkdir(parents=True, exist_ok=True)
|
|
980
|
+
|
|
981
|
+
uploaded: list[str] = []
|
|
982
|
+
folder = config_key_to_folder(key_path)
|
|
983
|
+
errors: list[str] = []
|
|
984
|
+
for file in files.values():
|
|
985
|
+
filename = sanitize_filename(file.filename or "")
|
|
986
|
+
if not filename:
|
|
987
|
+
errors.append("Invalid filename")
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
file_size = getattr(file, "content_length", None)
|
|
991
|
+
if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
|
|
992
|
+
errors.append(f"File too large: {filename}")
|
|
993
|
+
continue
|
|
994
|
+
|
|
995
|
+
ext = os.path.splitext(filename)[1].lstrip(".").lower()
|
|
996
|
+
if allowed_exts and ext not in allowed_exts:
|
|
997
|
+
errors.append(f"Unsupported file type: {filename}")
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
rel_path = f"files/{folder}/{filename}"
|
|
1001
|
+
save_path = (plugin_root_path / rel_path).resolve(strict=False)
|
|
1002
|
+
try:
|
|
1003
|
+
save_path.relative_to(plugin_root_path)
|
|
1004
|
+
except ValueError:
|
|
1005
|
+
errors.append(f"Invalid path: {filename}")
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1009
|
+
await file.save(str(save_path))
|
|
1010
|
+
if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
|
|
1011
|
+
save_path.unlink()
|
|
1012
|
+
errors.append(f"File too large: {filename}")
|
|
1013
|
+
continue
|
|
1014
|
+
uploaded.append(rel_path)
|
|
1015
|
+
|
|
1016
|
+
if not uploaded:
|
|
1017
|
+
return (
|
|
1018
|
+
Response()
|
|
1019
|
+
.error(
|
|
1020
|
+
"Upload failed: " + ", ".join(errors)
|
|
1021
|
+
if errors
|
|
1022
|
+
else "Upload failed",
|
|
1023
|
+
)
|
|
1024
|
+
.__dict__
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__
|
|
1028
|
+
|
|
1029
|
+
async def delete_config_file(self):
|
|
1030
|
+
"""删除插件数据目录中的文件。"""
|
|
1031
|
+
|
|
1032
|
+
scope = request.args.get("scope") or "plugin"
|
|
1033
|
+
name = request.args.get("name")
|
|
1034
|
+
if not name:
|
|
1035
|
+
return Response().error("Missing name parameter").__dict__
|
|
1036
|
+
if scope != "plugin":
|
|
1037
|
+
return Response().error(f"Unsupported scope: {scope}").__dict__
|
|
1038
|
+
|
|
1039
|
+
data = await request.get_json()
|
|
1040
|
+
rel_path = data.get("path") if isinstance(data, dict) else None
|
|
1041
|
+
rel_path = normalize_rel_path(rel_path)
|
|
1042
|
+
if not rel_path or not rel_path.startswith("files/"):
|
|
1043
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1044
|
+
|
|
1045
|
+
md = self._get_plugin_metadata_by_name(name)
|
|
1046
|
+
if not md:
|
|
1047
|
+
return Response().error(f"Plugin {name} not found").__dict__
|
|
1048
|
+
|
|
1049
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
1050
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
1051
|
+
try:
|
|
1052
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
1053
|
+
except ValueError:
|
|
1054
|
+
return Response().error("Invalid name parameter").__dict__
|
|
1055
|
+
target_path = (plugin_root_path / rel_path).resolve(strict=False)
|
|
1056
|
+
try:
|
|
1057
|
+
target_path.relative_to(plugin_root_path)
|
|
1058
|
+
except ValueError:
|
|
1059
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1060
|
+
if target_path.is_file():
|
|
1061
|
+
target_path.unlink()
|
|
1062
|
+
|
|
1063
|
+
return Response().ok(None, "Deleted").__dict__
|
|
1064
|
+
|
|
1065
|
+
async def get_config_file_list(self):
|
|
1066
|
+
"""获取配置项对应目录下的文件列表。"""
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
_, name, key_path, _, config = self._resolve_config_file_scope()
|
|
1070
|
+
except ValueError as e:
|
|
1071
|
+
return Response().error(str(e)).__dict__
|
|
1072
|
+
|
|
1073
|
+
meta = get_schema_item(getattr(config, "schema", None), key_path)
|
|
1074
|
+
if not meta or meta.get("type") != "file":
|
|
1075
|
+
return Response().error("Config item not found or not file type").__dict__
|
|
1076
|
+
|
|
1077
|
+
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
|
|
1078
|
+
plugin_root_path = (storage_root_path / name).resolve(strict=False)
|
|
1079
|
+
try:
|
|
1080
|
+
plugin_root_path.relative_to(storage_root_path)
|
|
1081
|
+
except ValueError:
|
|
1082
|
+
return Response().error("Invalid name parameter").__dict__
|
|
1083
|
+
|
|
1084
|
+
folder = config_key_to_folder(key_path)
|
|
1085
|
+
target_dir = (plugin_root_path / "files" / folder).resolve(strict=False)
|
|
1086
|
+
try:
|
|
1087
|
+
target_dir.relative_to(plugin_root_path)
|
|
1088
|
+
except ValueError:
|
|
1089
|
+
return Response().error("Invalid path parameter").__dict__
|
|
1090
|
+
|
|
1091
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
1092
|
+
return Response().ok({"files": []}).__dict__
|
|
1093
|
+
|
|
1094
|
+
files: list[str] = []
|
|
1095
|
+
for path in target_dir.rglob("*"):
|
|
1096
|
+
if not path.is_file():
|
|
1097
|
+
continue
|
|
1098
|
+
try:
|
|
1099
|
+
rel_path = path.relative_to(plugin_root_path).as_posix()
|
|
1100
|
+
except ValueError:
|
|
1101
|
+
continue
|
|
1102
|
+
if rel_path.startswith("files/"):
|
|
1103
|
+
files.append(rel_path)
|
|
1104
|
+
|
|
1105
|
+
return Response().ok({"files": files}).__dict__
|
|
1106
|
+
|
|
879
1107
|
async def post_new_platform(self):
|
|
880
1108
|
new_platform_config = await request.json
|
|
881
1109
|
|
|
@@ -1130,8 +1358,14 @@ class ConfigRoute(Route):
|
|
|
1130
1358
|
raise ValueError(f"插件 {plugin_name} 不存在")
|
|
1131
1359
|
if not md.config:
|
|
1132
1360
|
raise ValueError(f"插件 {plugin_name} 没有注册配置")
|
|
1361
|
+
assert md.config is not None
|
|
1133
1362
|
|
|
1134
1363
|
try:
|
|
1135
|
-
|
|
1364
|
+
errors, post_configs = validate_config(
|
|
1365
|
+
post_configs, getattr(md.config, "schema", {}), is_core=False
|
|
1366
|
+
)
|
|
1367
|
+
if errors:
|
|
1368
|
+
raise ValueError(f"格式校验未通过: {errors}")
|
|
1369
|
+
md.config.save_config(post_configs)
|
|
1136
1370
|
except Exception as e:
|
|
1137
1371
|
raise e
|
|
@@ -57,6 +57,7 @@ class PersonaRoute(Route):
|
|
|
57
57
|
"system_prompt": persona.system_prompt,
|
|
58
58
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
59
59
|
"tools": persona.tools,
|
|
60
|
+
"skills": persona.skills,
|
|
60
61
|
"folder_id": persona.folder_id,
|
|
61
62
|
"sort_order": persona.sort_order,
|
|
62
63
|
"created_at": persona.created_at.isoformat()
|
|
@@ -96,6 +97,7 @@ class PersonaRoute(Route):
|
|
|
96
97
|
"system_prompt": persona.system_prompt,
|
|
97
98
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
98
99
|
"tools": persona.tools,
|
|
100
|
+
"skills": persona.skills,
|
|
99
101
|
"folder_id": persona.folder_id,
|
|
100
102
|
"sort_order": persona.sort_order,
|
|
101
103
|
"created_at": persona.created_at.isoformat()
|
|
@@ -120,6 +122,7 @@ class PersonaRoute(Route):
|
|
|
120
122
|
system_prompt = data.get("system_prompt", "").strip()
|
|
121
123
|
begin_dialogs = data.get("begin_dialogs", [])
|
|
122
124
|
tools = data.get("tools")
|
|
125
|
+
skills = data.get("skills")
|
|
123
126
|
folder_id = data.get("folder_id") # None 表示根目录
|
|
124
127
|
sort_order = data.get("sort_order", 0)
|
|
125
128
|
|
|
@@ -142,6 +145,7 @@ class PersonaRoute(Route):
|
|
|
142
145
|
system_prompt=system_prompt,
|
|
143
146
|
begin_dialogs=begin_dialogs if begin_dialogs else None,
|
|
144
147
|
tools=tools if tools else None,
|
|
148
|
+
skills=skills if skills else None,
|
|
145
149
|
folder_id=folder_id,
|
|
146
150
|
sort_order=sort_order,
|
|
147
151
|
)
|
|
@@ -156,6 +160,7 @@ class PersonaRoute(Route):
|
|
|
156
160
|
"system_prompt": persona.system_prompt,
|
|
157
161
|
"begin_dialogs": persona.begin_dialogs or [],
|
|
158
162
|
"tools": persona.tools or [],
|
|
163
|
+
"skills": persona.skills or [],
|
|
159
164
|
"folder_id": persona.folder_id,
|
|
160
165
|
"sort_order": persona.sort_order,
|
|
161
166
|
"created_at": persona.created_at.isoformat()
|
|
@@ -183,6 +188,7 @@ class PersonaRoute(Route):
|
|
|
183
188
|
system_prompt = data.get("system_prompt")
|
|
184
189
|
begin_dialogs = data.get("begin_dialogs")
|
|
185
190
|
tools = data.get("tools")
|
|
191
|
+
skills = data.get("skills")
|
|
186
192
|
|
|
187
193
|
if not persona_id:
|
|
188
194
|
return Response().error("缺少必要参数: persona_id").__dict__
|
|
@@ -200,6 +206,7 @@ class PersonaRoute(Route):
|
|
|
200
206
|
system_prompt=system_prompt,
|
|
201
207
|
begin_dialogs=begin_dialogs,
|
|
202
208
|
tools=tools,
|
|
209
|
+
skills=skills,
|
|
203
210
|
)
|
|
204
211
|
|
|
205
212
|
return Response().ok({"message": "人格更新成功"}).__dict__
|