AstrBot 4.10.2__py3-none-any.whl → 4.10.4__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/long_term_memory.py +186 -0
- astrbot/builtin_stars/astrbot/main.py +120 -0
- astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
- astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
- astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
- astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
- astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
- astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
- astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
- astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
- astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
- astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
- astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
- astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
- astrbot/builtin_stars/builtin_commands/main.py +237 -0
- astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/main.py +536 -0
- astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
- astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
- astrbot/builtin_stars/reminder/main.py +266 -0
- astrbot/builtin_stars/reminder/metadata.yaml +4 -0
- astrbot/builtin_stars/session_controller/main.py +114 -0
- astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
- astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
- astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
- astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
- astrbot/builtin_stars/web_searcher/main.py +436 -0
- astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/message.py +32 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +26 -8
- astrbot/core/astr_agent_hooks.py +6 -0
- astrbot/core/backup/__init__.py +26 -0
- astrbot/core/backup/constants.py +77 -0
- astrbot/core/backup/exporter.py +477 -0
- astrbot/core/backup/importer.py +761 -0
- astrbot/core/config/astrbot_config.py +2 -0
- astrbot/core/config/default.py +47 -6
- astrbot/core/knowledge_base/chunking/recursive.py +10 -2
- astrbot/core/log.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
- astrbot/core/pipeline/result_decorate/stage.py +65 -57
- astrbot/core/pipeline/waking_check/stage.py +31 -3
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
- astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
- astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
- astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
- astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
- astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
- astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
- astrbot/core/provider/entities.py +41 -10
- astrbot/core/provider/provider.py +3 -1
- astrbot/core/provider/sources/anthropic_source.py +140 -30
- astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
- astrbot/core/provider/sources/gemini_source.py +112 -29
- astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
- astrbot/core/provider/sources/openai_source.py +93 -56
- astrbot/core/provider/sources/xai_source.py +29 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
- astrbot/core/star/context.py +1 -1
- astrbot/core/star/star_manager.py +52 -13
- astrbot/core/utils/astrbot_path.py +34 -0
- astrbot/core/utils/pip_installer.py +20 -1
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/backup.py +1093 -0
- astrbot/dashboard/routes/config.py +45 -0
- astrbot/dashboard/routes/log.py +44 -10
- astrbot/dashboard/server.py +9 -1
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/RECORD +84 -44
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
- {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""AstrBot 数据导入器
|
|
2
|
+
|
|
3
|
+
负责从 ZIP 备份文件恢复所有数据。
|
|
4
|
+
导入时进行版本校验:
|
|
5
|
+
- 主版本(前两位)不同时直接拒绝导入
|
|
6
|
+
- 小版本(第三位)不同时提示警告,用户可选择强制导入
|
|
7
|
+
- 版本匹配时也需要用户确认
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import delete
|
|
20
|
+
|
|
21
|
+
from astrbot.core import logger
|
|
22
|
+
from astrbot.core.config.default import VERSION
|
|
23
|
+
from astrbot.core.db import BaseDatabase
|
|
24
|
+
from astrbot.core.utils.astrbot_path import (
|
|
25
|
+
get_astrbot_data_path,
|
|
26
|
+
get_astrbot_knowledge_base_path,
|
|
27
|
+
)
|
|
28
|
+
from astrbot.core.utils.version_comparator import VersionComparator
|
|
29
|
+
|
|
30
|
+
# 从共享常量模块导入
|
|
31
|
+
from .constants import (
|
|
32
|
+
KB_METADATA_MODELS,
|
|
33
|
+
MAIN_DB_MODELS,
|
|
34
|
+
get_backup_directories,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_major_version(version_str: str) -> str:
|
|
42
|
+
"""提取版本的主版本部分(前两位)
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
version_str: 版本字符串,如 "4.9.1", "4.10.0-beta"
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
主版本字符串,如 "4.9", "4.10"
|
|
49
|
+
"""
|
|
50
|
+
if not version_str:
|
|
51
|
+
return "0.0"
|
|
52
|
+
# 移除 v 前缀和预发布标签
|
|
53
|
+
version = version_str.lower().replace("v", "").split("-")[0].split("+")[0]
|
|
54
|
+
parts = [p for p in version.split(".") if p] # 过滤空字符串
|
|
55
|
+
if len(parts) >= 2:
|
|
56
|
+
return f"{parts[0]}.{parts[1]}"
|
|
57
|
+
elif len(parts) == 1 and parts[0]:
|
|
58
|
+
return f"{parts[0]}.0"
|
|
59
|
+
return "0.0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
|
|
63
|
+
KB_PATH = get_astrbot_knowledge_base_path()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ImportPreCheckResult:
|
|
68
|
+
"""导入预检查结果
|
|
69
|
+
|
|
70
|
+
用于在实际导入前检查备份文件的版本兼容性,
|
|
71
|
+
并返回确认信息让用户决定是否继续导入。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# 检查是否通过(文件有效且版本可导入)
|
|
75
|
+
valid: bool = False
|
|
76
|
+
# 是否可以导入(版本兼容)
|
|
77
|
+
can_import: bool = False
|
|
78
|
+
# 版本状态: match(完全匹配), minor_diff(小版本差异), major_diff(主版本不同,拒绝)
|
|
79
|
+
version_status: str = ""
|
|
80
|
+
# 备份文件中的 AstrBot 版本
|
|
81
|
+
backup_version: str = ""
|
|
82
|
+
# 当前运行的 AstrBot 版本
|
|
83
|
+
current_version: str = VERSION
|
|
84
|
+
# 备份创建时间
|
|
85
|
+
backup_time: str = ""
|
|
86
|
+
# 确认消息(显示给用户)
|
|
87
|
+
confirm_message: str = ""
|
|
88
|
+
# 警告消息列表
|
|
89
|
+
warnings: list[str] = field(default_factory=list)
|
|
90
|
+
# 错误消息(如果检查失败)
|
|
91
|
+
error: str = ""
|
|
92
|
+
# 备份包含的内容摘要
|
|
93
|
+
backup_summary: dict = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"valid": self.valid,
|
|
98
|
+
"can_import": self.can_import,
|
|
99
|
+
"version_status": self.version_status,
|
|
100
|
+
"backup_version": self.backup_version,
|
|
101
|
+
"current_version": self.current_version,
|
|
102
|
+
"backup_time": self.backup_time,
|
|
103
|
+
"confirm_message": self.confirm_message,
|
|
104
|
+
"warnings": self.warnings,
|
|
105
|
+
"error": self.error,
|
|
106
|
+
"backup_summary": self.backup_summary,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ImportResult:
|
|
111
|
+
"""导入结果"""
|
|
112
|
+
|
|
113
|
+
def __init__(self):
|
|
114
|
+
self.success = True
|
|
115
|
+
self.imported_tables: dict[str, int] = {}
|
|
116
|
+
self.imported_files: dict[str, int] = {}
|
|
117
|
+
self.imported_directories: dict[str, int] = {}
|
|
118
|
+
self.warnings: list[str] = []
|
|
119
|
+
self.errors: list[str] = []
|
|
120
|
+
|
|
121
|
+
def add_warning(self, msg: str) -> None:
|
|
122
|
+
self.warnings.append(msg)
|
|
123
|
+
logger.warning(msg)
|
|
124
|
+
|
|
125
|
+
def add_error(self, msg: str) -> None:
|
|
126
|
+
self.errors.append(msg)
|
|
127
|
+
self.success = False
|
|
128
|
+
logger.error(msg)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict:
|
|
131
|
+
return {
|
|
132
|
+
"success": self.success,
|
|
133
|
+
"imported_tables": self.imported_tables,
|
|
134
|
+
"imported_files": self.imported_files,
|
|
135
|
+
"imported_directories": self.imported_directories,
|
|
136
|
+
"warnings": self.warnings,
|
|
137
|
+
"errors": self.errors,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AstrBotImporter:
|
|
142
|
+
"""AstrBot 数据导入器
|
|
143
|
+
|
|
144
|
+
导入备份文件中的所有数据,包括:
|
|
145
|
+
- 主数据库所有表
|
|
146
|
+
- 知识库元数据和文档
|
|
147
|
+
- 配置文件
|
|
148
|
+
- 附件文件
|
|
149
|
+
- 知识库多媒体文件
|
|
150
|
+
- 插件目录(data/plugins)
|
|
151
|
+
- 插件数据目录(data/plugin_data)
|
|
152
|
+
- 配置目录(data/config)
|
|
153
|
+
- T2I 模板目录(data/t2i_templates)
|
|
154
|
+
- WebChat 数据目录(data/webchat)
|
|
155
|
+
- 临时文件目录(data/temp)
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
main_db: BaseDatabase,
|
|
161
|
+
kb_manager: "KnowledgeBaseManager | None" = None,
|
|
162
|
+
config_path: str = CMD_CONFIG_FILE_PATH,
|
|
163
|
+
kb_root_dir: str = KB_PATH,
|
|
164
|
+
):
|
|
165
|
+
self.main_db = main_db
|
|
166
|
+
self.kb_manager = kb_manager
|
|
167
|
+
self.config_path = config_path
|
|
168
|
+
self.kb_root_dir = kb_root_dir
|
|
169
|
+
|
|
170
|
+
def pre_check(self, zip_path: str) -> ImportPreCheckResult:
|
|
171
|
+
"""预检查备份文件
|
|
172
|
+
|
|
173
|
+
在实际导入前检查备份文件的有效性和版本兼容性。
|
|
174
|
+
返回检查结果供前端显示确认对话框。
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
zip_path: ZIP 备份文件路径
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
ImportPreCheckResult: 预检查结果
|
|
181
|
+
"""
|
|
182
|
+
result = ImportPreCheckResult()
|
|
183
|
+
result.current_version = VERSION
|
|
184
|
+
|
|
185
|
+
if not os.path.exists(zip_path):
|
|
186
|
+
result.error = f"备份文件不存在: {zip_path}"
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
191
|
+
# 读取 manifest
|
|
192
|
+
try:
|
|
193
|
+
manifest_data = zf.read("manifest.json")
|
|
194
|
+
manifest = json.loads(manifest_data)
|
|
195
|
+
except KeyError:
|
|
196
|
+
result.error = "备份文件缺少 manifest.json,不是有效的 AstrBot 备份"
|
|
197
|
+
return result
|
|
198
|
+
except json.JSONDecodeError as e:
|
|
199
|
+
result.error = f"manifest.json 格式错误: {e}"
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
# 提取基本信息
|
|
203
|
+
result.backup_version = manifest.get("astrbot_version", "未知")
|
|
204
|
+
result.backup_time = manifest.get("exported_at", "未知")
|
|
205
|
+
result.valid = True
|
|
206
|
+
|
|
207
|
+
# 构建备份摘要
|
|
208
|
+
result.backup_summary = {
|
|
209
|
+
"tables": list(manifest.get("tables", {}).keys()),
|
|
210
|
+
"has_knowledge_bases": manifest.get("has_knowledge_bases", False),
|
|
211
|
+
"has_config": manifest.get("has_config", False),
|
|
212
|
+
"directories": manifest.get("directories", []),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# 检查版本兼容性
|
|
216
|
+
version_check = self._check_version_compatibility(result.backup_version)
|
|
217
|
+
result.version_status = version_check["status"]
|
|
218
|
+
result.can_import = version_check["can_import"]
|
|
219
|
+
|
|
220
|
+
# 版本信息由前端根据 version_status 和 i18n 生成显示
|
|
221
|
+
# 不再将版本消息添加到 warnings 列表中,避免中文硬编码
|
|
222
|
+
# warnings 列表保留用于其他非版本相关的警告
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
except zipfile.BadZipFile:
|
|
227
|
+
result.error = "无效的 ZIP 文件"
|
|
228
|
+
return result
|
|
229
|
+
except Exception as e:
|
|
230
|
+
result.error = f"检查备份文件失败: {e}"
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
def _check_version_compatibility(self, backup_version: str) -> dict:
|
|
234
|
+
"""检查版本兼容性
|
|
235
|
+
|
|
236
|
+
规则:
|
|
237
|
+
- 主版本(前两位,如 4.9)必须一致,否则拒绝
|
|
238
|
+
- 小版本(第三位,如 4.9.1 vs 4.9.2)不同时,警告但允许导入
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
dict: {status, can_import, message}
|
|
242
|
+
"""
|
|
243
|
+
if not backup_version:
|
|
244
|
+
return {
|
|
245
|
+
"status": "major_diff",
|
|
246
|
+
"can_import": False,
|
|
247
|
+
"message": "备份文件缺少版本信息",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# 提取主版本(前两位)进行比较
|
|
251
|
+
backup_major = _get_major_version(backup_version)
|
|
252
|
+
current_major = _get_major_version(VERSION)
|
|
253
|
+
|
|
254
|
+
# 比较主版本
|
|
255
|
+
if VersionComparator.compare_version(backup_major, current_major) != 0:
|
|
256
|
+
return {
|
|
257
|
+
"status": "major_diff",
|
|
258
|
+
"can_import": False,
|
|
259
|
+
"message": (
|
|
260
|
+
f"主版本不兼容: 备份版本 {backup_version}, 当前版本 {VERSION}。"
|
|
261
|
+
f"跨主版本导入可能导致数据损坏,请使用相同主版本的 AstrBot。"
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# 比较完整版本
|
|
266
|
+
version_cmp = VersionComparator.compare_version(backup_version, VERSION)
|
|
267
|
+
if version_cmp != 0:
|
|
268
|
+
return {
|
|
269
|
+
"status": "minor_diff",
|
|
270
|
+
"can_import": True,
|
|
271
|
+
"message": (
|
|
272
|
+
f"小版本差异: 备份版本 {backup_version}, 当前版本 {VERSION}。"
|
|
273
|
+
),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"status": "match",
|
|
278
|
+
"can_import": True,
|
|
279
|
+
"message": "版本匹配",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async def import_all(
|
|
283
|
+
self,
|
|
284
|
+
zip_path: str,
|
|
285
|
+
mode: str = "replace", # "replace" 清空后导入
|
|
286
|
+
progress_callback: Any | None = None,
|
|
287
|
+
) -> ImportResult:
|
|
288
|
+
"""从 ZIP 文件导入所有数据
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
zip_path: ZIP 备份文件路径
|
|
292
|
+
mode: 导入模式,目前仅支持 "replace"(清空后导入)
|
|
293
|
+
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
ImportResult: 导入结果
|
|
297
|
+
"""
|
|
298
|
+
result = ImportResult()
|
|
299
|
+
|
|
300
|
+
if not os.path.exists(zip_path):
|
|
301
|
+
result.add_error(f"备份文件不存在: {zip_path}")
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
logger.info(f"开始从 {zip_path} 导入备份")
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
308
|
+
# 1. 读取并验证 manifest
|
|
309
|
+
if progress_callback:
|
|
310
|
+
await progress_callback("validate", 0, 100, "正在验证备份文件...")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
manifest_data = zf.read("manifest.json")
|
|
314
|
+
manifest = json.loads(manifest_data)
|
|
315
|
+
except KeyError:
|
|
316
|
+
result.add_error("备份文件缺少 manifest.json")
|
|
317
|
+
return result
|
|
318
|
+
except json.JSONDecodeError as e:
|
|
319
|
+
result.add_error(f"manifest.json 格式错误: {e}")
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
# 版本校验
|
|
323
|
+
try:
|
|
324
|
+
self._validate_version(manifest)
|
|
325
|
+
except ValueError as e:
|
|
326
|
+
result.add_error(str(e))
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
if progress_callback:
|
|
330
|
+
await progress_callback("validate", 100, 100, "验证完成")
|
|
331
|
+
|
|
332
|
+
# 2. 导入主数据库
|
|
333
|
+
if progress_callback:
|
|
334
|
+
await progress_callback("main_db", 0, 100, "正在导入主数据库...")
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
main_data_content = zf.read("databases/main_db.json")
|
|
338
|
+
main_data = json.loads(main_data_content)
|
|
339
|
+
|
|
340
|
+
if mode == "replace":
|
|
341
|
+
await self._clear_main_db()
|
|
342
|
+
|
|
343
|
+
imported = await self._import_main_database(main_data)
|
|
344
|
+
result.imported_tables.update(imported)
|
|
345
|
+
except Exception as e:
|
|
346
|
+
result.add_error(f"导入主数据库失败: {e}")
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
if progress_callback:
|
|
350
|
+
await progress_callback("main_db", 100, 100, "主数据库导入完成")
|
|
351
|
+
|
|
352
|
+
# 3. 导入知识库
|
|
353
|
+
if self.kb_manager and "databases/kb_metadata.json" in zf.namelist():
|
|
354
|
+
if progress_callback:
|
|
355
|
+
await progress_callback("kb", 0, 100, "正在导入知识库...")
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
kb_meta_content = zf.read("databases/kb_metadata.json")
|
|
359
|
+
kb_meta_data = json.loads(kb_meta_content)
|
|
360
|
+
|
|
361
|
+
if mode == "replace":
|
|
362
|
+
await self._clear_kb_data()
|
|
363
|
+
|
|
364
|
+
await self._import_knowledge_bases(zf, kb_meta_data, result)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
result.add_warning(f"导入知识库失败: {e}")
|
|
367
|
+
|
|
368
|
+
if progress_callback:
|
|
369
|
+
await progress_callback("kb", 100, 100, "知识库导入完成")
|
|
370
|
+
|
|
371
|
+
# 4. 导入配置文件
|
|
372
|
+
if progress_callback:
|
|
373
|
+
await progress_callback("config", 0, 100, "正在导入配置文件...")
|
|
374
|
+
|
|
375
|
+
if "config/cmd_config.json" in zf.namelist():
|
|
376
|
+
try:
|
|
377
|
+
config_content = zf.read("config/cmd_config.json")
|
|
378
|
+
# 备份现有配置
|
|
379
|
+
if os.path.exists(self.config_path):
|
|
380
|
+
backup_path = f"{self.config_path}.bak"
|
|
381
|
+
shutil.copy2(self.config_path, backup_path)
|
|
382
|
+
|
|
383
|
+
with open(self.config_path, "wb") as f:
|
|
384
|
+
f.write(config_content)
|
|
385
|
+
result.imported_files["config"] = 1
|
|
386
|
+
except Exception as e:
|
|
387
|
+
result.add_warning(f"导入配置文件失败: {e}")
|
|
388
|
+
|
|
389
|
+
if progress_callback:
|
|
390
|
+
await progress_callback("config", 100, 100, "配置文件导入完成")
|
|
391
|
+
|
|
392
|
+
# 5. 导入附件文件
|
|
393
|
+
if progress_callback:
|
|
394
|
+
await progress_callback("attachments", 0, 100, "正在导入附件...")
|
|
395
|
+
|
|
396
|
+
attachment_count = await self._import_attachments(
|
|
397
|
+
zf, main_data.get("attachments", [])
|
|
398
|
+
)
|
|
399
|
+
result.imported_files["attachments"] = attachment_count
|
|
400
|
+
|
|
401
|
+
if progress_callback:
|
|
402
|
+
await progress_callback("attachments", 100, 100, "附件导入完成")
|
|
403
|
+
|
|
404
|
+
# 6. 导入插件和其他目录
|
|
405
|
+
if progress_callback:
|
|
406
|
+
await progress_callback(
|
|
407
|
+
"directories", 0, 100, "正在导入插件和数据目录..."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
dir_stats = await self._import_directories(zf, manifest, result)
|
|
411
|
+
result.imported_directories = dir_stats
|
|
412
|
+
|
|
413
|
+
if progress_callback:
|
|
414
|
+
await progress_callback("directories", 100, 100, "目录导入完成")
|
|
415
|
+
|
|
416
|
+
logger.info(f"备份导入完成: {result.to_dict()}")
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
except zipfile.BadZipFile:
|
|
420
|
+
result.add_error("无效的 ZIP 文件")
|
|
421
|
+
return result
|
|
422
|
+
except Exception as e:
|
|
423
|
+
result.add_error(f"导入失败: {e}")
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def _validate_version(self, manifest: dict) -> None:
|
|
427
|
+
"""验证版本兼容性 - 仅允许相同主版本导入
|
|
428
|
+
|
|
429
|
+
注意:此方法仅在 import_all 中调用,用于双重校验。
|
|
430
|
+
前端应先调用 pre_check 获取详细的版本信息并让用户确认。
|
|
431
|
+
"""
|
|
432
|
+
backup_version = manifest.get("astrbot_version")
|
|
433
|
+
if not backup_version:
|
|
434
|
+
raise ValueError("备份文件缺少版本信息")
|
|
435
|
+
|
|
436
|
+
# 使用新的版本兼容性检查
|
|
437
|
+
version_check = self._check_version_compatibility(backup_version)
|
|
438
|
+
|
|
439
|
+
if version_check["status"] == "major_diff":
|
|
440
|
+
raise ValueError(version_check["message"])
|
|
441
|
+
|
|
442
|
+
# minor_diff 和 match 都允许导入
|
|
443
|
+
if version_check["status"] == "minor_diff":
|
|
444
|
+
logger.warning(f"版本差异警告: {version_check['message']}")
|
|
445
|
+
|
|
446
|
+
async def _clear_main_db(self) -> None:
|
|
447
|
+
"""清空主数据库所有表"""
|
|
448
|
+
async with self.main_db.get_db() as session:
|
|
449
|
+
async with session.begin():
|
|
450
|
+
for table_name, model_class in MAIN_DB_MODELS.items():
|
|
451
|
+
try:
|
|
452
|
+
await session.execute(delete(model_class))
|
|
453
|
+
logger.debug(f"已清空表 {table_name}")
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.warning(f"清空表 {table_name} 失败: {e}")
|
|
456
|
+
|
|
457
|
+
async def _clear_kb_data(self) -> None:
|
|
458
|
+
"""清空知识库数据"""
|
|
459
|
+
if not self.kb_manager:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# 清空知识库元数据表
|
|
463
|
+
async with self.kb_manager.kb_db.get_db() as session:
|
|
464
|
+
async with session.begin():
|
|
465
|
+
for table_name, model_class in KB_METADATA_MODELS.items():
|
|
466
|
+
try:
|
|
467
|
+
await session.execute(delete(model_class))
|
|
468
|
+
logger.debug(f"已清空知识库表 {table_name}")
|
|
469
|
+
except Exception as e:
|
|
470
|
+
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
|
|
471
|
+
|
|
472
|
+
# 删除知识库文件目录
|
|
473
|
+
for kb_id in list(self.kb_manager.kb_insts.keys()):
|
|
474
|
+
try:
|
|
475
|
+
kb_helper = self.kb_manager.kb_insts[kb_id]
|
|
476
|
+
await kb_helper.terminate()
|
|
477
|
+
if kb_helper.kb_dir.exists():
|
|
478
|
+
shutil.rmtree(kb_helper.kb_dir)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logger.warning(f"清理知识库 {kb_id} 失败: {e}")
|
|
481
|
+
|
|
482
|
+
self.kb_manager.kb_insts.clear()
|
|
483
|
+
|
|
484
|
+
async def _import_main_database(
|
|
485
|
+
self, data: dict[str, list[dict]]
|
|
486
|
+
) -> dict[str, int]:
|
|
487
|
+
"""导入主数据库数据"""
|
|
488
|
+
imported: dict[str, int] = {}
|
|
489
|
+
|
|
490
|
+
async with self.main_db.get_db() as session:
|
|
491
|
+
async with session.begin():
|
|
492
|
+
for table_name, rows in data.items():
|
|
493
|
+
model_class = MAIN_DB_MODELS.get(table_name)
|
|
494
|
+
if not model_class:
|
|
495
|
+
logger.warning(f"未知的表: {table_name}")
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
count = 0
|
|
499
|
+
for row in rows:
|
|
500
|
+
try:
|
|
501
|
+
# 转换 datetime 字符串为 datetime 对象
|
|
502
|
+
row = self._convert_datetime_fields(row, model_class)
|
|
503
|
+
obj = model_class(**row)
|
|
504
|
+
session.add(obj)
|
|
505
|
+
count += 1
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.warning(f"导入记录到 {table_name} 失败: {e}")
|
|
508
|
+
|
|
509
|
+
imported[table_name] = count
|
|
510
|
+
logger.debug(f"导入表 {table_name}: {count} 条记录")
|
|
511
|
+
|
|
512
|
+
return imported
|
|
513
|
+
|
|
514
|
+
async def _import_knowledge_bases(
|
|
515
|
+
self,
|
|
516
|
+
zf: zipfile.ZipFile,
|
|
517
|
+
kb_meta_data: dict[str, list[dict]],
|
|
518
|
+
result: ImportResult,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""导入知识库数据"""
|
|
521
|
+
if not self.kb_manager:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
# 1. 导入知识库元数据
|
|
525
|
+
async with self.kb_manager.kb_db.get_db() as session:
|
|
526
|
+
async with session.begin():
|
|
527
|
+
for table_name, rows in kb_meta_data.items():
|
|
528
|
+
model_class = KB_METADATA_MODELS.get(table_name)
|
|
529
|
+
if not model_class:
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
count = 0
|
|
533
|
+
for row in rows:
|
|
534
|
+
try:
|
|
535
|
+
row = self._convert_datetime_fields(row, model_class)
|
|
536
|
+
obj = model_class(**row)
|
|
537
|
+
session.add(obj)
|
|
538
|
+
count += 1
|
|
539
|
+
except Exception as e:
|
|
540
|
+
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
|
|
541
|
+
|
|
542
|
+
result.imported_tables[f"kb_{table_name}"] = count
|
|
543
|
+
|
|
544
|
+
# 2. 导入每个知识库的文档和文件
|
|
545
|
+
for kb_data in kb_meta_data.get("knowledge_bases", []):
|
|
546
|
+
kb_id = kb_data.get("kb_id")
|
|
547
|
+
if not kb_id:
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
# 创建知识库目录
|
|
551
|
+
kb_dir = Path(self.kb_root_dir) / kb_id
|
|
552
|
+
kb_dir.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
|
|
554
|
+
# 导入文档数据
|
|
555
|
+
doc_path = f"databases/kb_{kb_id}/documents.json"
|
|
556
|
+
if doc_path in zf.namelist():
|
|
557
|
+
try:
|
|
558
|
+
doc_content = zf.read(doc_path)
|
|
559
|
+
doc_data = json.loads(doc_content)
|
|
560
|
+
|
|
561
|
+
# 导入到文档存储数据库
|
|
562
|
+
await self._import_kb_documents(kb_id, doc_data)
|
|
563
|
+
except Exception as e:
|
|
564
|
+
result.add_warning(f"导入知识库 {kb_id} 的文档失败: {e}")
|
|
565
|
+
|
|
566
|
+
# 导入 FAISS 索引
|
|
567
|
+
faiss_path = f"databases/kb_{kb_id}/index.faiss"
|
|
568
|
+
if faiss_path in zf.namelist():
|
|
569
|
+
try:
|
|
570
|
+
target_path = kb_dir / "index.faiss"
|
|
571
|
+
with zf.open(faiss_path) as src, open(target_path, "wb") as dst:
|
|
572
|
+
dst.write(src.read())
|
|
573
|
+
except Exception as e:
|
|
574
|
+
result.add_warning(f"导入知识库 {kb_id} 的 FAISS 索引失败: {e}")
|
|
575
|
+
|
|
576
|
+
# 导入媒体文件
|
|
577
|
+
media_prefix = f"files/kb_media/{kb_id}/"
|
|
578
|
+
for name in zf.namelist():
|
|
579
|
+
if name.startswith(media_prefix):
|
|
580
|
+
try:
|
|
581
|
+
rel_path = name[len(media_prefix) :]
|
|
582
|
+
target_path = kb_dir / rel_path
|
|
583
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
584
|
+
with zf.open(name) as src, open(target_path, "wb") as dst:
|
|
585
|
+
dst.write(src.read())
|
|
586
|
+
except Exception as e:
|
|
587
|
+
result.add_warning(f"导入媒体文件 {name} 失败: {e}")
|
|
588
|
+
|
|
589
|
+
# 3. 重新加载知识库实例
|
|
590
|
+
await self.kb_manager.load_kbs()
|
|
591
|
+
|
|
592
|
+
async def _import_kb_documents(self, kb_id: str, doc_data: dict) -> None:
|
|
593
|
+
"""导入知识库文档到向量数据库"""
|
|
594
|
+
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
|
|
595
|
+
|
|
596
|
+
kb_dir = Path(self.kb_root_dir) / kb_id
|
|
597
|
+
doc_db_path = kb_dir / "doc.db"
|
|
598
|
+
|
|
599
|
+
# 初始化文档存储
|
|
600
|
+
doc_storage = DocumentStorage(str(doc_db_path))
|
|
601
|
+
await doc_storage.initialize()
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
documents = doc_data.get("documents", [])
|
|
605
|
+
for doc in documents:
|
|
606
|
+
try:
|
|
607
|
+
await doc_storage.insert_document(
|
|
608
|
+
doc_id=doc.get("doc_id", ""),
|
|
609
|
+
text=doc.get("text", ""),
|
|
610
|
+
metadata=json.loads(doc.get("metadata", "{}")),
|
|
611
|
+
)
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.warning(f"导入文档块失败: {e}")
|
|
614
|
+
finally:
|
|
615
|
+
await doc_storage.close()
|
|
616
|
+
|
|
617
|
+
async def _import_attachments(
|
|
618
|
+
self,
|
|
619
|
+
zf: zipfile.ZipFile,
|
|
620
|
+
attachments: list[dict],
|
|
621
|
+
) -> int:
|
|
622
|
+
"""导入附件文件"""
|
|
623
|
+
count = 0
|
|
624
|
+
|
|
625
|
+
attachments_dir = Path(self.config_path).parent / "attachments"
|
|
626
|
+
attachments_dir.mkdir(parents=True, exist_ok=True)
|
|
627
|
+
|
|
628
|
+
attachment_prefix = "files/attachments/"
|
|
629
|
+
for name in zf.namelist():
|
|
630
|
+
if name.startswith(attachment_prefix) and name != attachment_prefix:
|
|
631
|
+
try:
|
|
632
|
+
# 从附件记录中找到原始路径
|
|
633
|
+
attachment_id = os.path.splitext(os.path.basename(name))[0]
|
|
634
|
+
original_path = None
|
|
635
|
+
for att in attachments:
|
|
636
|
+
if att.get("attachment_id") == attachment_id:
|
|
637
|
+
original_path = att.get("path")
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
if original_path:
|
|
641
|
+
target_path = Path(original_path)
|
|
642
|
+
else:
|
|
643
|
+
target_path = attachments_dir / os.path.basename(name)
|
|
644
|
+
|
|
645
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
646
|
+
with zf.open(name) as src, open(target_path, "wb") as dst:
|
|
647
|
+
dst.write(src.read())
|
|
648
|
+
count += 1
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.warning(f"导入附件 {name} 失败: {e}")
|
|
651
|
+
|
|
652
|
+
return count
|
|
653
|
+
|
|
654
|
+
async def _import_directories(
|
|
655
|
+
self,
|
|
656
|
+
zf: zipfile.ZipFile,
|
|
657
|
+
manifest: dict,
|
|
658
|
+
result: ImportResult,
|
|
659
|
+
) -> dict[str, int]:
|
|
660
|
+
"""导入插件和其他数据目录
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
zf: ZIP 文件对象
|
|
664
|
+
manifest: 备份清单
|
|
665
|
+
result: 导入结果对象
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
dict: 每个目录导入的文件数量
|
|
669
|
+
"""
|
|
670
|
+
dir_stats: dict[str, int] = {}
|
|
671
|
+
|
|
672
|
+
# 检查备份版本是否支持目录备份(需要版本 >= 1.1)
|
|
673
|
+
backup_version = manifest.get("version", "1.0")
|
|
674
|
+
if VersionComparator.compare_version(backup_version, "1.1") < 0:
|
|
675
|
+
logger.info("备份版本不支持目录备份,跳过目录导入")
|
|
676
|
+
return dir_stats
|
|
677
|
+
|
|
678
|
+
backed_up_dirs = manifest.get("directories", [])
|
|
679
|
+
backup_directories = get_backup_directories()
|
|
680
|
+
|
|
681
|
+
for dir_name in backed_up_dirs:
|
|
682
|
+
if dir_name not in backup_directories:
|
|
683
|
+
result.add_warning(f"未知的目录类型: {dir_name}")
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
target_dir = Path(backup_directories[dir_name])
|
|
687
|
+
archive_prefix = f"directories/{dir_name}/"
|
|
688
|
+
|
|
689
|
+
file_count = 0
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
# 获取该目录下的所有文件
|
|
693
|
+
dir_files = [
|
|
694
|
+
name
|
|
695
|
+
for name in zf.namelist()
|
|
696
|
+
if name.startswith(archive_prefix) and name != archive_prefix
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
if not dir_files:
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
# 备份现有目录(如果存在)
|
|
703
|
+
if target_dir.exists():
|
|
704
|
+
backup_path = Path(f"{target_dir}.bak")
|
|
705
|
+
if backup_path.exists():
|
|
706
|
+
shutil.rmtree(backup_path)
|
|
707
|
+
shutil.move(str(target_dir), str(backup_path))
|
|
708
|
+
logger.debug(f"已备份现有目录 {target_dir} 到 {backup_path}")
|
|
709
|
+
|
|
710
|
+
# 创建目标目录
|
|
711
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
712
|
+
|
|
713
|
+
# 解压文件
|
|
714
|
+
for name in dir_files:
|
|
715
|
+
try:
|
|
716
|
+
# 计算相对路径
|
|
717
|
+
rel_path = name[len(archive_prefix) :]
|
|
718
|
+
if not rel_path: # 跳过目录条目
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
target_path = target_dir / rel_path
|
|
722
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
723
|
+
|
|
724
|
+
with zf.open(name) as src, open(target_path, "wb") as dst:
|
|
725
|
+
dst.write(src.read())
|
|
726
|
+
file_count += 1
|
|
727
|
+
except Exception as e:
|
|
728
|
+
result.add_warning(f"导入文件 {name} 失败: {e}")
|
|
729
|
+
|
|
730
|
+
dir_stats[dir_name] = file_count
|
|
731
|
+
logger.debug(f"导入目录 {dir_name}: {file_count} 个文件")
|
|
732
|
+
|
|
733
|
+
except Exception as e:
|
|
734
|
+
result.add_warning(f"导入目录 {dir_name} 失败: {e}")
|
|
735
|
+
dir_stats[dir_name] = 0
|
|
736
|
+
|
|
737
|
+
return dir_stats
|
|
738
|
+
|
|
739
|
+
def _convert_datetime_fields(self, row: dict, model_class: type) -> dict:
|
|
740
|
+
"""转换 datetime 字符串字段为 datetime 对象"""
|
|
741
|
+
result = row.copy()
|
|
742
|
+
|
|
743
|
+
# 获取模型的 datetime 字段
|
|
744
|
+
from sqlalchemy import inspect as sa_inspect
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
mapper = sa_inspect(model_class)
|
|
748
|
+
for column in mapper.columns:
|
|
749
|
+
if column.name in result and result[column.name] is not None:
|
|
750
|
+
# 检查是否是 datetime 类型的列
|
|
751
|
+
from sqlalchemy import DateTime
|
|
752
|
+
|
|
753
|
+
if isinstance(column.type, DateTime):
|
|
754
|
+
value = result[column.name]
|
|
755
|
+
if isinstance(value, str):
|
|
756
|
+
# 解析 ISO 格式的日期时间字符串
|
|
757
|
+
result[column.name] = datetime.fromisoformat(value)
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
return result
|