世界书部分基本完成,剩余编辑条目部分,可以推进到下一步

This commit is contained in:
2026-04-07 05:40:27 +08:00
parent 4f9cf4b725
commit dd17206e1f
6 changed files with 1815 additions and 889 deletions

View File

@@ -9,16 +9,18 @@ from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse, FileResponse
# 本地模块导入
# 本地模块导入
from backend.core.models.WorldBook import WorldBook
from backend.core.models.WorldItem import (
WorldInfoEntry,
WorldItem,
TriggerConfig,
KeywordTriggerConfig,
RAGTriggerConfig,
ConditionTriggerConfig,
TriggerStrategy
)
from backend.core.config import settings
# 配置日志
@@ -94,7 +96,6 @@ async def get_worldbook(name: str):
@router.post("/", response_model=Dict[str, Any])
async def create_worldbook(
name: str = Form(...),
description: str = Form(""),
file: Optional[UploadFile] = File(None)
):
"""
@@ -121,7 +122,6 @@ async def create_worldbook(
world_book = WorldBook.load(Path(temp_path).stem)
# 更新名称和描述
world_book.name = name
world_book.description = description
# 保存世界书
world_book.save()
logger.info(f"从文件创建世界书: {name}")
@@ -131,7 +131,7 @@ async def create_worldbook(
os.remove(temp_path)
else:
# 创建空世界书
world_book = WorldBook.create_empty(name, description)
world_book = WorldBook.create_empty(name)
logger.info(f"创建空世界书: {name}")
return world_book.to_dict()
@@ -145,7 +145,6 @@ async def create_worldbook(
@router.put("/{name}", response_model=Dict[str, Any])
async def update_worldbook(
name: str,
description: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None)
):
"""
@@ -185,10 +184,6 @@ async def update_worldbook(
if os.path.exists(temp_path):
os.remove(temp_path)
# 更新描述(如果提供)
if description is not None:
world_book.description = description
# 保存世界书
world_book.save()
logger.info(f"更新世界书: {name}")
@@ -289,7 +284,7 @@ async def get_worldbook_entry(name: str, uid: int):
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
logger.info(f"获取世界书 {name} 的条目: UID={uid}")
return entry.dict()
return entry.to_dict()
except HTTPException:
raise
except Exception as e:
@@ -350,7 +345,7 @@ async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
entry_data["trigger_config"] = trigger_config
# 创建条目
entry = WorldInfoEntry(**entry_data)
entry = WorldItem.Entry(**entry_data)
# 添加条目
world_book.add_entry(entry)
@@ -359,7 +354,7 @@ async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
world_book.save()
logger.info(f"在世界书 {name} 中创建条目: UID={entry.uid}")
return entry.dict()
return entry.to_dict()
except HTTPException:
raise
except Exception as e:
@@ -424,8 +419,7 @@ async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]
# 设置触发配置
entry_data["trigger_config"] = trigger_config
# 过滤无效字段,只保留 WorldInfoEntry 中存在的字段
valid_fields = WorldInfoEntry.__fields__.keys()
valid_fields = WorldItem.Entry.model_fields.keys()
filtered_data = {k: v for k, v in entry_data.items() if k in valid_fields}
# 更新条目
@@ -440,7 +434,7 @@ async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]
entry = world_book.get_entry(uid)
logger.info(f"更新世界书 {name} 的条目: UID={uid}")
return entry.dict()
return entry.to_dict()
except HTTPException:
raise
except Exception as e:

View File

@@ -3,8 +3,8 @@ import os
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
from pydantic import BaseModel, Field, validator
from .WorldItem import WorldInfoEntry, TriggerStrategy
from pydantic import BaseModel, Field, field_validator
from .WorldItem import WorldItem, TriggerStrategy
from backend.core.config import settings
# 配置日志
@@ -20,12 +20,13 @@ class WorldBook(BaseModel):
name: str = Field(..., description="世界书名称")
# 条目集合
entries: Dict[str, WorldInfoEntry] = Field(
entries: Dict[str, WorldItem.Entry] = Field(
default_factory=dict,
description="世界书条目字典对象 (Key-Value Map)"
)
@validator('entries')
@field_validator('entries')
@classmethod
def validate_entries_unique_uid(cls, v):
"""验证条目 UID 的唯一性"""
uids = [entry.uid for entry in v.values()]
@@ -91,7 +92,7 @@ class WorldBook(BaseModel):
logger.info(f"创建空白世界书: {name}")
return world_book
def add_entry(self, entry: WorldInfoEntry) -> None:
def add_entry(self, entry: WorldItem.Entry) -> None:
"""
添加世界书条目
@@ -127,7 +128,7 @@ class WorldBook(BaseModel):
logger.warning(f"尝试移除不存在的条目: 世界书 {self.name} 中未找到 UID={uid}")
return False
def get_entry(self, uid: int) -> Optional[WorldInfoEntry]:
def get_entry(self, uid: int) -> Optional[WorldItem.Entry]:
"""
获取指定 UID 的世界书条目
@@ -135,7 +136,7 @@ class WorldBook(BaseModel):
uid: 条目 UID
Returns:
Optional[WorldInfoEntry]: 找到的条目,未找到返回 None
Optional[WorldItem.Entry]: 找到的条目,未找到返回 None
"""
entry_key = str(uid)
entry = self.entries.get(entry_key)
@@ -167,7 +168,7 @@ class WorldBook(BaseModel):
logger.info(f"已更新世界书 {self.name} 中的条目: UID={uid}, 更新字段={list(kwargs.keys())}")
return True
def filter_by_position(self, position: int) -> List[WorldInfoEntry]:
def filter_by_position(self, position: int) -> List[WorldItem.Entry]:
"""
根据位置筛选条目
@@ -175,7 +176,7 @@ class WorldBook(BaseModel):
position: 位置值
Returns:
List[WorldInfoEntry]: 筛选后的条目列表
List[WorldItem.Entry]: 筛选后的条目列表
"""
filtered_entries = [
entry for entry in self.entries.values()
@@ -227,12 +228,11 @@ class WorldBook(BaseModel):
"""
result = {
'name': self.name,
'entries': {uid: entry.dict() for uid, entry in self.entries.items()}
'entries': {uid: entry.model_dump() for uid, entry in self.entries.items()}
}
logger.debug(f"将世界书 {self.name} 转换为字典")
return result
@classmethod
@classmethod
def load(cls, name: str) -> 'WorldBook':
"""
@@ -277,7 +277,10 @@ class WorldBook(BaseModel):
# 转换标准格式的条目
for uid, entry_data in entries_dict.items():
try:
world_entry = WorldInfoEntry.from_sillytavern_data(entry_data)
# 先使用 WorldItem 解析数据
world_item = WorldItem.from_sillytavern_data(entry_data)
# 然后转换为 Entry
world_entry = world_item.to_entry()
world_book.add_entry(world_entry)
except Exception as e:
logger.warning(f"跳过条目 {uid},解析失败: {e}")
@@ -336,26 +339,20 @@ class WorldBook(BaseModel):
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
"""
result = []
for uid, entry in self.entries.items():
# 获取启用的触发策略
for entry in self.entries.values():
entry_dict = entry.to_dict()
# 添加额外的触发相关信息
enabled_triggers = entry.trigger_config.get_enabled_triggers()
# 检查是否启用了关键词触发
keyword_enabled, keyword_config = entry.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
triggers = keyword_config.key if keyword_enabled and keyword_config else []
# 检查是否启用了永久触发
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result.append({
"uid": entry.uid,
"comment": entry.comment,
"triggers": triggers,
"content": entry.content,
"position": entry.position,
entry_dict.update({
"triggers": keyword_config.key if keyword_enabled and keyword_config else [],
"constant": constant_enabled,
"trigger_strategies": [strategy.value for strategy in enabled_triggers]
})
result.append(entry_dict)
logger.debug(f"列出世界书 {self.name} 的触发词和内容: 条目数={len(result)}")
return result
@@ -366,26 +363,7 @@ class WorldBook(BaseModel):
Returns:
List[Dict[str, Any]]: 包含核心信息的条目列表
"""
result = []
for uid, entry in self.entries.items():
# 获取启用的触发策略
enabled_triggers = entry.trigger_config.get_enabled_triggers()
# 检查是否启用了永久触发
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result.append({
"uid": entry.uid,
"comment": entry.comment,
"content": entry.content,
"constant": constant_enabled,
"position": entry.position,
"order": entry.order,
"role": entry.role,
"disable": entry.disable,
"trigger_strategies": [strategy.value for strategy in enabled_triggers],
"trigger_params": entry.get_trigger_params()
})
result = [entry.to_dict() for entry in self.entries.values()]
logger.debug(f"获取世界书 {self.name} 的所有条目: 条目数={len(result)}")
return result
@@ -434,7 +412,7 @@ class WorldBook(BaseModel):
if __name__ == "__main__":
try:
# 创建空白世界书
world_book = WorldBook.create_empty("test_worldbook", "测试世界书")
world_book = WorldBook.create_empty("test_worldbook")
# 加载世界书
world_book = WorldBook.load("test_worldbook")

View File

@@ -1,13 +1,108 @@
import logging
from enum import Enum
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, validator
from pydantic import Extra
from pydantic import BaseModel, Field, field_validator, ConfigDict
# 配置日志
logger = logging.getLogger(__name__)
class WorldInfoPosition(Enum):
"""
SillyTavern 世界书条目插入位置枚举
注意枚举值的顺序0-4并不完全代表物理顺序
以下是按照 Prompt 从上到下的真实物理顺序排列的:
"""
# --- 1. 顶部区域 ---
# (System Prompt 在这里,不可插入)
# --- 2. 核心指令区 (Position 4 实际上在这里) ---
SYSTEM_PROMPT = 4
"""
物理位置:紧跟在系统提示词之后,角色定义之前。
语境:最高优先级的规则。
用途作者注释、核心系统规则。AI 在读人设前就会先读到这个。
"""
# --- 3. 角色人设区 (Position 0 实际上在这里) ---
# (Character Definition 在这里)
CHAR_AFTER = 0
"""
物理位置:紧跟在角色定义之后。
语境:角色固有属性。
用途:性格、外貌、长期设定。
"""
# --- 4. 示例对话区 ---
EXAMPLE_BEFORE = 2
"""
物理位置:在示例对话块之前。
"""
EXAMPLE_AFTER = 3
"""
物理位置:在示例对话块之后。
"""
# --- 5. 底部区域 ---
# (Chat History 在这里)
# (User Input 在这里 - 最新输入)
# --- 6. 动态深度区 (Depth / d0-d99) ---
# 这是你强调的"第 6 个插入区"
# 它不是一个固定的物理点,而是一个动态区域
DEPTH_HISTORY = 4
"""
物理位置:
- d0: 在 [用户最新输入] 之前,[AI 回复] 之前。
- d0~d99: 在 [Chat History] 内部,倒数第 N 条消息之前。
语境:
- d0: 即时状态("现在正在发生")。
- d1+: 历史背景("当时就在那里")。
用途:
这是最灵活的插入区,利用 Depth 字段来精确控制条目在对话流中的位置。
"""
@classmethod
def get_description(cls, position: int) -> str:
"""
获取位置描述
Args:
position: 位置值
Returns:
str: 位置描述
"""
position_map = {
0: "角色定义之后",
1: "角色定义之后 (最常用)",
2: "示例对话之前",
3: "示例对话之后",
4: "系统提示 / 作者注释 (底部) 或 历史记录深度插入"
}
return position_map.get(position, "未知位置")
@classmethod
def is_depth_position(cls, position: int) -> bool:
"""
判断是否为深度插入位置
Args:
position: 位置值
Returns:
bool: 是否为深度插入位置
"""
return position == cls.DEPTH_HISTORY.value
class TriggerStrategy(str, Enum):
"""
触发策略枚举
@@ -18,6 +113,15 @@ class TriggerStrategy(str, Enum):
CONDITION = "condition" # 逻辑条件触发
class RAGTriggerConfig(BaseModel):
"""
RAG触发配置
"""
threshold: float = Field(0.75, description="RAG 相似度阈值")
top_k: int = Field(5, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
class KeywordTriggerConfig(BaseModel):
"""
关键词触发配置
@@ -30,15 +134,6 @@ class KeywordTriggerConfig(BaseModel):
caseSensitive: bool = Field(False, description="是否区分大小写")
class RAGTriggerConfig(BaseModel):
"""
RAG触发配置
"""
threshold: float = Field(0.75, description="RAG 相似度阈值")
top_k: int = Field(5, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
class ConditionTriggerConfig(BaseModel):
"""
条件触发配置
@@ -64,8 +159,7 @@ class TriggerConfig(BaseModel):
description="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
)
class Config:
extra = Extra.forbid # 禁止额外字段
model_config = ConfigDict(extra='forbid')
def set_trigger(self, strategy: TriggerStrategy, enabled: bool,
config: Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]] = None
@@ -103,187 +197,233 @@ class TriggerConfig(BaseModel):
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
class WorldInfoEntry(BaseModel):
class WorldItem(BaseModel):
"""
世界书条目模型
完整兼容所有可能的属性字段
世界书条目完整模型
包含所有 SillyTavern 世界书条目属性,用于导入导出
"""
# A. 基础定义 (Base Fields)
class Entry(BaseModel):
"""
世界书条目模型
精简版,只包含必要字段,用于实际使用
"""
# 基础定义
uid: int = Field(..., description="唯一标识符")
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
comment: str = Field("", description="条目名、备注")
# 注入与排序
position: int = Field(0,
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
depth: int = Field(4, description="扫描深度0为最深/最高4为标准")
# 触发配置
trigger_config: Optional[TriggerConfig] = Field(
default_factory=TriggerConfig,
description="触发配置,为空表示无需触发配置"
)
# 角色匹配
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
# 条目启用状态
enabled: bool = Field(True, description="条目是否启用启用才会被插入到LLM")
@field_validator('position')
@classmethod
def validate_position(cls, v):
"""验证 position 值是否在有效范围内"""
if v not in [0, 1, 2, 3, 4]:
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
return 1
return v
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
Returns:
Dict[str, Any]: 字典数据
"""
return self.dict()
def get_trigger_params(self) -> Dict[str, Any]:
"""
获取触发策略所需的参数
Returns:
Dict[str, Any]: 触发参数字典
"""
params = {}
try:
# 获取所有启用的触发策略
enabled_triggers = self.trigger_config.get_enabled_triggers()
# 处理 RAG 触发
if TriggerStrategy.RAG in enabled_triggers:
_, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_config:
params["threshold"] = rag_config.threshold
params["top_k"] = rag_config.top_k
params["query_template"] = rag_config.query_template
params["vectorized"] = True
# 处理关键词触发
if TriggerStrategy.KEYWORD in enabled_triggers:
_, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_config:
params["key"] = keyword_config.key
params["keysecondary"] = keyword_config.keysecondary
params["selective"] = keyword_config.selective
params["selectiveLogic"] = keyword_config.selectiveLogic
params["matchWholeWords"] = keyword_config.matchWholeWords
params["caseSensitive"] = keyword_config.caseSensitive
# 处理条件触发
if TriggerStrategy.CONDITION in enabled_triggers:
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_config:
params["variable_a"] = condition_config.variable_a
params["operator"] = condition_config.operator
params["variable_b"] = condition_config.variable_b
except Exception as e:
# 如果获取触发参数失败,返回空字典,表示使用默认的永久触发
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
return params
# 基础定义
uid: int = Field(..., description="唯一标识符")
key: List[str] = Field(default_factory=list, description="条目名")
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
comment: str = Field("", description="备注")
comment: str = Field("", description="条目名、备注")
# B. 注入与排序 (Injection & Order)
position: int = Field(0, description="插入位置")
order: int = Field(100, description="注入顺序权重")
depth: int = Field(4, description="扫描深度")
scanDepth: Optional[int] = Field(None, description="显式扫描深度")
addMemo: bool = Field(False, description="是否添加备忘录标记")
# 注入与排序
position: int = Field(0,
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
depth: int = Field(4, description="扫描深度0为最深/最高4为标准")
# C. 触发配置 (Trigger Configuration)
# 触发配置
trigger_config: TriggerConfig = Field(
default_factory=TriggerConfig,
description="触发配置"
)
# D. 高级匹配 (Advanced Matching)
# 角色匹配
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
# 条目启用状态
enabled: bool = Field(True, description="条目是否启用启用才会被插入到LLM")
# 触发相关属性
vectorized: bool = Field(False, description="是否使用向量检索RAG触发")
selective: bool = Field(True, description="是否开启选择性匹配(关键词触发)")
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
constant: bool = Field(False, description="是否永久触发")
# 关键词相关
key: List[str] = Field(default_factory=list, description="主关键词数组")
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
matchWholeWords: Optional[bool] = Field(None, description="是否全词匹配")
caseSensitive: Optional[bool] = Field(None, description="是否区分大小写")
# RAG相关
rag_threshold: Optional[float] = Field(None, description="RAG 相似度阈值")
top_k: Optional[int] = Field(None, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
# 条目控制
addMemo: bool = Field(True, description="是否添加备忘")
disable: bool = Field(False, description="是否禁用")
ignoreBudget: bool = Field(False, description="是否忽略预算")
excludeRecursion: bool = Field(True, description="是否排除递归")
preventRecursion: bool = Field(True, description="是否阻止递归")
matchPersonaDescription: bool = Field(False, description="是否匹配人设描述")
matchCharacterDescription: bool = Field(False, description="是否匹配角色描述")
matchCharacterPersonality: bool = Field(False, description="是否匹配角色性格")
matchCharacterDepthPrompt: bool = Field(False, description="是否匹配深度提示")
matchScenario: bool = Field(False, description="是否匹配场景")
matchCreatorNotes: bool = Field(False, description="是否匹配作者笔记")
delayUntilRecursion: bool = Field(False, description="是否延迟递归")
# 概率相关
probability: int = Field(100, description="触发概率 (0-100)")
useProbability: bool = Field(True, description="是否使用概率")
# 分组相关
group: str = Field("", description="分组名称")
groupOverride: bool = Field(False, description="是否覆盖分组")
groupWeight: int = Field(100, description="分组权重")
useGroupScoring: bool = Field(False, description="是否使用分组评分")
# E. 分组设置 (Grouping)
group: Optional[str] = Field(None, description="分组名称")
groupOverride: bool = Field(False, description="是否覆盖分组限制")
groupWeight: int = Field(100, description="分组权重")
# 其他属性
scanDepth: Optional[int] = Field(None, description="扫描深度")
automationId: str = Field("", description="自动化ID")
sticky: int = Field(0, description="粘性")
cooldown: int = Field(0, description="冷却时间(秒)")
delay: int = Field(0, description="延迟时间(秒)")
displayIndex: int = Field(0, description="显示索引")
# F. 递归与防抖 (Recursion)
excludeRecursion: bool = Field(True, description="排除递归")
preventRecursion: bool = Field(True, description="防止递归")
delayUntilRecursion: bool = Field(False, description="延迟直到递归发生")
# 角色过滤器
characterFilter: Dict[str, Any] = Field(
default_factory=lambda: {"isExclude": False, "names": [], "tags": []},
description="角色过滤器"
)
# G. 状态与元数据 (State & Metadata)
disable: bool = Field(False, description="是否禁用该条目")
ignoreBudget: bool = Field(False, description="忽略 Token 预算")
outletName: Optional[str] = Field(None, description="出口名称")
automationId: Optional[str] = Field(None, description="自动化 ID")
sticky: int = Field(0, description="粘性值")
cooldown: int = Field(0, description="冷却时间")
delay: int = Field(0, description="延迟触发")
triggers: List[str] = Field(default_factory=list, description="触发器数组")
displayIndex: int = Field(0, description="UI 显示索引")
vectorized: bool = Field(False, description="是否已向量化")
# 验证器
@field_validator('position')
@classmethod
def validate_position(cls, v):
"""验证 position 值是否在有效范围内"""
if v not in [0, 1, 2, 3, 4]:
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
return 1
return v
# H. 兼容性字段 (Compatibility)
matchPersonaDescription: bool = Field(False, description="匹配人设描述")
matchCharacterDescription: bool = Field(False, description="匹配角色描述")
matchCharacterPersonality: bool = Field(False, description="匹配角色性格")
matchCharacterDepthPrompt: bool = Field(False, description="匹配深度提示词")
matchScenario: bool = Field(False, description="匹配场景")
matchCreatorNotes: bool = Field(False, description="匹配作者注释")
# I. 嵌套对象 (Nested Objects)
characterFilter: Optional[Dict[str, Any]] = Field(None, description="角色过滤器配置")
extensions: Optional[Dict[str, Any]] = Field(None, description="扩展属性")
@field_validator('role')
@classmethod
def validate_role(cls, v):
"""验证 role 值是否在有效范围内"""
if v not in [0, 1, 2]:
logger.warning(f"无效的 role 值: {v},将使用默认值 2")
return 2
return v
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldInfoEntry':
def from_dict(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
SillyTavern 格式的数据创建条目
字典创建 WorldItem 对象
Args:
data: SillyTavern 格式的条目数据
data: 字典数据
Returns:
WorldInfoEntry: 世界书条目对象
Raises:
ValueError: 数据格式无效
WorldItem: WorldItem 对象
"""
try:
# 提取基本字段
uid = int(data.get("uid", data.get("id", 0))) # 条目唯一标识符
key = data.get("key", data.get("keys", [])) # 条目触发关键词数组,用于匹配和触发该条目
keysecondary = data.get("keysecondary", data.get("secondary_keys", [])) # 次要关键词数组,用于扩展触发条件
comment = data.get("comment", "") # 条目备注说明,用于描述条目的用途和特点
content = data.get("content", "") # 条目的实际内容,将被注入到提示词中
constant = data.get("constant", False) # 是否常驻注入true表示始终注入
selective = data.get("selective", True) # 是否启用选择性匹配true表示仅关键词匹配时注入
order = data.get("order", data.get("insertion_order", 100)) # 条目注入顺序权重,数字越小越优先
return cls(**data)
# 处理 position 字段,确保为整数类型
position = data.get("position", 0)
if isinstance(position, str):
# 如果是字符串,尝试转换为整数
try:
position = int(position)
except ValueError:
# 如果转换失败,使用默认值
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
position = 0
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
# 处理触发配置,默认使用永久触发
try:
trigger_config = TriggerConfig()
Returns:
Dict[str, Any]: 字典数据
"""
return self.dict()
# 设置永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, constant)
def to_entry(self) -> Entry:
"""
转换为 Entry 对象
# 设置关键词触发
if not constant and key:
keyword_config = KeywordTriggerConfig(
key=key,
keysecondary=keysecondary,
selective=selective,
selectiveLogic=data.get("selectiveLogic", 0),
matchWholeWords=data.get("matchWholeWords", False),
caseSensitive=data.get("caseSensitive", False)
)
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
# 设置RAG触发
if "rag_threshold" in data or "top_k" in data or "query_template" in data:
rag_config = RAGTriggerConfig(
threshold=data.get("rag_threshold", 0.75),
top_k=data.get("top_k", 5),
query_template=data.get("query_template", None)
)
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
# 设置条件触发
if "variable_a" in data and "operator" in data and "variable_b" in data:
condition_config = ConditionTriggerConfig(
variable_a=data.get("variable_a", ""),
operator=data.get("operator", "="),
variable_b=data.get("variable_b", "")
)
trigger_config.set_trigger(TriggerStrategy.CONDITION, True, condition_config)
except Exception as e:
# 如果触发配置解析失败,使用默认的永久触发配置
logger.warning(f"条目 {uid} 的触发配置解析失败: {str(e)},使用默认的永久触发配置")
trigger_config = TriggerConfig()
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
# 处理其他字段
probability = data.get("probability", 100)
useProbability = data.get("useProbability", False)
group = data.get("group", None)
use_regex = data.get("useRegex", False)
# 处理 extensions 字段(如果存在)
extensions = data.get("extensions", {})
if extensions:
probability = extensions.get("probability", probability)
useProbability = extensions.get("useProbability", useProbability)
group = extensions.get("group", group)
# 创建条目对象
entry = cls(
uid=uid,
key=key, # 保留key字段用于兼容
comment=comment,
content=content,
position=position,
order=order,
trigger_config=trigger_config,
group=group,
probability=probability,
useProbability=useProbability,
use_regex=use_regex
)
# 复制其他字段(如果存在)
for key, value in data.items():
if hasattr(entry, key) and key not in ["uid", "key", "comment", "content",
"constant", "position", "order", "probability",
"useProbability", "group", "use_regex",
"keysecondary", "selective", "selectiveLogic",
"matchWholeWords", "caseSensitive"]:
setattr(entry, key, value)
return entry
except Exception as e:
logger.error(f"从 SillyTavern 数据创建条目失败: {str(e)}")
raise ValueError(f"从 SillyTavern 数据创建条目失败: {str(e)}")
Returns:
Entry: Entry 对象
"""
# 转换为 SillyTavern 格式的字典
sillytavern_dict = self.to_sillytavern_dict()
# 创建 Entry 对象
return self.Entry(**sillytavern_dict)
def to_sillytavern_dict(self) -> Dict[str, Any]:
"""
@@ -294,96 +434,393 @@ class WorldInfoEntry(BaseModel):
"""
result = {
"uid": self.uid,
"key": self.key,
"comment": self.comment,
"content": self.content,
"comment": self.comment,
"position": self.position,
"order": self.order,
"depth": self.depth,
"role": self.role,
"enabled": self.enabled,
"vectorized": self.vectorized,
"selective": self.selective,
"selectiveLogic": self.selectiveLogic,
"constant": self.constant,
"key": self.key,
"keysecondary": self.keysecondary,
"matchWholeWords": self.matchWholeWords,
"caseSensitive": self.caseSensitive,
"addMemo": self.addMemo,
"disable": self.disable,
"ignoreBudget": self.ignoreBudget,
"excludeRecursion": self.excludeRecursion,
"preventRecursion": self.preventRecursion,
"matchPersonaDescription": self.matchPersonaDescription,
"matchCharacterDescription": self.matchCharacterDescription,
"matchCharacterPersonality": self.matchCharacterPersonality,
"matchCharacterDepthPrompt": self.matchCharacterDepthPrompt,
"matchScenario": self.matchScenario,
"matchCreatorNotes": self.matchCreatorNotes,
"delayUntilRecursion": self.delayUntilRecursion,
"probability": self.probability,
"useProbability": self.useProbability,
"group": self.group,
"useRegex": getattr(self, 'use_regex', False),
"preventRecursion": self.preventRecursion,
"excludeRecursion": self.excludeRecursion
"groupOverride": self.groupOverride,
"groupWeight": self.groupWeight,
"scanDepth": self.scanDepth,
"automationId": self.automationId,
"sticky": self.sticky,
"cooldown": self.cooldown,
"delay": self.delay,
"displayIndex": self.displayIndex,
"characterFilter": self.characterFilter
}
# 根据触发策略设置对应的字段
try:
# 检查永久触发是否启用
constant_enabled, _ = self.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result["constant"] = constant_enabled
# 添加 RAG 相关字段
if self.vectorized:
result["rag_threshold"] = self.rag_threshold
result["top_k"] = self.top_k
result["query_template"] = self.query_template
# 检查关键词触发是否启用
keyword_enabled, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_enabled and keyword_config:
result["selective"] = keyword_config.selective
result["keysecondary"] = keyword_config.keysecondary
result["selectiveLogic"] = keyword_config.selectiveLogic
result["matchWholeWords"] = keyword_config.matchWholeWords
result["caseSensitive"] = keyword_config.caseSensitive
# 检查RAG触发是否启用
rag_enabled, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_enabled and rag_config:
result["rag_threshold"] = rag_config.threshold
result["top_k"] = rag_config.top_k
result["query_template"] = rag_config.query_template
# 检查条件触发是否启用
condition_enabled, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_enabled and condition_config:
# 添加条件触发相关字段
if TriggerStrategy.CONDITION in self.trigger_config.get_enabled_triggers():
condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)[1]
if condition_config:
result["variable_a"] = condition_config.variable_a
result["operator"] = condition_config.operator
result["variable_b"] = condition_config.variable_b
except Exception as e:
# 如果触发配置导出失败,使用默认的永久触发配置
logger.warning(f"条目 {self.uid} 的触发配置导出失败: {str(e)},使用默认的永久触发配置")
result["constant"] = True
return result
def get_trigger_params(self) -> Dict[str, Any]:
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
获取触发策略所需的参数
从 SillyTavern 格式的数据创建 WorldItem 对象
Args:
data: SillyTavern 格式的条目数据
Returns:
Dict[str, Any]: 触发参数字典
WorldItem: WorldItem 对象
"""
params = {}
constant = data.get("constant", False)
if isinstance(constant, str):
constant = constant.lower() in ('true', '1', 'yes')
enabled = data.get("enabled", True)
if isinstance(enabled, str):
enabled = enabled.lower() in ('true', '1', 'yes')
try:
# 获取所有启用的触发策略
enabled_triggers = self.trigger_config.get_enabled_triggers()
# 提取必要字段
uid = int(data.get("uid", data.get("id", 0)))
content = data.get("content", "")
comment = data.get("comment", "")
position = data.get("position", 0)
order = data.get("order", 100)
depth = data.get("depth", 4)
role = data.get("role", 0)
enabled = data.get("enabled", True)
# 处理关键词触发
if TriggerStrategy.KEYWORD in enabled_triggers:
_, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_config:
params["key"] = keyword_config.key
params["keysecondary"] = keyword_config.keysecondary
params["selective"] = keyword_config.selective
params["selectiveLogic"] = keyword_config.selectiveLogic
params["matchWholeWords"] = keyword_config.matchWholeWords
params["caseSensitive"] = keyword_config.caseSensitive
# 处理 position 字段,确保为整数类型
if isinstance(position, str):
try:
position = int(position)
except ValueError:
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
position = 0
# 处理RAG触发
if TriggerStrategy.RAG in enabled_triggers:
_, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_config:
params["threshold"] = rag_config.threshold
params["top_k"] = rag_config.top_k
params["query_template"] = rag_config.query_template
params["vectorized"] = self.vectorized
# 初始化触发配置
trigger_config = TriggerConfig()
# 处理条件触发
if TriggerStrategy.CONDITION in enabled_triggers:
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_config:
params["variable_a"] = condition_config.variable_a
params["operator"] = condition_config.operator
params["variable_b"] = condition_config.variable_b
# 读取触发相关字段,并进行类型转换
vectorized = data.get("vectorized", False)
if isinstance(vectorized, str):
vectorized = vectorized.lower() in ('true', '1', 'yes')
selective = data.get("selective", True)
if isinstance(selective, str):
selective = selective.lower() in ('true', '1', 'yes')
constant = data.get("constant", False)
if isinstance(constant, str):
constant = constant.lower() in ('true', '1', 'yes')
# 初始化变量,确保它们始终有值
key = []
keysecondary = []
selectiveLogic = 0
matchWholeWords = False
caseSensitive = False
# 判断触发策略并设置对应的触发配置
# 优先级vectorized > constant > selective
if vectorized:
# RAG 触发
rag_config = RAGTriggerConfig(
threshold=float(data.get("rag_threshold", 0.75)),
top_k=int(data.get("top_k", 5)),
query_template=data.get("query_template", None)
)
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
elif constant:
# 永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
elif selective:
# 关键词触发
key = data.get("key", [])
keysecondary = data.get("keysecondary", data.get("secondary_keys", []))
selectiveLogic = int(data.get("selectiveLogic", 0))
# 处理 matchWholeWords 字段
matchWholeWords = data.get("matchWholeWords", False)
if matchWholeWords is None:
matchWholeWords = False
elif isinstance(matchWholeWords, str):
matchWholeWords = matchWholeWords.lower() in ('true', '1', 'yes')
# 处理 caseSensitive 字段
caseSensitive = data.get("caseSensitive", False)
if caseSensitive is None:
caseSensitive = False
elif isinstance(caseSensitive, str):
caseSensitive = caseSensitive.lower() in ('true', '1', 'yes')
keyword_config = KeywordTriggerConfig(
key=key,
keysecondary=keysecondary,
selective=selective,
selectiveLogic=selectiveLogic,
matchWholeWords=matchWholeWords,
caseSensitive=caseSensitive
)
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
else:
# 默认使用永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
# 检查是否有条件触发(虽然 JSON 中没有对应字段,但需要保留兼容性)
if "variable_a" in data and "operator" in data and "variable_b" in data:
condition_config = ConditionTriggerConfig(
variable_a=data.get("variable_a", ""),
operator=data.get("operator", "="),
variable_b=data.get("variable_b", "")
)
trigger_config.set_trigger(TriggerStrategy.CONDITION, True, condition_config)
# 创建 WorldItem 对象
return cls(
uid=uid,
content=content,
comment=comment,
position=position,
order=order,
depth=depth,
trigger_config=trigger_config,
role=role,
enabled=enabled,
vectorized=vectorized,
selective=selective,
selectiveLogic=selectiveLogic,
constant=constant,
key=key,
keysecondary=keysecondary,
matchWholeWords=matchWholeWords,
caseSensitive=caseSensitive,
rag_threshold=float(data.get("rag_threshold", None)) if vectorized else None,
top_k=int(data.get("top_k", None)) if vectorized else None,
query_template=data.get("query_template", None),
addMemo=data.get("addMemo", True),
disable=data.get("disable", False),
ignoreBudget=data.get("ignoreBudget", False),
excludeRecursion=data.get("excludeRecursion", True),
preventRecursion=data.get("preventRecursion", True),
matchPersonaDescription=data.get("matchPersonaDescription", False),
matchCharacterDescription=data.get("matchCharacterDescription", False),
matchCharacterPersonality=data.get("matchCharacterPersonality", False),
matchCharacterDepthPrompt=data.get("matchCharacterDepthPrompt", False),
matchScenario=data.get("matchScenario", False),
matchCreatorNotes=data.get("matchCreatorNotes", False),
delayUntilRecursion=data.get("delayUntilRecursion", False),
probability=data.get("probability", 100),
useProbability=data.get("useProbability", True),
group=data.get("group", ""),
groupOverride=data.get("groupOverride", False),
groupWeight=data.get("groupWeight", 100),
useGroupScoring=data.get("useGroupScoring", False),
scanDepth=data.get("scanDepth", None),
automationId=data.get("automationId", ""),
sticky=data.get("sticky", 0),
cooldown=data.get("cooldown", 0),
delay=data.get("delay", 0),
displayIndex=data.get("displayIndex", 0),
characterFilter=data.get("characterFilter", {"isExclude": False, "names": [], "tags": []})
)
except Exception as e:
# 如果获取触发参数失败,返回空字典,表示使用默认的永久触发
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
logger.error(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
raise ValueError(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
return params
if __name__ == "__main__":
"""
测试入口:用于调试 WorldItem 的解析和转换功能
可以像断点调试一样查看内部执行过程
"""
import json
import sys
from pathlib import Path
# 测试用例1基本条目
test_data_1 = {
"uid": 0,
"content": "测试内容",
"comment": "测试条目",
"position": 0,
"order": 100,
"depth": 4,
"role": 0,
"vectorized": False,
"selective": True,
"selectiveLogic": 0,
"constant": False,
"key": ["测试关键词"],
"keysecondary": [],
"matchWholeWords": False,
"caseSensitive": False,
"addMemo": True,
"disable": False,
"ignoreBudget": False,
"excludeRecursion": True,
"preventRecursion": True
}
# 测试用例2RAG触发
test_data_2 = {
"uid": 1,
"content": "RAG测试内容",
"comment": "RAG测试条目",
"position": 4,
"order": 50,
"depth": 0,
"role": 0,
"vectorized": True,
"rag_threshold": 0.8,
"top_k": 10,
"query_template": "测试模板"
}
# 测试用例3条件触发
test_data_3 = {
"uid": 2,
"content": "条件触发测试",
"comment": "条件触发条目",
"position": 1,
"order": 75,
"depth": 2,
"role": 0,
"variable_a": "好感度",
"operator": ">",
"variable_b": "50"
}
print("=" * 60)
print("开始测试 WorldItem 解析功能")
print("=" * 60)
try:
# 测试1解析基本条目
print("\n【测试1】解析基本条目...")
item1 = WorldItem.from_sillytavern_data(test_data_1)
print(f"✓ 解析成功: {item1.comment}")
print(f" - UID: {item1.uid}")
print(f" - Position: {item1.position}")
print(f" - 触发策略和配置:")
for strategy in item1.trigger_config.get_enabled_triggers():
enabled, config = item1.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
# 测试2解析RAG触发条目
print("\n【测试2】解析RAG触发条目...")
item2 = WorldItem.from_sillytavern_data(test_data_2)
print(f"✓ 解析成功: {item2.comment}")
print(f" - UID: {item2.uid}")
print(f" - Position: {item2.position}")
print(f" - 触发策略和配置:")
for strategy in item2.trigger_config.get_enabled_triggers():
enabled, config = item2.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
if item2.rag_threshold:
print(f" - RAG阈值: {item2.rag_threshold}")
# 测试3解析条件触发条目
print("\n【测试3】解析条件触发条目...")
item3 = WorldItem.from_sillytavern_data(test_data_3)
print(f"✓ 解析成功: {item3.comment}")
print(f" - UID: {item3.uid}")
print(f" - Position: {item3.position}")
print(f" - 触发策略和配置:")
for strategy in item3.trigger_config.get_enabled_triggers():
enabled, config = item3.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
# 测试4从文件读取实际数据
print("\n【测试4】从实际JSON文件读取...")
# 从当前文件位置向上查找项目根目录
current_file = Path(__file__).resolve()
project_root = current_file
while project_root.name != "llm_workflow_engine" and project_root.parent != project_root:
project_root = project_root.parent
# 构建正确的文件路径
json_path = project_root / "data" / "worldbooks" / "卡立创-v5.json"
print(f"查找文件路径: {json_path}")
if json_path.exists():
with open(json_path, 'r', encoding='utf-8') as f:
worldbook_data = json.load(f)
entries = worldbook_data.get('entries', {})
print(f"找到 {len(entries)} 个条目")
# 只测试前3个条目
for uid, entry_data in list(entries.items())[:3]:
try:
item = WorldItem.from_sillytavern_data(entry_data)
print(f"\n✓ 条目 {uid} 解析成功:")
print(f" - 备注: {item.comment}")
print(f" - UID: {item.uid}")
print(f" - Position: {item.position}")
print(f" - 触发策略和配置:")
for strategy in item.trigger_config.get_enabled_triggers():
enabled, config = item.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
except Exception as e:
print(f"\n✗ 条目 {uid} 解析失败: {str(e)}")
import traceback
traceback.print_exc()
else:
print(f"⚠ 文件不存在: {json_path}")
print(f"请确认文件路径是否正确")
# 列出可能的文件位置
possible_paths = [
project_root / "data" / "worldbooks",
project_root / "backend" / "data" / "worldbooks",
current_file.parent.parent.parent / "data" / "worldbooks"
]
print("\n可能的文件位置:")
for path in possible_paths:
if path.exists():
print(f"{path}")
for file in path.glob("*.json"):
print(f" - {file.name}")
print("\n" + "=" * 60)
print("所有测试完成!")
print("=" * 60)
except Exception as e:
print(f"\n✗ 测试失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -1,5 +1,28 @@
import { create } from 'zustand';
// LocalStorage 键名
const GLOBAL_WORLDBOOKS_KEY = 'global_worldbooks';
// 辅助函数:从 LocalStorage 加载全局世界书
const loadGlobalWorldBooks = () => {
try {
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('加载全局世界书失败:', error);
return [];
}
};
// 辅助函数:保存全局世界书到 LocalStorage
const saveGlobalWorldBooks = (globalBooks) => {
try {
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
} catch (error) {
console.error('保存全局世界书失败:', error);
}
};
// 辅助函数:处理 API 响应
const handleResponse = async (response) => {
if (!response.ok) {
@@ -26,7 +49,7 @@ const handleFileDownload = async (response, filename) => {
const useWorldBookStore = create((set, get) => ({
// 状态
worldBooks: [], // 世界书列表
globalWorldBooks: [], // 全局世界书列表
globalWorldBooks: loadGlobalWorldBooks(), // 从 LocalStorage 初始化全局世界书列表
currentWorldBook: null, // 当前选中的世界书
currentEntries: [], // 当前世界书的条目列表
currentEntry: null, // 当前选中的条目
@@ -58,14 +81,7 @@ const useWorldBookStore = create((set, get) => ({
toggleGlobalWorldBook: async (name, isGlobal) => {
set({ loading: true, error: null, success: false });
try {
// 先获取当前世界书的信息
const currentBook = get().worldBooks.find(wb => wb.name === name);
if (!currentBook) {
throw new Error(`世界书 "${name}" 不存在`);
}
const formData = new FormData();
formData.append('description', currentBook.description);
formData.append('is_global', isGlobal);
const response = await fetch(`/api/worldbooks/${name}`, {
@@ -100,6 +116,9 @@ const useWorldBookStore = create((set, get) => ({
}
}
// 保存到 LocalStorage
saveGlobalWorldBooks(updatedGlobalBooks);
return {
loading: false,
worldBooks: updatedWorldBooks,
@@ -108,7 +127,6 @@ const useWorldBookStore = create((set, get) => ({
? data
: state.currentWorldBook,
success: true,
message: isGlobal ? `已将 "${name}" 设置为全局世界书` : `已取消 "${name}" 的全局世界书状态`
};
});
@@ -130,8 +148,8 @@ const useWorldBookStore = create((set, get) => ({
const response = await fetch(`/api/worldbooks/`);
const data = await handleResponse(response);
// 筛选出全局世界书
const globalBooks = data.filter(book => book.is_global);
// 从 LocalStorage 获取全局世界书列表
const globalBooks = loadGlobalWorldBooks();
set({
loading: false,
@@ -171,12 +189,11 @@ const useWorldBookStore = create((set, get) => ({
},
// 异步操作:创建世界书
createWorldBook: async ({ name, description, is_global, file }) => {
createWorldBook: async ({ name, is_global, file }) => {
set({ loading: true, error: null, success: false });
try {
const formData = new FormData();
formData.append('name', name);
formData.append('description', description || '');
if (is_global !== undefined) {
formData.append('is_global', is_global);
}
@@ -193,9 +210,13 @@ const useWorldBookStore = create((set, get) => ({
set(state => {
const newWorldBooks = [...state.worldBooks, data];
const newGlobalBooks = data.is_global
? [...state.globalWorldBooks, data]
: state.globalWorldBooks;
let newGlobalBooks = [...state.globalWorldBooks];
// 如果是世界书被标记为全局,添加到全局列表
if (data.is_global) {
newGlobalBooks = [...newGlobalBooks, data];
saveGlobalWorldBooks(newGlobalBooks);
}
return {
loading: false,
@@ -219,13 +240,10 @@ const useWorldBookStore = create((set, get) => ({
},
// 异步操作:更新世界书
updateWorldBook: async ({ name, description, is_global, file }) => {
updateWorldBook: async ({ name, is_global, file }) => {
set({ loading: true, error: null, success: false });
try {
const formData = new FormData();
if (description !== undefined) {
formData.append('description', description);
}
if (is_global !== undefined) {
formData.append('is_global', is_global);
}
@@ -265,6 +283,9 @@ const useWorldBookStore = create((set, get) => ({
}
}
// 保存到 LocalStorage
saveGlobalWorldBooks(updatedGlobalBooks);
return {
loading: false,
worldBooks: updatedWorldBooks,
@@ -302,6 +323,9 @@ const useWorldBookStore = create((set, get) => ({
const filteredWorldBooks = state.worldBooks.filter(wb => wb.name !== name);
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
// 保存到 LocalStorage
saveGlobalWorldBooks(filteredGlobalBooks);
return {
loading: false,
worldBooks: filteredWorldBooks,
@@ -387,144 +411,142 @@ const useWorldBookStore = create((set, get) => ({
}
},
// 异步操作:创建世界书条目
createWorldBookEntry: async (name, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 异步操作:创建世界书条目
createWorldBookEntry: async (name, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
processedEntryData.trigger_config = triggerConfig;
}
const response = await fetch(`/api/worldbooks/${name}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
processedEntryData.trigger_config = triggerConfig;
}
const data = await handleResponse(response);
const response = await fetch(`/api/worldbooks/${name}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntries: [...state.currentEntries, data],
success: true,
message: '条目创建成功'
};
}
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntries: [...state.currentEntries, data],
success: true,
message: '条目创建成功'
};
});
return data;
} catch (error) {
set({
}
return {
loading: false,
error: error.message,
success: false
});
throw error;
}
},
success: true,
message: '条目创建成功'
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:更新世界书条目
updateWorldBookEntry: async (name, uid, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 异步操作:更新世界书条目
updateWorldBookEntry: async (name, uid, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
processedEntryData.trigger_config = triggerConfig;
}
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
processedEntryData.trigger_config = triggerConfig;
}
const data = await handleResponse(response);
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
set(state => {
if (state.currentWorldBook?.name === name) {
const updatedEntries = state.currentEntries.map(entry =>
entry.uid === data.uid ? data : entry
);
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
const updatedEntries = state.currentEntries.map(entry =>
entry.uid === data.uid ? data : entry
);
return {
loading: false,
currentEntries: updatedEntries,
currentEntry: state.currentEntry?.uid === data.uid
? data
: state.currentEntry,
success: true,
message: '条目更新成功'
};
}
return {
loading: false,
currentEntries: updatedEntries,
currentEntry: state.currentEntry?.uid === data.uid
? data
: state.currentEntry,
success: true,
message: '条目更新成功'
};
});
return data;
} catch (error) {
set({
}
return {
loading: false,
error: error.message,
success: false
});
throw error;
}
},
success: true,
message: '条目更新成功'
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:删除世界书条目
deleteWorldBookEntry: async (name, uid) => {
@@ -613,6 +635,9 @@ const useWorldBookStore = create((set, get) => ({
}
}
// 保存到 LocalStorage
saveGlobalWorldBooks(updatedGlobalBooks);
return {
loading: false,
worldBooks: updatedWorldBooks,
@@ -668,4 +693,4 @@ const useWorldBookStore = create((set, get) => ({
},
}));
export default useWorldBookStore;
export default useWorldBookStore;

File diff suppressed because it is too large Load Diff

View File

@@ -2,52 +2,56 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
overflow: hidden;
padding: 8px;
gap: 8px;
background: #f8f9fa;
overflow-y: auto;
}
/* 全局世界书区域 */
.global-worldbooks-section {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.global-worldbooks-slot {
background: white;
border-radius: 6px;
border: 1px solid #e0e0e0;
border-radius: 4px;
border: 1px solid #e9ecef;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.global-books-header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
gap: 3px;
padding: 8px 10px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.title-text {
font-size: 11px;
color: #777;
font-weight: 500;
font-weight: 600;
color: #2c3e50;
}
.active-books-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 3px;
}
.active-book-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
gap: 3px;
font-size: 11px;
color: #4a6cf7;
font-weight: 500;
padding: 2px 6px;
padding: 3px 6px;
background: rgba(74, 108, 247, 0.1);
border-radius: 3px;
cursor: pointer;
@@ -56,6 +60,8 @@
.active-book-item:hover {
background: rgba(74, 108, 247, 0.2);
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
}
.active-book-item .remove-btn {
@@ -72,34 +78,40 @@
/* 操作按钮组 */
.worldbook-actions {
display: flex;
gap: 6px;
gap: 4px;
flex-wrap: wrap;
}
.action-btn {
padding: 5px 10px;
padding: 4px 8px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #555;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
cursor: pointer;
font-size: 12px;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
flex: 1;
min-width: 60px;
min-width: 50px;
}
.action-btn:hover {
background: #f5f5f5;
background: #f8f9fa;
border-color: #4a6cf7;
color: #4a6cf7;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.action-btn:active {
transform: translateY(0);
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
gap: 8px;
gap: 6px;
align-items: center;
}
@@ -109,24 +121,30 @@
.dropdown-btn {
width: 100%;
padding: 8px 10px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #333;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
cursor: pointer;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
}
.dropdown-btn:hover {
background: #f5f5f5;
background: white;
border-color: #ced4da;
}
.dropdown-btn:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.dropdown-menu {
@@ -135,26 +153,26 @@
left: 0;
right: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
border: 1px solid #e9ecef;
border-radius: 3px;
margin-top: 3px;
max-height: 180px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dropdown-item {
padding: 8px 10px;
padding: 5px 8px;
cursor: pointer;
transition: background 0.15s ease;
font-size: 13px;
color: #333;
transition: all 0.15s ease;
font-size: 11px;
color: #495057;
font-weight: 500;
}
.dropdown-item:hover {
background: #f5f5f5;
background: #f8f9fa;
}
.dropdown-item.active {
@@ -168,77 +186,78 @@
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.entry-item {
padding: 10px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.entry-item:hover {
background: #f9f9f9;
border-color: #4a6cf7;
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.entry-item.active {
background: rgba(74, 108, 247, 0.1);
border-color: #4a6cf7;
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
margin-bottom: 3px;
}
.entry-name {
font-weight: 600;
font-size: 14px;
color: #333;
font-size: 12px;
color: #2c3e50;
}
.entry-status {
font-size: 11px;
color: #777;
padding: 2px 6px;
border-radius: 3px;
background: #f5f5f5;
font-size: 10px;
color: #6c757d;
padding: 2px 5px;
border-radius: 2px;
background: #f1f3f5;
font-weight: 500;
}
.entry-status.enabled {
color: #4a90e2;
background: rgba(74, 144, 226, 0.1);
color: #4a6cf7;
background: rgba(74, 108, 247, 0.1);
}
.entry-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #777;
gap: 8px;
font-size: 10px;
color: #6c757d;
font-weight: 500;
}
/* 编辑面板 */
.edit-panel {
position: fixed;
top: 0;
top: 60px;
right: 0;
bottom: 0;
left: 300px;
left: 280px;
background: white;
z-index: 1000;
padding: 16px;
padding: 12px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease-out;
border-left: 1px solid #e0e0e0;
border-left: 1px solid #e9ecef;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
@@ -250,14 +269,14 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
.edit-panel-header h2 {
font-size: 16px;
color: #333;
font-size: 13px;
color: #2c3e50;
margin: 0;
font-weight: 600;
}
@@ -265,26 +284,31 @@
.close-btn {
background: none;
border: none;
color: #999;
font-size: 20px;
color: #adb5bd;
font-size: 18px;
cursor: pointer;
padding: 4px;
padding: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.close-btn:hover {
color: #333;
color: #495057;
}
.form-group {
margin-bottom: 12px;
margin-bottom: 10px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: #555;
margin-bottom: 4px;
font-size: 11px;
color: #495057;
font-weight: 500;
}
@@ -292,12 +316,12 @@
.form-textarea,
.form-select {
width: 100%;
padding: 8px 10px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #333;
font-size: 13px;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
font-size: 11px;
transition: all 0.15s ease;
}
@@ -310,17 +334,19 @@
}
.form-textarea {
min-height: 100px;
min-height: 80px;
resize: vertical;
font-family: inherit;
line-height: 1.4;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: #555;
font-size: 11px;
color: #495057;
font-weight: 500;
}
@@ -330,11 +356,11 @@
/* 按钮样式 */
.btn {
padding: 8px 12px;
padding: 5px 10px;
border: none;
border-radius: 4px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
}
@@ -346,6 +372,8 @@
.btn-primary:hover {
background: #3a5ce5;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
}
.btn-danger {
@@ -355,24 +383,260 @@
.btn-danger:hover {
background: #c0392b;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(231, 76, 60, 0.15);
}
/* 加载和错误状态 */
.loading,
.error {
text-align: center;
padding: 20px;
font-size: 13px;
padding: 16px;
font-size: 11px;
font-weight: 500;
}
.loading {
color: #777;
font-weight: 500;
color: #6c757d;
}
.error {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
border-radius: 3px;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: #f1f3f5;
border-radius: 2px;
}
::-webkit-scrollbar-thumb {
background: #ced4da;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #adb5bd;
}
/* 插入位置权重提示 */
.position-tooltip {
position: relative;
display: inline-block;
cursor: help;
}
.position-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
color: white;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
margin-bottom: 6px;
}
.position-tooltip:hover::after {
opacity: 1;
visibility: visible;
}
/* 紧凑的参数显示 */
.compact-params {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.param-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #6c757d;
}
.param-label {
font-weight: 500;
}
.param-value {
color: #495057;
}
/* 响应式设计 */
@media (max-width: 768px) {
.worldbook-content {
padding: 6px;
gap: 6px;
}
.edit-panel {
left: 0;
top: 50px;
}
.compact-params {
gap: 6px;
}
}
/* 编辑面板打开状态优化 */
.edit-panel.open {
transform: translateX(0);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15);
}
/* 位置选择器样式 */
.position-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.position-option {
padding: 6px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.position-option:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.position-option.active {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
}
.position-label {
display: block;
font-size: 11px;
font-weight: 500;
color: #2c3e50;
margin-bottom: 2px;
}
.position-weight {
display: inline-block;
padding: 1px 4px;
font-size: 10px;
font-weight: 600;
border-radius: 2px;
background: #f1f3f5;
color: #6c757d;
}
.position-option.active .position-weight {
background: rgba(74, 108, 247, 0.1);
color: #4a6cf7;
}
/* 触发策略选择器样式 */
.trigger-strategy-selector {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.trigger-strategy-btn {
flex: 1;
min-width: 80px;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: #495057;
transition: all 0.15s ease;
}
.trigger-strategy-btn:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.trigger-strategy-btn.active {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
color: #4a6cf7;
}
/* 编辑面板内容区域优化 */
.edit-panel .form-group {
margin-bottom: 8px;
}
.edit-panel .form-label {
margin-bottom: 3px;
}
.edit-panel .form-input,
.edit-panel .form-textarea,
.edit-panel .form-select {
padding: 4px 7px;
}
.edit-panel .form-textarea {
min-height: 60px;
line-height: 1.3;
}
/* 编辑面板按钮优化 */
.edit-panel .btn {
width: 100%;
margin-top: 8px;
}
/* 权重标签颜色区分 */
.weight-high {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.weight-medium {
background: rgba(241, 196, 15, 0.1);
color: #f39c12;
}
.weight-low {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
}
.weight-extreme {
background: rgba(192, 57, 43, 0.1);
color: #c0392b;
}
.weight-maximum {
background: rgba(142, 68, 173, 0.1);
color: #8e44ad;
}