From 4f4cf9546032bef2abf27a5ffd8e0f2fb3d7bf13 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Wed, 17 Jun 2026 22:40:31 +0800 Subject: [PATCH 1/3] feat: add SillyTavern worldbook import --- docs/integrations.md | 32 + docs/webui-api.md | 48 + services/integration/__init__.py | 1 + services/integration/worldbook_importer.py | 858 ++++++++++++++++++ .../test_worldbook_integration_routes.py | 99 ++ tests/unit/test_worldbook_importer.py | 232 +++++ webui/blueprints/integrations.py | 77 ++ webui/services/integration_service.py | 3 + 8 files changed, 1350 insertions(+) create mode 100644 services/integration/worldbook_importer.py create mode 100644 tests/integration/test_worldbook_integration_routes.py create mode 100644 tests/unit/test_worldbook_importer.py diff --git a/docs/integrations.md b/docs/integrations.md index e1c71557..181a697e 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -178,6 +178,9 @@ GET /api/hub/v1/status ### Self Learning - `GET /api/integrations/status` +- `POST /api/integrations/worldbook/preview` +- `POST /api/integrations/worldbook/import` +- `GET /api/integrations/worldbook/imports` - `GET /api/hub/v1/manifest` - `GET /api/hub/v1/status` - `POST /api/hub/v1/context` @@ -198,6 +201,35 @@ GET /api/hub/v1/status - `GET /api/jargon/list` - `GET /api/style_learning/content_text` +## SillyTavern 世界书导入 + +Self Learning 支持导入 SillyTavern 世界书 JSON。导入器接受标准格式: + +```json +{ + "name": "世界书名称", + "entries": { + "0": { + "key": ["关键词"], + "secondaryKeys": ["辅助关键词"], + "content": "设定正文", + "constant": false, + "order": 100, + "insertion_order": 0 + } + } +} +``` + +`entries` 也可以是数组。当前导入路径复用现有表: + +- `content` 写入 `persona_update_reviews`,作为 `worldbook_entry` 待审记忆。 +- `key` 和 `secondaryKeys` 写入 `jargon` 候选池。 +- 世界书条目、关键词及触发关系写入 `kg_entities` 和 `kg_relations`。 + +导入历史通过 `GET /api/integrations/worldbook/imports` 从审查记录 +metadata 聚合,不需要额外迁移表。 + ### LivingMemory Self Learning 不再调用 LivingMemory 的 Page 图谱 API。Dashboard 的 diff --git a/docs/webui-api.md b/docs/webui-api.md index c7d60e4f..2a97295d 100644 --- a/docs/webui-api.md +++ b/docs/webui-api.md @@ -79,6 +79,9 @@ $env:ASTRBOT_ENABLE_WEB_DEP_INSTALL="false" | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/api/integrations/status` | 获取 Self Learning、LivingMemory、Group Chat Plus 的委托状态、面板入口和开发 API 列表 | +| POST | `/api/integrations/worldbook/preview` | 预览 SillyTavern 世界书 JSON,可传 `payload`、`json_text` 或 `json_path` | +| POST | `/api/integrations/worldbook/import` | 导入 SillyTavern 世界书到人格待审记忆、黑话候选和知识图谱 | +| GET | `/api/integrations/worldbook/imports` | 从人格审查元数据读取最近世界书导入历史 | 返回字段: @@ -93,6 +96,51 @@ $env:ASTRBOT_ENABLE_WEB_DEP_INSTALL="false" 详见 [功能融合](integrations.md)。 +### SillyTavern 世界书导入 + +预览: + +```http +POST /api/integrations/worldbook/preview +``` + +```json +{ + "payload": { + "name": "世界书名称", + "entries": { + "0": { + "key": ["关键词"], + "secondaryKeys": ["辅助关键词"], + "content": "设定正文", + "constant": false, + "order": 100, + "insertion_order": 0 + } + } + } +} +``` + +导入: + +```json +{ + "payload": {}, + "default_group_id": "global", + "import_memories": true, + "import_jargons": true, + "import_knowledge_graph": true, + "include_disabled": false +} +``` + +导入器兼容 `entries` 为对象或数组。`content` 会进入 +`persona_update_reviews` 待审队列;`key` 和 `secondaryKeys` +会进入 `jargon` 候选;知识图谱会写入世界书条目节点、关键词节点和 +`触发关键词` 关系。导入历史不新增表,来自 `worldbook_entry` +人格审查记录的 metadata。 + ## Self Learning Hub API 蓝图: `webui/blueprints/hub.py` diff --git a/services/integration/__init__.py b/services/integration/__init__.py index 6199910d..da072205 100644 --- a/services/integration/__init__.py +++ b/services/integration/__init__.py @@ -18,6 +18,7 @@ "MaiBotQualityMonitor": ".maibot_adapters", "MaiBotEnhancedLearningManager": ".maibot_enhanced_learning_manager", "MaiBotLearningImporter": ".maibot_learning_importer", + "WorldBookImporter": ".worldbook_importer", "ExemplarLibrary": ".exemplar_library", "KnowledgeGraphManager": ".knowledge_graph_manager", "LightRAGKnowledgeManager": ".lightrag_knowledge_manager", diff --git a/services/integration/worldbook_importer.py b/services/integration/worldbook_importer.py new file mode 100644 index 00000000..6b468b4b --- /dev/null +++ b/services/integration/worldbook_importer.py @@ -0,0 +1,858 @@ +"""SillyTavern worldbook import bridge. + +This module reads standard SillyTavern worldbook JSON, normalizes entries, and +writes them through the plugin's existing review, jargon, and knowledge graph +tables. It deliberately avoids schema changes: full entry text is preserved in +persona review metadata/content, while the local KG tables store entry/keyword +nodes and trigger relations. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Optional + +from astrbot.api import logger +from sqlalchemy import and_, desc, func, select + + +WORLDBOOK_EXPORT_VERSION = 1 +WORLDBOOK_SOURCE = "sillytavern_worldbook" +WORLDBOOK_REVIEW_TYPE = "worldbook_entry" +WORLDBOOK_TRIGGER_PREDICATE = "触发关键词" + + +@dataclass +class WorldBookEntry: + """Normalized SillyTavern worldbook entry.""" + + source_id: str + title: str + content: str + keys: list[str] = field(default_factory=list) + secondary_keys: list[str] = field(default_factory=list) + constant: bool = False + order: float = 100.0 + insertion_order: int = 0 + enabled: bool = True + comment: str = "" + selective: bool = False + position: Any = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @property + def keywords(self) -> list[str]: + return _unique_terms([*self.keys, *self.secondary_keys]) + + +@dataclass +class WorldBookPackage: + """Normalized import package for a SillyTavern worldbook.""" + + version: int = WORLDBOOK_EXPORT_VERSION + source: str = WORLDBOOK_SOURCE + name: str = "SillyTavern WorldBook" + exported_at: float = field(default_factory=time.time) + source_paths: dict[str, str] = field(default_factory=dict) + entries: list[WorldBookEntry] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "source": self.source, + "name": self.name, + "exported_at": self.exported_at, + "source_paths": self.source_paths, + "entries": [entry.to_dict() for entry in self.entries], + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "WorldBookPackage": + return cls( + version=int(payload.get("version") or WORLDBOOK_EXPORT_VERSION), + source=str(payload.get("source") or WORLDBOOK_SOURCE), + name=str(payload.get("name") or "SillyTavern WorldBook"), + exported_at=_to_timestamp(payload.get("exported_at"), default=time.time()), + source_paths=dict(payload.get("source_paths") or {}), + entries=[ + WorldBookEntry(**_pick_keys(entry, WorldBookEntry)) + for entry in _as_list(payload.get("entries")) + if isinstance(entry, Mapping) + ], + ) + + +class WorldBookImporter: + """Parse and import SillyTavern worldbook JSON.""" + + def __init__(self, database_manager: Any = None) -> None: + self.database_manager = database_manager + + def preview( + self, + *, + payload: Mapping[str, Any] | str | None = None, + json_text: str | None = None, + json_path: str | Path | None = None, + ) -> dict[str, Any]: + package = self.load_package(payload=payload, json_text=json_text, json_path=json_path) + return self.package_summary(package) + + def load_package( + self, + *, + payload: Mapping[str, Any] | str | None = None, + json_text: str | None = None, + json_path: str | Path | None = None, + ) -> WorldBookPackage: + source_paths: dict[str, str] = {} + if payload is None and json_text: + payload = _json_decode(json_text) + if payload is None and json_path: + path = Path(json_path).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"世界书 JSON 文件不存在: {path}") + payload = _json_decode(path.read_text(encoding="utf-8-sig")) + source_paths["worldbook_json"] = str(path.resolve()) + if isinstance(payload, str): + payload = _json_decode(payload) + if not isinstance(payload, Mapping): + raise ValueError("请提供 SillyTavern 世界书 JSON 对象或 JSON 字符串") + + if payload.get("source") == WORLDBOOK_SOURCE and isinstance(payload.get("entries"), list): + package = WorldBookPackage.from_dict(payload) + package.source_paths.update(source_paths) + return package + + package = self._parse_sillytavern_payload(payload) + package.source_paths.update(source_paths) + return package + + async def import_from_source(self, **kwargs: Any) -> dict[str, Any]: + package = self.load_package( + payload=kwargs.get("payload"), + json_text=kwargs.get("json_text"), + json_path=kwargs.get("json_path"), + ) + return await self.import_package( + package, + default_group_id=str(kwargs.get("default_group_id") or "global"), + import_memories=_to_bool(kwargs.get("import_memories", True), True), + import_jargons=_to_bool(kwargs.get("import_jargons", True), True), + import_knowledge_graph=_to_bool(kwargs.get("import_knowledge_graph", True), True), + include_disabled=_to_bool(kwargs.get("include_disabled", False), False), + ) + + async def import_package( + self, + package: WorldBookPackage, + *, + default_group_id: str = "global", + import_memories: bool = True, + import_jargons: bool = True, + import_knowledge_graph: bool = True, + include_disabled: bool = False, + ) -> dict[str, Any]: + if not self.database_manager: + raise RuntimeError("数据库管理器不可用,无法导入世界书数据") + + result = { + "success": True, + "entries_imported": 0, + "memory_reviews_imported": 0, + "jargons_imported": 0, + "kg_entities_imported": 0, + "kg_relations_imported": 0, + "destinations": worldbook_import_destinations(), + "review_breakdown": { + "persona_memory_reviews": 0, + "jargon_candidates": 0, + "knowledge_graph_entities": 0, + "knowledge_graph_relations": 0, + }, + "skipped": 0, + "errors": [], + } + + now = time.time() + import_id = f"worldbook:{_safe_slug(package.name)}:{int(now)}" + group_id = str(default_group_id or "global") + + try: + async with self.database_manager.get_session() as session: + for entry in package.entries: + if not include_disabled and not entry.enabled: + result["skipped"] += 1 + continue + + imported_any = False + if import_memories and entry.content: + review_exists = await self._entry_review_exists( + session, + package.name, + entry, + group_id, + ) + if not review_exists: + self._add_memory_review( + session, + package, + entry, + group_id=group_id, + now=now, + import_id=import_id, + ) + result["memory_reviews_imported"] += 1 + imported_any = True + + if import_jargons: + imported = await self._import_jargon_candidates( + session, + package, + entry, + group_id=group_id, + now=now, + import_id=import_id, + ) + result["jargons_imported"] += imported + imported_any = imported_any or imported > 0 + + if import_knowledge_graph: + entities, relations = await self._import_knowledge_graph( + session, + package, + entry, + group_id=group_id, + now=now, + ) + result["kg_entities_imported"] += entities + result["kg_relations_imported"] += relations + imported_any = imported_any or entities > 0 or relations > 0 + + if imported_any: + result["entries_imported"] += 1 + else: + result["skipped"] += 1 + await session.commit() + except Exception as exc: + logger.error(f"[WorldBookImport] 导入世界书失败: {exc}", exc_info=True) + result["errors"].append(str(exc)) + + result["success"] = not result["errors"] + result["review_breakdown"] = { + "persona_memory_reviews": result["memory_reviews_imported"], + "jargon_candidates": result["jargons_imported"], + "knowledge_graph_entities": result["kg_entities_imported"], + "knowledge_graph_relations": result["kg_relations_imported"], + } + return result + + async def import_history(self, *, limit: int = 20, offset: int = 0) -> dict[str, Any]: + if not self.database_manager: + raise RuntimeError("数据库管理器不可用,无法读取世界书导入历史") + try: + from ...models.orm.learning import PersonaLearningReview + except ImportError: + from models.orm.learning import PersonaLearningReview + + safe_limit = max(1, min(int(limit or 20), 100)) + safe_offset = max(0, int(offset or 0)) + async with self.database_manager.get_session() as session: + total = ( + await session.execute( + select(func.count(PersonaLearningReview.id)).where( + PersonaLearningReview.update_type == WORLDBOOK_REVIEW_TYPE + ) + ) + ).scalar() or 0 + rows = ( + await session.execute( + select(PersonaLearningReview) + .where(PersonaLearningReview.update_type == WORLDBOOK_REVIEW_TYPE) + .order_by(desc(PersonaLearningReview.timestamp)) + .offset(safe_offset) + .limit(safe_limit) + ) + ).scalars().all() + + items = [] + imports: dict[str, dict[str, Any]] = {} + for row in rows: + metadata = _json_dict(getattr(row, "metadata_", None)) + imported_at = metadata.get("imported_at") or row.timestamp + import_id = str(metadata.get("import_id") or f"review:{row.id}") + item = { + "review_id": row.id, + "group_id": row.group_id, + "status": row.status, + "worldbook_name": metadata.get("worldbook_name"), + "worldbook_entry_id": metadata.get("worldbook_entry_id"), + "title": metadata.get("title"), + "import_id": import_id, + "imported_at": imported_at, + "content_preview": _preview_text(row.new_content or row.proposed_content or ""), + } + items.append(item) + aggregate = imports.setdefault( + import_id, + { + "import_id": import_id, + "worldbook_name": item["worldbook_name"], + "group_id": row.group_id, + "imported_at": imported_at, + "entries": 0, + "review_ids": [], + }, + ) + aggregate["entries"] += 1 + aggregate["review_ids"].append(row.id) + + return { + "total": int(total), + "limit": safe_limit, + "offset": safe_offset, + "items": items, + "imports": list(imports.values()), + } + + def package_summary(self, package: WorldBookPackage) -> dict[str, Any]: + keyword_count = sum(len(entry.keys) for entry in package.entries) + secondary_keyword_count = sum(len(entry.secondary_keys) for entry in package.entries) + content_chars = sum(len(entry.content) for entry in package.entries) + return { + "version": package.version, + "source": package.source, + "name": package.name, + "source_paths": package.source_paths, + "counts": { + "entries": len(package.entries), + "enabled_entries": sum(1 for entry in package.entries if entry.enabled), + "disabled_entries": sum(1 for entry in package.entries if not entry.enabled), + "constant_entries": sum(1 for entry in package.entries if entry.constant), + "keywords": keyword_count, + "secondary_keywords": secondary_keyword_count, + "content_chars": content_chars, + "estimated_tokens": max(1, content_chars // 4) if content_chars else 0, + }, + "samples": { + "entries": [entry.to_dict() for entry in package.entries[:5]], + }, + "destinations": worldbook_import_destinations(), + "review_breakdown": { + "persona_memory_reviews": sum(1 for entry in package.entries if entry.content), + "jargon_candidates": keyword_count + secondary_keyword_count, + "knowledge_graph_entities": len(package.entries) + len(_all_keywords(package.entries)), + "knowledge_graph_relations": keyword_count + secondary_keyword_count, + }, + } + + def export_json(self, **kwargs: Any) -> dict[str, Any]: + return self.load_package(**kwargs).to_dict() + + def _parse_sillytavern_payload(self, payload: Mapping[str, Any]) -> WorldBookPackage: + entries_payload = payload.get("entries") + if entries_payload is None and isinstance(payload.get("data"), Mapping): + entries_payload = payload["data"].get("entries") + if entries_payload is None and isinstance(payload, list): + entries_payload = payload + if not isinstance(entries_payload, (Mapping, list)): + raise ValueError("不是有效的 SillyTavern 世界书 JSON:缺少 entries 对象或数组") + + entries: list[WorldBookEntry] = [] + for index, (source_key, raw_entry) in enumerate(_iter_entries(entries_payload)): + entry = self._parse_entry(source_key, raw_entry, index) + if entry: + entries.append(entry) + + return WorldBookPackage( + name=str(payload.get("name") or payload.get("worldbook_name") or "SillyTavern WorldBook"), + exported_at=_to_timestamp(payload.get("exported_at"), default=time.time()), + entries=entries, + ) + + @staticmethod + def _parse_entry(source_key: Any, raw_entry: Any, index: int) -> Optional[WorldBookEntry]: + if not isinstance(raw_entry, Mapping): + return None + content = str(raw_entry.get("content") or "").strip() + keys = _normalize_terms(_first_present(raw_entry, ("key", "keys", "primaryKeys", "primary_keys"))) + secondary_keys = _normalize_terms( + _first_present( + raw_entry, + ( + "secondaryKeys", + "secondary_keys", + "keysecondary", + "keySecondary", + "secondary", + ), + ) + ) + if not content and not keys and not secondary_keys: + return None + + disabled = _to_bool( + _first_present(raw_entry, ("disable", "disabled", "is_disabled")), + False, + ) + enabled_value = _first_present(raw_entry, ("enabled", "is_enabled")) + enabled = _to_bool(enabled_value, True) if enabled_value is not None else not disabled + enabled = bool(enabled and not disabled) + + source_id = str( + raw_entry.get("uid") + or raw_entry.get("id") + or raw_entry.get("entry_id") + or source_key + or index + ) + comment = str(raw_entry.get("comment") or "").strip() + title = str( + raw_entry.get("name") + or raw_entry.get("title") + or comment + or (keys[0] if keys else "") + or f"entry-{source_id}" + ).strip() + + return WorldBookEntry( + source_id=source_id, + title=title, + content=content, + keys=keys, + secondary_keys=secondary_keys, + constant=_to_bool(raw_entry.get("constant"), False), + order=_to_float(raw_entry.get("order"), default=100.0), + insertion_order=_to_int( + _first_present(raw_entry, ("insertion_order", "insertionOrder")), + default=index, + ), + enabled=enabled, + comment=comment, + selective=_to_bool(raw_entry.get("selective"), False), + position=raw_entry.get("position"), + metadata=_entry_metadata(raw_entry), + ) + + async def _entry_review_exists( + self, + session: Any, + worldbook_name: str, + entry: WorldBookEntry, + group_id: str, + ) -> bool: + try: + from ...models.orm.learning import PersonaLearningReview + except ImportError: + from models.orm.learning import PersonaLearningReview + + stmt = select(PersonaLearningReview.id).where( + and_( + PersonaLearningReview.update_type == WORLDBOOK_REVIEW_TYPE, + PersonaLearningReview.group_id == group_id, + PersonaLearningReview.metadata_.like( + f'%"worldbook_name": "{_like_json_text(worldbook_name)}"%' + ), + PersonaLearningReview.metadata_.like( + f'%"worldbook_entry_id": "{_like_json_text(entry.source_id)}"%' + ), + ) + ) + return (await session.execute(stmt)).scalar_one_or_none() is not None + + @staticmethod + def _add_memory_review( + session: Any, + package: WorldBookPackage, + entry: WorldBookEntry, + *, + group_id: str, + now: float, + import_id: str, + ) -> None: + try: + from ...models.orm.learning import PersonaLearningReview + except ImportError: + from models.orm.learning import PersonaLearningReview + + session.add( + PersonaLearningReview( + timestamp=now, + group_id=group_id, + update_type=WORLDBOOK_REVIEW_TYPE, + original_content="", + new_content=entry.content, + proposed_content=entry.content, + confidence_score=0.7, + reason="从 SillyTavern 世界书导入的设定条目,等待确认后可沉淀到人格/记忆上下文。", + status="pending", + metadata_=json.dumps( + _entry_import_metadata(package, entry, now=now, import_id=import_id), + ensure_ascii=False, + ), + ) + ) + + @staticmethod + async def _import_jargon_candidates( + session: Any, + package: WorldBookPackage, + entry: WorldBookEntry, + *, + group_id: str, + now: float, + import_id: str, + ) -> int: + try: + from ...models.orm.jargon import Jargon + except ImportError: + from models.orm.jargon import Jargon + + imported = 0 + now_int = int(now) + for keyword in entry.keywords: + existing = ( + await session.execute( + select(Jargon.id).where( + and_( + Jargon.chat_id == group_id, + Jargon.content == keyword, + ) + ) + ) + ).scalar_one_or_none() + if existing: + continue + raw_content = { + "source": WORLDBOOK_SOURCE, + "worldbook_name": package.name, + "worldbook_entry_id": entry.source_id, + "title": entry.title, + "content_preview": _preview_text(entry.content, limit=240), + "keys": entry.keys, + "secondary_keys": entry.secondary_keys, + "constant": entry.constant, + "order": entry.order, + "insertion_order": entry.insertion_order, + "import_id": import_id, + } + session.add( + Jargon( + content=keyword, + raw_content=json.dumps(raw_content, ensure_ascii=False), + meaning=None, + is_jargon=None, + count=1, + last_inference_count=0, + is_complete=False, + is_global=group_id == "global", + chat_id=group_id, + created_at=now_int, + updated_at=now_int, + ) + ) + imported += 1 + return imported + + @staticmethod + async def _import_knowledge_graph( + session: Any, + package: WorldBookPackage, + entry: WorldBookEntry, + *, + group_id: str, + now: float, + ) -> tuple[int, int]: + try: + from ...models.orm.knowledge_graph import KGEntity, KGRelation + except ImportError: + from models.orm.knowledge_graph import KGEntity, KGRelation + + entities_imported = 0 + relations_imported = 0 + entry_name = _db_text(f"世界书:{package.name}:{entry.title}", 191) + if await _touch_entity(session, KGEntity, entry_name, group_id, "worldbook_entry", now): + entities_imported += 1 + + for keyword in entry.keywords: + keyword_name = _db_text(keyword, 191) + if await _touch_entity(session, KGEntity, keyword_name, group_id, "worldbook_keyword", now): + entities_imported += 1 + relation_exists = ( + await session.execute( + select(KGRelation.id).where( + and_( + KGRelation.subject == entry_name, + KGRelation.predicate == WORLDBOOK_TRIGGER_PREDICATE, + KGRelation.object == keyword_name, + KGRelation.group_id == group_id, + ) + ) + ) + ).scalar_one_or_none() + if relation_exists: + continue + session.add( + KGRelation( + subject=entry_name, + predicate=WORLDBOOK_TRIGGER_PREDICATE, + object=keyword_name, + confidence=1.0, + created_time=now, + group_id=group_id, + ) + ) + relations_imported += 1 + return entities_imported, relations_imported + + +async def _touch_entity( + session: Any, + entity_cls: Any, + name: str, + group_id: str, + entity_type: str, + now: float, +) -> bool: + existing = ( + await session.execute( + select(entity_cls).where( + and_( + entity_cls.name == name, + entity_cls.group_id == group_id, + ) + ) + ) + ).scalar_one_or_none() + if existing: + existing.appear_count = (existing.appear_count or 0) + 1 + existing.last_active_time = now + if entity_type != "general": + existing.entity_type = entity_type + return False + session.add( + entity_cls( + name=name, + entity_type=entity_type, + appear_count=1, + last_active_time=now, + group_id=group_id, + ) + ) + return True + + +def worldbook_import_destinations() -> dict[str, str]: + return { + "memories": "persona_update_reviews", + "jargons": "jargon", + "knowledge_graph_entities": "kg_entities", + "knowledge_graph_relations": "kg_relations", + } + + +def _iter_entries(entries_payload: Mapping[str, Any] | list[Any]): + if isinstance(entries_payload, Mapping): + return sorted(entries_payload.items(), key=lambda item: _entry_sort_key(item[0])) + return list(enumerate(entries_payload)) + + +def _entry_sort_key(key: Any) -> tuple[int, int | str]: + text = str(key) + try: + return (0, int(text)) + except ValueError: + return (1, text) + + +def _entry_metadata(entry: Mapping[str, Any]) -> dict[str, Any]: + omitted = { + "content", + "key", + "keys", + "primaryKeys", + "primary_keys", + "secondaryKeys", + "secondary_keys", + "keysecondary", + "keySecondary", + "secondary", + } + return {str(key): value for key, value in entry.items() if key not in omitted} + + +def _entry_import_metadata( + package: WorldBookPackage, + entry: WorldBookEntry, + *, + now: float, + import_id: str, +) -> dict[str, Any]: + return { + "source": WORLDBOOK_SOURCE, + "worldbook_name": package.name, + "worldbook_entry_id": entry.source_id, + "title": entry.title, + "keys": entry.keys, + "secondary_keys": entry.secondary_keys, + "constant": entry.constant, + "order": entry.order, + "insertion_order": entry.insertion_order, + "enabled": entry.enabled, + "comment": entry.comment, + "selective": entry.selective, + "position": entry.position, + "entry_metadata": entry.metadata, + "import_id": import_id, + "imported_at": now, + } + + +def _normalize_terms(value: Any) -> list[str]: + terms: list[str] = [] + if value is None: + return terms + if isinstance(value, str): + parts = value.replace("\n", ",").split(",") + terms.extend(part.strip() for part in parts) + elif isinstance(value, (list, tuple, set)): + for item in value: + terms.extend(_normalize_terms(item)) + else: + terms.append(str(value).strip()) + return _unique_terms(terms) + + +def _unique_terms(values: list[str]) -> list[str]: + seen = set() + normalized = [] + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + normalized.append(text) + return normalized + + +def _all_keywords(entries: list[WorldBookEntry]) -> list[str]: + return _unique_terms([keyword for entry in entries for keyword in entry.keywords]) + + +def _first_present(mapping: Mapping[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key in mapping: + return mapping[key] + return None + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _json_decode(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if value is None: + return None + text = str(value).strip() + if not text: + return None + return json.loads(text) + + +def _json_dict(value: Any) -> dict[str, Any]: + decoded = None + try: + decoded = _json_decode(value) + except (TypeError, ValueError): + return {} + return decoded if isinstance(decoded, dict) else {} + + +def _to_timestamp(value: Any, *, default: float) -> float: + if value in (None, ""): + return float(default) + if isinstance(value, (int, float)): + number = float(value) + return number / 1000 if number > 10_000_000_000 else number + text = str(value).strip() + if not text: + return float(default) + try: + number = float(text) + return number / 1000 if number > 10_000_000_000 else number + except ValueError: + pass + normalized = text.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(normalized) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + return float(default) + + +def _to_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"true", "1", "yes", "on"}: + return True + if text in {"false", "0", "no", "off"}: + return False + return default + + +def _to_int(value: Any, *, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _to_float(value: Any, *, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _preview_text(value: str, *, limit: int = 120) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)]}…" + + +def _db_text(value: str, limit: int) -> str: + text = str(value or "").strip() + if len(text) <= limit: + return text + return text[:limit] + + +def _safe_slug(value: str) -> str: + text = "".join(ch if ch.isalnum() else "-" for ch in str(value or "worldbook").lower()) + return "-".join(part for part in text.split("-") if part)[:48] or "worldbook" + + +def _like_json_text(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _pick_keys(item: Mapping[str, Any], cls: type) -> dict[str, Any]: + annotations = getattr(cls, "__annotations__", {}) + return {key: item[key] for key in annotations if key in item} diff --git a/tests/integration/test_worldbook_integration_routes.py b/tests/integration/test_worldbook_integration_routes.py new file mode 100644 index 00000000..98afbe31 --- /dev/null +++ b/tests/integration/test_worldbook_integration_routes.py @@ -0,0 +1,99 @@ +from types import SimpleNamespace + +import pytest +from quart import Quart + +from config import PluginConfig +from services.database.sqlalchemy_database_manager import SQLAlchemyDatabaseManager +import webui.blueprints.integrations as integrations_module +from webui.blueprints.integrations import integrations_bp + + +def _worldbook_payload(): + return { + "name": "路线世界书", + "entries": [ + { + "key": ["月港"], + "secondaryKeys": ["潮汐"], + "content": "月港在退潮时开放。", + "comment": "月港设定", + } + ], + } + + +@pytest.fixture +async def app(monkeypatch, tmp_path): + manager = SQLAlchemyDatabaseManager( + PluginConfig( + data_dir=str(tmp_path / "plugin"), + db_type="sqlite", + enable_web_interface=False, + ) + ) + assert await manager.start() is True + monkeypatch.setattr( + integrations_module, + "get_container", + lambda: SimpleNamespace(database_manager=manager), + ) + + app = Quart(__name__) + app.config["TESTING"] = True + app.secret_key = "test-secret-key" + app.register_blueprint(integrations_bp) + app.database_manager = manager + try: + yield app + finally: + await manager.stop() + + +@pytest.fixture +async def client(app): + return app.test_client() + + +@pytest.mark.asyncio +async def test_worldbook_preview_route_reports_counts(client): + response = await client.post( + "/api/integrations/worldbook/preview", + json={"payload": _worldbook_payload()}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data["success"] is True + assert data["data"]["counts"]["entries"] == 1 + assert data["data"]["counts"]["keywords"] == 1 + assert data["data"]["counts"]["secondary_keywords"] == 1 + + +@pytest.mark.asyncio +async def test_worldbook_import_and_history_routes(client): + import_response = await client.post( + "/api/integrations/worldbook/import", + json={ + "payload": _worldbook_payload(), + "default_group_id": "group-route", + "import_memories": True, + "import_jargons": True, + "import_knowledge_graph": True, + }, + ) + history_response = await client.get("/api/integrations/worldbook/imports?limit=5") + + assert import_response.status_code == 200 + import_data = await import_response.get_json() + assert import_data["success"] is True + assert import_data["data"]["entries_imported"] == 1 + assert import_data["data"]["memory_reviews_imported"] == 1 + assert import_data["data"]["jargons_imported"] == 2 + + assert history_response.status_code == 200 + history_data = await history_response.get_json() + assert history_data["success"] is True + assert history_data["data"]["total"] == 1 + assert history_data["data"]["items"][0]["group_id"] == "group-route" + assert history_data["data"]["items"][0]["worldbook_name"] == "路线世界书" diff --git a/tests/unit/test_worldbook_importer.py b/tests/unit/test_worldbook_importer.py new file mode 100644 index 00000000..a278692f --- /dev/null +++ b/tests/unit/test_worldbook_importer.py @@ -0,0 +1,232 @@ +import json + +import pytest +from sqlalchemy import select + +from config import PluginConfig +from models.orm.jargon import Jargon +from models.orm.knowledge_graph import KGEntity, KGRelation +from models.orm.learning import PersonaLearningReview +from services.database.sqlalchemy_database_manager import SQLAlchemyDatabaseManager +from services.integration.worldbook_importer import WorldBookImporter + + +def _sample_worldbook(): + return { + "name": "测试世界书", + "entries": { + "10": { + "key": ["星门", "传送门"], + "secondaryKeys": ["遗迹"], + "content": "星门是一种古代交通设施。", + "constant": False, + "order": 20, + "insertion_order": 3, + "comment": "星门设定", + }, + "2": { + "key": "守夜人, 夜巡", + "keysecondary": ["城墙"], + "content": "守夜人负责夜间巡逻。", + "constant": True, + "disable": True, + "order": 5, + }, + }, + } + + +def _sample_worldbook_list_entries(): + return { + "name": "数组世界书", + "entries": [ + { + "keys": ["灯塔"], + "secondary_keys": "海岸", + "content": "灯塔会在暴风雨时启动。", + "enabled": True, + } + ], + } + + +def test_worldbook_importer_parses_dict_entries_in_stable_order(): + package = WorldBookImporter().load_package(payload=_sample_worldbook()) + + assert package.name == "测试世界书" + assert [entry.source_id for entry in package.entries] == ["2", "10"] + assert package.entries[0].keys == ["守夜人", "夜巡"] + assert package.entries[0].secondary_keys == ["城墙"] + assert package.entries[0].constant is True + assert package.entries[0].enabled is False + assert package.entries[1].title == "星门设定" + assert package.entries[1].keywords == ["星门", "传送门", "遗迹"] + + +def test_worldbook_importer_parses_list_entries_and_string_payload(): + payload = json.dumps(_sample_worldbook_list_entries(), ensure_ascii=False) + package = WorldBookImporter().load_package(payload=payload) + + assert len(package.entries) == 1 + assert package.entries[0].source_id == "0" + assert package.entries[0].keys == ["灯塔"] + assert package.entries[0].secondary_keys == ["海岸"] + + +def test_worldbook_importer_preview_reports_destinations_and_counts(): + summary = WorldBookImporter().preview(payload=_sample_worldbook()) + + assert summary["counts"]["entries"] == 2 + assert summary["counts"]["enabled_entries"] == 1 + assert summary["counts"]["disabled_entries"] == 1 + assert summary["counts"]["constant_entries"] == 1 + assert summary["counts"]["keywords"] == 4 + assert summary["counts"]["secondary_keywords"] == 2 + assert summary["destinations"] == { + "memories": "persona_update_reviews", + "jargons": "jargon", + "knowledge_graph_entities": "kg_entities", + "knowledge_graph_relations": "kg_relations", + } + + +@pytest.mark.asyncio +async def test_worldbook_importer_imports_into_existing_tables(tmp_path): + manager = SQLAlchemyDatabaseManager( + PluginConfig( + data_dir=str(tmp_path / "plugin"), + db_type="sqlite", + enable_web_interface=False, + ) + ) + try: + assert await manager.start() is True + importer = WorldBookImporter(manager) + result = await importer.import_from_source( + payload=_sample_worldbook(), + default_group_id="group-1", + ) + + assert result["success"] is True + assert result["entries_imported"] == 1 + assert result["memory_reviews_imported"] == 1 + assert result["jargons_imported"] == 3 + assert result["kg_entities_imported"] == 4 + assert result["kg_relations_imported"] == 3 + assert result["skipped"] == 1 + + async with manager.get_session() as session: + reviews = ( + await session.execute( + select(PersonaLearningReview).where( + PersonaLearningReview.update_type == "worldbook_entry" + ) + ) + ).scalars().all() + jargons = (await session.execute(select(Jargon))).scalars().all() + entities = (await session.execute(select(KGEntity))).scalars().all() + relations = (await session.execute(select(KGRelation))).scalars().all() + + assert len(reviews) == 1 + assert reviews[0].group_id == "group-1" + assert reviews[0].new_content == "星门是一种古代交通设施。" + metadata = json.loads(reviews[0].metadata_) + assert metadata["source"] == "sillytavern_worldbook" + assert metadata["worldbook_name"] == "测试世界书" + assert metadata["worldbook_entry_id"] == "10" + assert {item.content for item in jargons} == {"星门", "传送门", "遗迹"} + assert {item.entity_type for item in entities} == {"worldbook_entry", "worldbook_keyword"} + assert {item.predicate for item in relations} == {"触发关键词"} + + history = await importer.import_history() + assert history["total"] == 1 + assert history["items"][0]["worldbook_entry_id"] == "10" + assert history["imports"][0]["entries"] == 1 + finally: + await manager.stop() + + +@pytest.mark.asyncio +async def test_worldbook_importer_import_is_idempotent_for_memory_reviews(tmp_path): + manager = SQLAlchemyDatabaseManager( + PluginConfig( + data_dir=str(tmp_path / "plugin"), + db_type="sqlite", + enable_web_interface=False, + ) + ) + try: + assert await manager.start() is True + importer = WorldBookImporter(manager) + first = await importer.import_from_source( + payload=_sample_worldbook(), + default_group_id="group-1", + ) + second = await importer.import_from_source( + payload=_sample_worldbook(), + default_group_id="group-1", + ) + + assert first["entries_imported"] == 1 + assert second["entries_imported"] == 0 + assert second["skipped"] == 2 + + async with manager.get_session() as session: + count = ( + await session.execute( + select(PersonaLearningReview).where( + PersonaLearningReview.update_type == "worldbook_entry" + ) + ) + ).scalars().all() + assert len(count) == 1 + finally: + await manager.stop() + + +@pytest.mark.asyncio +async def test_worldbook_importer_can_backfill_jargon_after_memory_only_import(tmp_path): + manager = SQLAlchemyDatabaseManager( + PluginConfig( + data_dir=str(tmp_path / "plugin"), + db_type="sqlite", + enable_web_interface=False, + ) + ) + try: + assert await manager.start() is True + importer = WorldBookImporter(manager) + memory_only = await importer.import_from_source( + payload=_sample_worldbook(), + default_group_id="group-1", + import_memories=True, + import_jargons=False, + import_knowledge_graph=False, + ) + backfill = await importer.import_from_source( + payload=_sample_worldbook(), + default_group_id="group-1", + import_memories=False, + import_jargons=True, + import_knowledge_graph=True, + ) + + assert memory_only["memory_reviews_imported"] == 1 + assert backfill["memory_reviews_imported"] == 0 + assert backfill["jargons_imported"] == 3 + assert backfill["kg_relations_imported"] == 3 + + async with manager.get_session() as session: + reviews = ( + await session.execute( + select(PersonaLearningReview).where( + PersonaLearningReview.update_type == "worldbook_entry" + ) + ) + ).scalars().all() + jargons = (await session.execute(select(Jargon))).scalars().all() + + assert len(reviews) == 1 + assert {item.content for item in jargons} == {"星门", "传送门", "遗迹"} + finally: + await manager.stop() diff --git a/webui/blueprints/integrations.py b/webui/blueprints/integrations.py index dcd4c8bf..f0e3d49d 100644 --- a/webui/blueprints/integrations.py +++ b/webui/blueprints/integrations.py @@ -11,8 +11,10 @@ from ..utils.response import error_response try: from ...services.integration.maibot_learning_importer import MaiBotLearningImporter + from ...services.integration.worldbook_importer import WorldBookImporter except ImportError: from services.integration.maibot_learning_importer import MaiBotLearningImporter + from services.integration.worldbook_importer import WorldBookImporter integrations_bp = Blueprint("integrations", __name__, url_prefix="/api") @@ -102,6 +104,63 @@ async def export_maibot_learning(): return error_response(f"导出 MaiBot 学习数据失败: {str(e)}", 500) +@integrations_bp.route("/integrations/worldbook/preview", methods=["POST"]) +@require_auth +async def preview_worldbook(): + """Preview SillyTavern worldbook JSON before importing it.""" + try: + body = await request.get_json(silent=True) or {} + importer = WorldBookImporter() + return jsonify({ + "success": True, + "data": importer.preview(**_worldbook_source_args(body)), + }), 200 + except Exception as e: + logger.error(f"预览 SillyTavern 世界书失败: {e}", exc_info=True) + return error_response(f"预览 SillyTavern 世界书失败: {str(e)}", 500) + + +@integrations_bp.route("/integrations/worldbook/import", methods=["POST"]) +@require_auth +async def import_worldbook(): + """Import SillyTavern worldbook entries into this plugin.""" + try: + body = await request.get_json(silent=True) or {} + container = get_container() + database_manager = getattr(container, "database_manager", None) + importer = WorldBookImporter(database_manager) + result = await importer.import_from_source( + **_worldbook_source_args(body), + default_group_id=body.get("default_group_id") or body.get("group_id") or "global", + import_memories=_body_bool(body, "import_memories", True), + import_jargons=_body_bool(body, "import_jargons", True), + import_knowledge_graph=_body_bool(body, "import_knowledge_graph", True), + include_disabled=_body_bool(body, "include_disabled", False), + ) + return jsonify({"success": bool(result.get("success")), "data": result}), 200 + except Exception as e: + logger.error(f"导入 SillyTavern 世界书失败: {e}", exc_info=True) + return error_response(f"导入 SillyTavern 世界书失败: {str(e)}", 500) + + +@integrations_bp.route("/integrations/worldbook/imports", methods=["GET"]) +@require_auth +async def list_worldbook_imports(): + """List recent worldbook imports derived from review metadata.""" + try: + container = get_container() + database_manager = getattr(container, "database_manager", None) + importer = WorldBookImporter(database_manager) + data = await importer.import_history( + limit=_query_int("limit", 20), + offset=_query_int("offset", 0), + ) + return jsonify({"success": True, "data": data}), 200 + except Exception as e: + logger.error(f"读取 SillyTavern 世界书导入历史失败: {e}", exc_info=True) + return error_response(f"读取 SillyTavern 世界书导入历史失败: {str(e)}", 500) + + def _maibot_source_args(body: dict) -> dict: payload = body.get("payload") return { @@ -112,6 +171,17 @@ def _maibot_source_args(body: dict) -> dict: } +def _worldbook_source_args(body: dict) -> dict: + payload = body.get("payload") + if payload is None: + payload = body.get("worldbook") + return { + "payload": payload if isinstance(payload, (dict, str)) else None, + "json_text": body.get("json_text") or None, + "json_path": body.get("json_path") or body.get("worldbook_path") or None, + } + + def _body_bool(body: dict, key: str, default: bool) -> bool: value = body.get(key, default) if isinstance(value, bool): @@ -121,6 +191,13 @@ def _body_bool(body: dict, key: str, default: bool) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "on"} +def _query_int(key: str, default: int) -> int: + try: + return int(request.args.get(key, default)) + except (TypeError, ValueError): + return default + + def _render_embed_shell(target: dict) -> str: title = escape(str(target.get("title") or "伴随插件面板")) role = escape(str(target.get("role") or "")) diff --git a/webui/services/integration_service.py b/webui/services/integration_service.py index d01f842f..531f5368 100644 --- a/webui/services/integration_service.py +++ b/webui/services/integration_service.py @@ -14,6 +14,9 @@ SELF_LEARNING_API_ENDPOINTS = [ "GET /api/integrations/status", + "POST /api/integrations/worldbook/preview", + "POST /api/integrations/worldbook/import", + "GET /api/integrations/worldbook/imports", "GET /api/hub/v1/manifest", "GET /api/hub/v1/status", "POST /api/hub/v1/context", From 13249cc38f547b407937dfc58756e39002620671 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Thu, 18 Jun 2026 08:34:56 +0800 Subject: [PATCH 2/3] fix: address worldbook import review feedback --- services/integration/worldbook_importer.py | 27 ++++++++++++------- .../test_worldbook_integration_routes.py | 15 ++++++++++- tests/unit/test_worldbook_importer.py | 17 ++++++++++++ webui/blueprints/integrations.py | 7 +++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/services/integration/worldbook_importer.py b/services/integration/worldbook_importer.py index 6b468b4b..24466456 100644 --- a/services/integration/worldbook_importer.py +++ b/services/integration/worldbook_importer.py @@ -98,7 +98,7 @@ def __init__(self, database_manager: Any = None) -> None: def preview( self, *, - payload: Mapping[str, Any] | str | None = None, + payload: Mapping[str, Any] | list[Any] | str | None = None, json_text: str | None = None, json_path: str | Path | None = None, ) -> dict[str, Any]: @@ -108,7 +108,7 @@ def preview( def load_package( self, *, - payload: Mapping[str, Any] | str | None = None, + payload: Mapping[str, Any] | list[Any] | str | None = None, json_text: str | None = None, json_path: str | Path | None = None, ) -> WorldBookPackage: @@ -123,10 +123,14 @@ def load_package( source_paths["worldbook_json"] = str(path.resolve()) if isinstance(payload, str): payload = _json_decode(payload) - if not isinstance(payload, Mapping): - raise ValueError("请提供 SillyTavern 世界书 JSON 对象或 JSON 字符串") - - if payload.get("source") == WORLDBOOK_SOURCE and isinstance(payload.get("entries"), list): + if not isinstance(payload, (Mapping, list)): + raise ValueError("请提供 SillyTavern 世界书 JSON 对象、数组或 JSON 字符串") + + if ( + isinstance(payload, Mapping) + and payload.get("source") == WORLDBOOK_SOURCE + and isinstance(payload.get("entries"), list) + ): package = WorldBookPackage.from_dict(payload) package.source_paths.update(source_paths) return package @@ -356,12 +360,17 @@ def package_summary(self, package: WorldBookPackage) -> dict[str, Any]: def export_json(self, **kwargs: Any) -> dict[str, Any]: return self.load_package(**kwargs).to_dict() - def _parse_sillytavern_payload(self, payload: Mapping[str, Any]) -> WorldBookPackage: + def _parse_sillytavern_payload(self, payload: Mapping[str, Any] | list[Any]) -> WorldBookPackage: + if isinstance(payload, list): + return WorldBookPackage(entries=[ + entry + for index, (source_key, raw_entry) in enumerate(_iter_entries(payload)) + if (entry := self._parse_entry(source_key, raw_entry, index)) + ]) + entries_payload = payload.get("entries") if entries_payload is None and isinstance(payload.get("data"), Mapping): entries_payload = payload["data"].get("entries") - if entries_payload is None and isinstance(payload, list): - entries_payload = payload if not isinstance(entries_payload, (Mapping, list)): raise ValueError("不是有效的 SillyTavern 世界书 JSON:缺少 entries 对象或数组") diff --git a/tests/integration/test_worldbook_integration_routes.py b/tests/integration/test_worldbook_integration_routes.py index 98afbe31..cba79630 100644 --- a/tests/integration/test_worldbook_integration_routes.py +++ b/tests/integration/test_worldbook_integration_routes.py @@ -6,7 +6,7 @@ from config import PluginConfig from services.database.sqlalchemy_database_manager import SQLAlchemyDatabaseManager import webui.blueprints.integrations as integrations_module -from webui.blueprints.integrations import integrations_bp +from webui.blueprints.integrations import _worldbook_source_args, integrations_bp def _worldbook_payload(): @@ -23,6 +23,19 @@ def _worldbook_payload(): } +def test_worldbook_source_args_ignores_server_side_paths(): + args = _worldbook_source_args( + { + "payload": _worldbook_payload(), + "json_path": "C:/Windows/win.ini", + "worldbook_path": "C:/Windows/system.ini", + } + ) + + assert args["payload"]["name"] == "路线世界书" + assert args["json_path"] is None + + @pytest.fixture async def app(monkeypatch, tmp_path): manager = SQLAlchemyDatabaseManager( diff --git a/tests/unit/test_worldbook_importer.py b/tests/unit/test_worldbook_importer.py index a278692f..4a31f5d9 100644 --- a/tests/unit/test_worldbook_importer.py +++ b/tests/unit/test_worldbook_importer.py @@ -73,6 +73,23 @@ def test_worldbook_importer_parses_list_entries_and_string_payload(): assert package.entries[0].secondary_keys == ["海岸"] +def test_worldbook_importer_parses_top_level_list_payload(): + package = WorldBookImporter().load_package( + payload=[ + { + "key": ["塔"], + "secondaryKeys": ["钟声"], + "content": "钟塔每天清晨响起。", + } + ] + ) + + assert len(package.entries) == 1 + assert package.entries[0].source_id == "0" + assert package.entries[0].keys == ["塔"] + assert package.entries[0].secondary_keys == ["钟声"] + + def test_worldbook_importer_preview_reports_destinations_and_counts(): summary = WorldBookImporter().preview(payload=_sample_worldbook()) diff --git a/webui/blueprints/integrations.py b/webui/blueprints/integrations.py index f0e3d49d..1cd352c8 100644 --- a/webui/blueprints/integrations.py +++ b/webui/blueprints/integrations.py @@ -176,9 +176,12 @@ def _worldbook_source_args(body: dict) -> dict: if payload is None: payload = body.get("worldbook") return { - "payload": payload if isinstance(payload, (dict, str)) else None, + "payload": payload if isinstance(payload, (dict, list, str)) else None, "json_text": body.get("json_text") or None, - "json_path": body.get("json_path") or body.get("worldbook_path") or None, + # Do not accept server-side paths from WebUI requests. Callers should + # upload/send JSON content instead; direct Python callers may still use + # WorldBookImporter.load_package(json_path=...). + "json_path": None, } From 33d0f8520d4f540b9753080b83aba667a81daafd Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Thu, 18 Jun 2026 08:39:48 +0800 Subject: [PATCH 3/3] chore: bump release to 3.4.2 --- CHANGELOG.md | 12 ++++++++++++ README.md | 2 +- README_EN.md | 2 +- metadata.yaml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d25a2af..6857bb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 所有重要更改都将记录在此文件中。 +## [3.4.2] - 2026-06-18 + +### 集成 + +- 新增 SillyTavern 世界书预览、导入与导入历史接口,可将世界书条目写入现有学习审查、黑话和知识图谱数据。 +- WebUI 世界书导入不再接受请求传入的服务端路径,避免通过 `json_path`/`worldbook_path` 读取任意本地文件。 +- 世界书导入器支持顶层数组格式 payload,并补充对应回归测试。 + +### 版本 + +- 将插件发布版本号提升至 `3.4.2`。 + ## [3.4.0] - 2026-06-14 ### 自学习中枢 diff --git a/README.md b/README.md index 3467cc7f..a1030738 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 让 AstrBot 在群聊中持续采集、学习、审查并注入上下文,使 Bot 逐步具备表达风格、群组黑话、社交关系、长期记忆和人格演化能力。 -[![Version](https://img.shields.io/badge/version-3.4.1-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) +[![Version](https://img.shields.io/badge/version-3.4.2-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) diff --git a/README_EN.md b/README_EN.md index 1828258c..0569f509 100644 --- a/README_EN.md +++ b/README_EN.md @@ -14,7 +14,7 @@
-[![Version](https://img.shields.io/badge/version-3.4.1-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) +[![Version](https://img.shields.io/badge/version-3.4.2-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [Features](#what-we-can-do) · [Quick Start](#quick-start) · [Web UI](#visual-management-interface) · [Community](#community) · [Contributing](CONTRIBUTING.md) diff --git a/metadata.yaml b/metadata.yaml index 1d2bee35..c9f0ee03 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -2,7 +2,7 @@ name: "astrbot_plugin_self_learning" author: "NickMo, EterUltimate" display_name: "self-learning" description: "SELF LEARNING 自主学习插件 — 让 AI 聊天机器人自主学习对话风格、理解群组黑话、管理社交关系与好感度、自适应人格演化,像真人一样自然对话。(使用前必须手动备份人格数据)" -version: "3.4.1" +version: "3.4.2" repo: "https://github.com/NickCharlie/astrbot_plugin_self_learning" tags: - "自学习"