AstrBot 4.10.2__py3-none-any.whl → 4.10.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
  2. astrbot/builtin_stars/astrbot/main.py +128 -0
  3. astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
  4. astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
  5. astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
  6. astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
  7. astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
  8. astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
  9. astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
  10. astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
  11. astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
  12. astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
  13. astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
  14. astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
  15. astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
  16. astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
  17. astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
  18. astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
  19. astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
  20. astrbot/builtin_stars/builtin_commands/main.py +237 -0
  21. astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
  22. astrbot/builtin_stars/python_interpreter/main.py +537 -0
  23. astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
  24. astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
  25. astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
  26. astrbot/builtin_stars/reminder/main.py +266 -0
  27. astrbot/builtin_stars/reminder/metadata.yaml +4 -0
  28. astrbot/builtin_stars/session_controller/main.py +114 -0
  29. astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
  30. astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
  31. astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
  32. astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
  33. astrbot/builtin_stars/web_searcher/main.py +436 -0
  34. astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
  35. astrbot/cli/__init__.py +1 -1
  36. astrbot/core/agent/message.py +9 -0
  37. astrbot/core/agent/runners/tool_loop_agent_runner.py +2 -1
  38. astrbot/core/backup/__init__.py +26 -0
  39. astrbot/core/backup/constants.py +77 -0
  40. astrbot/core/backup/exporter.py +476 -0
  41. astrbot/core/backup/importer.py +761 -0
  42. astrbot/core/config/default.py +1 -1
  43. astrbot/core/log.py +1 -1
  44. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +1 -1
  45. astrbot/core/pipeline/waking_check/stage.py +2 -1
  46. astrbot/core/provider/entities.py +32 -9
  47. astrbot/core/provider/provider.py +3 -1
  48. astrbot/core/provider/sources/anthropic_source.py +80 -27
  49. astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
  50. astrbot/core/provider/sources/gemini_source.py +75 -26
  51. astrbot/core/provider/sources/openai_source.py +68 -25
  52. astrbot/core/star/context.py +1 -1
  53. astrbot/core/star/star_manager.py +11 -13
  54. astrbot/core/utils/astrbot_path.py +34 -0
  55. astrbot/dashboard/routes/__init__.py +2 -0
  56. astrbot/dashboard/routes/backup.py +589 -0
  57. astrbot/dashboard/routes/log.py +44 -10
  58. astrbot/dashboard/server.py +8 -1
  59. {astrbot-4.10.2.dist-info → astrbot-4.10.3.dist-info}/METADATA +1 -1
  60. {astrbot-4.10.2.dist-info → astrbot-4.10.3.dist-info}/RECORD +63 -24
  61. {astrbot-4.10.2.dist-info → astrbot-4.10.3.dist-info}/WHEEL +0 -0
  62. {astrbot-4.10.2.dist-info → astrbot-4.10.3.dist-info}/entry_points.txt +0 -0
  63. {astrbot-4.10.2.dist-info → astrbot-4.10.3.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