380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""
|
|
数据模型转换器
|
|
|
|
提供 SillyTavern 格式与内部格式之间的双向转换功能。
|
|
所有导入/导出操作都应该通过转换器进行,确保数据格式的一致性。
|
|
"""
|
|
import uuid
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import datetime
|
|
|
|
from models.internal import (
|
|
WorldInfo,
|
|
WorldInfoEntry,
|
|
ActivationType,
|
|
)
|
|
|
|
|
|
class WorldBookConverter:
|
|
"""世界书数据转换器
|
|
|
|
负责 SillyTavern 格式和项目内部格式之间的转换。
|
|
|
|
SillyTavern 格式特点:
|
|
- entries 是 dict (key 为 uid)
|
|
- 使用 constant 字段表示常驻激活
|
|
- position 是字符串 (如 "after_char")
|
|
|
|
项目内部格式特点:
|
|
- entries 是 list
|
|
- 使用 activationType 枚举
|
|
- position 是数字 (0-5)
|
|
- 包含 trigger_config 结构(前端需要)
|
|
"""
|
|
|
|
@staticmethod
|
|
def detect_format(data: Dict[str, Any]) -> str:
|
|
"""
|
|
智能检测世界书数据格式
|
|
|
|
Args:
|
|
data: 世界书数据
|
|
|
|
Returns:
|
|
'sillytavern' | 'internal' | 'unknown'
|
|
"""
|
|
# 检查 entries 类型
|
|
entries = data.get("entries")
|
|
if not entries:
|
|
return "unknown"
|
|
|
|
# SillyTavern 特征: entries 是 dict
|
|
if isinstance(entries, dict):
|
|
return "sillytavern"
|
|
|
|
# 内部格式特征: entries 是 list
|
|
if isinstance(entries, list):
|
|
# 进一步检查是否有 trigger_config
|
|
if len(entries) > 0 and isinstance(entries[0], dict):
|
|
first_entry = entries[0]
|
|
if "trigger_config" in first_entry:
|
|
return "internal"
|
|
# 也可能是简化的内部格式
|
|
if "activationType" in first_entry or "position" in first_entry:
|
|
return "internal"
|
|
|
|
return "unknown"
|
|
|
|
# 位置映射: SillyTavern 字符串 -> 内部数字
|
|
POSITION_MAP_ST_TO_INTERNAL = {
|
|
"after_char": 0,
|
|
"before_char": 1,
|
|
"before_example": 2,
|
|
"after_example": 3,
|
|
"author_note": 4,
|
|
"system_prompt": 5,
|
|
}
|
|
|
|
# 位置映射: 内部数字 -> SillyTavern 字符串
|
|
POSITION_MAP_INTERNAL_TO_ST = {
|
|
0: "after_char",
|
|
1: "before_char",
|
|
2: "before_example",
|
|
3: "after_example",
|
|
4: "author_note",
|
|
5: "system_prompt",
|
|
}
|
|
|
|
@staticmethod
|
|
def st_to_internal(st_data: Dict[str, Any], name: str = None) -> Dict[str, Any]:
|
|
"""
|
|
将 SillyTavern 格式的世界书转换为内部格式
|
|
|
|
Args:
|
|
st_data: SillyTavern 格式的世界书数据
|
|
name: 世界书名称(可选,优先使用 st_data 中的 name)
|
|
|
|
Returns:
|
|
内部格式的世界书字典(包含 trigger_config)
|
|
"""
|
|
now = int(datetime.now().timestamp())
|
|
|
|
# 转换条目
|
|
entries = []
|
|
st_entries = st_data.get("entries", {})
|
|
|
|
# SillyTavern 的 entries 可能是 dict 或 list
|
|
if isinstance(st_entries, dict):
|
|
entries_list = list(st_entries.values())
|
|
elif isinstance(st_entries, list):
|
|
entries_list = st_entries
|
|
else:
|
|
entries_list = []
|
|
|
|
for st_entry in entries_list:
|
|
if not isinstance(st_entry, dict):
|
|
continue
|
|
|
|
# 判断激活类型
|
|
is_constant = st_entry.get("constant", False)
|
|
activation_type = ActivationType.PERMANENT if is_constant else ActivationType.KEYWORD
|
|
|
|
# 转换位置
|
|
st_position = st_entry.get("position", "after_char")
|
|
internal_position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(st_position, 0)
|
|
|
|
# 构建 trigger_config (前端期望的格式)
|
|
trigger_config = WorldBookConverter._build_trigger_config(
|
|
is_constant=is_constant,
|
|
key=st_entry.get("key", []),
|
|
keysecondary=st_entry.get("keysecondary", []),
|
|
selective=st_entry.get("selective", True)
|
|
)
|
|
|
|
# 创建内部格式的条目
|
|
entry_dict = {
|
|
"uid": st_entry.get("uid", str(uuid.uuid4())),
|
|
"key": st_entry.get("key", []),
|
|
"keysecondary": st_entry.get("keysecondary", []),
|
|
"content": st_entry.get("content", ""),
|
|
"comment": st_entry.get("comment", ""),
|
|
"activationType": activation_type.value,
|
|
"trigger_config": trigger_config,
|
|
"order": st_entry.get("order", 100),
|
|
"position": internal_position,
|
|
"depth": st_entry.get("depth", 4),
|
|
"role": st_entry.get("role", 0),
|
|
"probability": st_entry.get("probability", 100),
|
|
"group": st_entry.get("group", []),
|
|
"disable": st_entry.get("disable", False),
|
|
"createdAt": now,
|
|
"updatedAt": now
|
|
}
|
|
|
|
entries.append(entry_dict)
|
|
|
|
# 创建内部格式的世界书
|
|
worldbook_data = {
|
|
"id": str(uuid.uuid4()),
|
|
"name": name or st_data.get("name", "Unnamed"),
|
|
"description": st_data.get("description", ""),
|
|
"entries": entries,
|
|
"createdAt": now,
|
|
"updatedAt": now,
|
|
"version": 1
|
|
}
|
|
|
|
return worldbook_data
|
|
|
|
@staticmethod
|
|
def internal_to_st(worldbook_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
将内部格式的世界书转换为 SillyTavern 格式
|
|
|
|
Args:
|
|
worldbook_data: 内部格式的世界书字典
|
|
|
|
Returns:
|
|
SillyTavern 格式的世界书数据
|
|
"""
|
|
# 转换条目
|
|
st_entries = {}
|
|
|
|
for entry_data in worldbook_data.get("entries", []):
|
|
if not isinstance(entry_data, dict):
|
|
continue
|
|
|
|
uid = entry_data.get("uid", str(uuid.uuid4()))
|
|
|
|
# 从 trigger_config 或 activationType 判断是否常驻
|
|
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
|
|
|
# 提取关键词
|
|
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
|
|
|
# 转换位置
|
|
internal_position = entry_data.get("position", 0)
|
|
st_position = WorldBookConverter.POSITION_MAP_INTERNAL_TO_ST.get(internal_position, "after_char")
|
|
|
|
# 创建 SillyTavern 格式的条目
|
|
st_entry = {
|
|
"uid": uid,
|
|
"key": key,
|
|
"keysecondary": keysecondary,
|
|
"content": entry_data.get("content", ""),
|
|
"comment": entry_data.get("comment", ""),
|
|
"constant": is_constant,
|
|
"selective": not is_constant,
|
|
"order": entry_data.get("order", 100),
|
|
"position": st_position,
|
|
"depth": entry_data.get("depth", 4),
|
|
"probability": entry_data.get("probability", 100),
|
|
"group": entry_data.get("group", []),
|
|
"disable": entry_data.get("disable", False)
|
|
}
|
|
|
|
st_entries[uid] = st_entry
|
|
|
|
# 创建 SillyTavern 格式的世界书
|
|
st_data = {
|
|
"name": worldbook_data.get("name", ""),
|
|
"description": worldbook_data.get("description", ""),
|
|
"entries": st_entries
|
|
}
|
|
|
|
return st_data
|
|
|
|
@staticmethod
|
|
def normalize_entry(entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
规范化条目数据,确保包含所有必需字段和 trigger_config
|
|
|
|
Args:
|
|
entry_data: 条目数据(可能来自不同来源)
|
|
|
|
Returns:
|
|
规范化后的条目数据
|
|
"""
|
|
now = int(datetime.now().timestamp())
|
|
|
|
# 如果已经有 trigger_config,直接返回
|
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
|
return entry_data
|
|
|
|
# 否则从其他字段构建 trigger_config
|
|
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
|
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
|
|
|
trigger_config = WorldBookConverter._build_trigger_config(
|
|
is_constant=is_constant,
|
|
key=key,
|
|
keysecondary=keysecondary,
|
|
selective=entry_data.get("selective", True)
|
|
)
|
|
|
|
# 添加缺失的字段
|
|
normalized = {
|
|
"uid": entry_data.get("uid", str(uuid.uuid4())),
|
|
"key": key,
|
|
"keysecondary": keysecondary,
|
|
"content": entry_data.get("content", ""),
|
|
"comment": entry_data.get("comment", ""),
|
|
"activationType": entry_data.get("activationType",
|
|
ActivationType.PERMANENT.value if is_constant
|
|
else ActivationType.KEYWORD.value),
|
|
"trigger_config": trigger_config,
|
|
"order": entry_data.get("order", 100),
|
|
"position": entry_data.get("position", 0),
|
|
"depth": entry_data.get("depth", 4),
|
|
"role": entry_data.get("role", 0),
|
|
"probability": entry_data.get("probability", 100),
|
|
"group": entry_data.get("group", []),
|
|
"disable": entry_data.get("disable", False),
|
|
"createdAt": entry_data.get("createdAt", now),
|
|
"updatedAt": entry_data.get("updatedAt", now)
|
|
}
|
|
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _build_trigger_config(
|
|
is_constant: bool,
|
|
key: List[str],
|
|
keysecondary: List[str],
|
|
selective: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
构建 trigger_config 结构
|
|
|
|
Args:
|
|
is_constant: 是否常驻激活
|
|
key: 主关键词列表
|
|
keysecondary: 次要关键词列表
|
|
selective: 是否选择性匹配
|
|
|
|
Returns:
|
|
trigger_config 字典
|
|
"""
|
|
return {
|
|
"triggers": {
|
|
"constant": [is_constant, None],
|
|
"keyword": [
|
|
not is_constant,
|
|
{
|
|
"key": key,
|
|
"keysecondary": keysecondary,
|
|
"selective": selective,
|
|
"selectiveLogic": 0,
|
|
"matchWholeWords": False,
|
|
"caseSensitive": False
|
|
}
|
|
],
|
|
"rag": [False, {
|
|
"threshold": 0.75,
|
|
"top_k": 5,
|
|
"query_template": None
|
|
}],
|
|
"condition": [False, {
|
|
"variable_a": "",
|
|
"operator": "=",
|
|
"variable_b": ""
|
|
}]
|
|
}
|
|
}
|
|
|
|
@staticmethod
|
|
def _is_constant_entry(entry_data: Dict[str, Any]) -> bool:
|
|
"""
|
|
判断条目是否为常驻激活
|
|
|
|
Args:
|
|
entry_data: 条目数据
|
|
|
|
Returns:
|
|
是否常驻激活
|
|
"""
|
|
# 优先从 trigger_config 判断
|
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
|
try:
|
|
return entry_data["trigger_config"]["triggers"]["constant"][0]
|
|
except (KeyError, IndexError, TypeError):
|
|
pass
|
|
|
|
# 其次从 activationType 判断
|
|
if "activationType" in entry_data:
|
|
return entry_data["activationType"] == ActivationType.PERMANENT.value
|
|
|
|
# 最后从 constant 字段判断
|
|
if "constant" in entry_data:
|
|
return entry_data["constant"]
|
|
|
|
return False
|
|
|
|
@staticmethod
|
|
def _extract_keywords(entry_data: Dict[str, Any]) -> tuple:
|
|
"""
|
|
从条目数据中提取关键词
|
|
|
|
Args:
|
|
entry_data: 条目数据
|
|
|
|
Returns:
|
|
(key, keysecondary) 元组
|
|
"""
|
|
# 优先从 trigger_config 提取
|
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
|
try:
|
|
keyword_config = entry_data["trigger_config"]["triggers"]["keyword"][1]
|
|
if keyword_config:
|
|
key = keyword_config.get("key", [])
|
|
keysecondary = keyword_config.get("keysecondary", [])
|
|
return key, keysecondary
|
|
except (KeyError, IndexError, TypeError):
|
|
pass
|
|
|
|
# 否则从顶层字段提取
|
|
key = entry_data.get("key", [])
|
|
keysecondary = entry_data.get("keysecondary", [])
|
|
|
|
return key, keysecondary
|