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

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,244 +197,56 @@ class TriggerConfig(BaseModel):
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
class WorldInfoEntry(BaseModel):
class WorldItem(BaseModel):
"""
世界书条目完整模型
包含所有 SillyTavern 世界书条目属性,用于导入导出
"""
class Entry(BaseModel):
"""
世界书条目模型
完整兼容所有可能的属性字段
精简版,只包含必要字段,用于实际使用
"""
# A. 基础定义 (Base Fields)
# 基础定义
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(
# 触发配置
trigger_config: Optional[TriggerConfig] = Field(
default_factory=TriggerConfig,
description="触发配置"
description="触发配置,为空表示无需触发配置"
)
# D. 高级匹配 (Advanced Matching)
# 角色匹配
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
useGroupScoring: bool = Field(False, description="是否使用分组评分")
# E. 分组设置 (Grouping)
group: Optional[str] = Field(None, description="分组名称")
groupOverride: bool = Field(False, description="是否覆盖分组限制")
groupWeight: int = Field(100, description="分组权重")
# F. 递归与防抖 (Recursion)
excludeRecursion: bool = Field(True, description="排除递归")
preventRecursion: bool = Field(True, description="防止递归")
delayUntilRecursion: bool = Field(False, 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="是否已向量化")
# 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="扩展属性")
# 条目启用状态
enabled: bool = Field(True, description="条目是否启用启用才会被插入到LLM")
@field_validator('position')
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldInfoEntry':
"""
从 SillyTavern 格式的数据创建条目
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
Args:
data: SillyTavern 格式的条目数据
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
Returns:
WorldInfoEntry: 世界书条目对象
Raises:
ValueError: 数据格式无效
Dict[str, Any]: 字典数据
"""
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)) # 条目注入顺序权重,数字越小越优先
# 处理 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
# 处理触发配置,默认使用永久触发
try:
trigger_config = TriggerConfig()
# 设置永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, constant)
# 设置关键词触发
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)}")
def to_sillytavern_dict(self) -> Dict[str, Any]:
"""
转换为 SillyTavern 格式的字典
Returns:
Dict[str, Any]: SillyTavern 格式的条目数据
"""
result = {
"uid": self.uid,
"key": self.key,
"comment": self.comment,
"content": self.content,
"position": self.position,
"order": self.order,
"probability": self.probability,
"useProbability": self.useProbability,
"group": self.group,
"useRegex": getattr(self, 'use_regex', False),
"preventRecursion": self.preventRecursion,
"excludeRecursion": self.excludeRecursion
}
# 根据触发策略设置对应的字段
try:
# 检查永久触发是否启用
constant_enabled, _ = self.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result["constant"] = constant_enabled
# 检查关键词触发是否启用
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:
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
return self.dict()
def get_trigger_params(self) -> Dict[str, Any]:
"""
@@ -355,6 +261,15 @@ class WorldInfoEntry(BaseModel):
# 获取所有启用的触发策略
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)
@@ -366,15 +281,6 @@ class WorldInfoEntry(BaseModel):
params["matchWholeWords"] = keyword_config.matchWholeWords
params["caseSensitive"] = keyword_config.caseSensitive
# 处理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
# 处理条件触发
if TriggerStrategy.CONDITION in enabled_triggers:
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
@@ -387,3 +293,534 @@ class WorldInfoEntry(BaseModel):
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
return params
# 基础定义
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: TriggerConfig = Field(
default_factory=TriggerConfig,
description="触发配置"
)
# 角色匹配
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="是否使用分组评分")
# 其他属性
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="显示索引")
# 角色过滤器
characterFilter: Dict[str, Any] = Field(
default_factory=lambda: {"isExclude": False, "names": [], "tags": []},
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
@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_dict(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
从字典创建 WorldItem 对象
Args:
data: 字典数据
Returns:
WorldItem: WorldItem 对象
"""
return cls(**data)
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
Returns:
Dict[str, Any]: 字典数据
"""
return self.dict()
def to_entry(self) -> Entry:
"""
转换为 Entry 对象
Returns:
Entry: Entry 对象
"""
# 转换为 SillyTavern 格式的字典
sillytavern_dict = self.to_sillytavern_dict()
# 创建 Entry 对象
return self.Entry(**sillytavern_dict)
def to_sillytavern_dict(self) -> Dict[str, Any]:
"""
转换为 SillyTavern 格式的字典
Returns:
Dict[str, Any]: SillyTavern 格式的条目数据
"""
result = {
"uid": self.uid,
"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,
"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
}
# 添加 RAG 相关字段
if self.vectorized:
result["rag_threshold"] = self.rag_threshold
result["top_k"] = self.top_k
result["query_template"] = self.query_template
# 添加条件触发相关字段
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
return result
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
从 SillyTavern 格式的数据创建 WorldItem 对象
Args:
data: SillyTavern 格式的条目数据
Returns:
WorldItem: WorldItem 对象
"""
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:
# 提取必要字段
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)
# 处理 position 字段,确保为整数类型
if isinstance(position, str):
try:
position = int(position)
except ValueError:
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
position = 0
# 初始化触发配置
trigger_config = TriggerConfig()
# 读取触发相关字段,并进行类型转换
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.error(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
raise ValueError(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
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,
@@ -452,7 +476,6 @@ const useWorldBookStore = create((set, get) => ({
}
},
// 异步操作:更新世界书条目
updateWorldBookEntry: async (name, uid, entryData) => {
set({ loading: true, error: null, success: false });
@@ -525,7 +548,6 @@ const useWorldBookStore = create((set, get) => ({
}
},
// 异步操作:删除世界书条目
deleteWorldBookEntry: async (name, uid) => {
set({ loading: true, error: null, success: false });
@@ -613,6 +635,9 @@ const useWorldBookStore = create((set, get) => ({
}
}
// 保存到 LocalStorage
saveGlobalWorldBooks(updatedGlobalBooks);
return {
loading: false,
worldBooks: updatedWorldBooks,

View File

@@ -31,39 +31,12 @@ const WorldBook = () => {
const [newEntry, setNewEntry] = useState({
uid: 0,
key: [],
keysecondary: [],
content: '',
comment: '',
constant: false,
position: 0,
order: 100,
depth: 4,
selective: true,
selectiveLogic: 0,
probability: 100,
useProbability: false,
role: 0,
caseSensitive: false,
matchWholeWords: false,
useGroupScoring: false,
group: '',
groupOverride: false,
groupWeight: 100,
excludeRecursion: true,
preventRecursion: true,
delayUntilRecursion: false,
disable: false,
ignoreBudget: false,
outletName: '',
automationId: '',
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
vectorized: false,
// 新的触发配置结构
trigger_config: {
triggers: {
constant: [true, null],
@@ -89,10 +62,9 @@ const WorldBook = () => {
}
});
const [showEditPanel, setShowEditPanel] = useState(false);
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
const [showGlobalDropdown, setShowGlobalDropdown] = useState(false);
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
useEffect(() => {
fetchWorldBooks();
@@ -112,13 +84,26 @@ const WorldBook = () => {
}
}, [error, clearError]);
useEffect(() => {
if (currentEntry && currentEntry.trigger_config) {
const triggers = currentEntry.trigger_config.triggers;
if (triggers.constant[0]) {
setActiveTriggerStrategy('constant');
} else if (triggers.keyword[0]) {
setActiveTriggerStrategy('keyword');
} else if (triggers.rag[0]) {
setActiveTriggerStrategy('rag');
} else if (triggers.condition[0]) {
setActiveTriggerStrategy('condition');
}
}
}, [currentEntry]);
const handleCreateWorldBook = async () => {
const name = prompt('请输入世界书名称:');
const description = prompt('请输入世界书描述(可选):') || '';
if (name) {
try {
await createWorldBook({ name, description });
// 创建成功后自动选择新创建的世界书
await createWorldBook({ name });
const newBook = worldBooks.find(wb => wb.name === name);
if (newBook) {
handleSelectWorldBook(newBook);
@@ -150,24 +135,22 @@ const WorldBook = () => {
const handleAddEntry = async () => {
if (!currentWorldBook) return;
// 生成新的UID
const maxUid = currentEntries.reduce((max, entry) => Math.max(max, entry.uid), 0);
const newUid = maxUid + 1;
// 处理触发配置数据
const triggerConfig = {
triggers: {
constant: [newEntry.constant, null],
keyword: [!newEntry.constant && newEntry.key.length > 0, {
key: newEntry.key,
keysecondary: newEntry.keysecondary,
selective: newEntry.selective,
selectiveLogic: newEntry.selectiveLogic,
matchWholeWords: newEntry.matchWholeWords,
caseSensitive: newEntry.caseSensitive
constant: [newEntry.trigger_config.triggers.constant[0], null],
keyword: [!newEntry.trigger_config.triggers.constant[0] && newEntry.trigger_config.triggers.keyword[1].key.length > 0, {
key: newEntry.trigger_config.triggers.keyword[1].key,
keysecondary: newEntry.trigger_config.triggers.keyword[1].keysecondary,
selective: newEntry.trigger_config.triggers.keyword[1].selective,
selectiveLogic: newEntry.trigger_config.triggers.keyword[1].selectiveLogic,
matchWholeWords: newEntry.trigger_config.triggers.keyword[1].matchWholeWords,
caseSensitive: newEntry.trigger_config.triggers.keyword[1].caseSensitive
}],
rag: [false, {
threshold: newEntry.rag_threshold || 0.75,
threshold: 0.75,
top_k: 5,
query_template: null
}],
@@ -187,65 +170,19 @@ const WorldBook = () => {
order: newEntry.order,
depth: newEntry.depth,
role: newEntry.role,
disable: newEntry.disable,
ignoreBudget: newEntry.ignoreBudget,
outletName: newEntry.outletName,
automationId: newEntry.automationId,
sticky: newEntry.sticky,
cooldown: newEntry.cooldown,
delay: newEntry.delay,
triggers: newEntry.triggers,
displayIndex: newEntry.displayIndex,
vectorized: newEntry.vectorized,
useGroupScoring: newEntry.useGroupScoring,
group: newEntry.group,
groupOverride: newEntry.groupOverride,
groupWeight: newEntry.groupWeight,
excludeRecursion: newEntry.excludeRecursion,
preventRecursion: newEntry.preventRecursion,
delayUntilRecursion: newEntry.delayUntilRecursion,
probability: newEntry.probability,
useProbability: newEntry.useProbability,
trigger_config: triggerConfig
};
try {
await createWorldBookEntry(currentWorldBook.name, entryData);
// 重置新条目表单
setNewEntry({
uid: 0,
key: [],
keysecondary: [],
content: '',
comment: '',
constant: false,
position: 0,
order: 100,
depth: 4,
selective: true,
selectiveLogic: 0,
probability: 100,
useProbability: false,
role: 0,
caseSensitive: false,
matchWholeWords: false,
useGroupScoring: false,
group: '',
groupOverride: false,
groupWeight: 100,
excludeRecursion: true,
preventRecursion: true,
delayUntilRecursion: false,
disable: false,
ignoreBudget: false,
outletName: '',
automationId: '',
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
vectorized: false,
trigger_config: {
triggers: {
constant: [true, null],
@@ -275,7 +212,6 @@ const WorldBook = () => {
}
};
const handleEntryClick = (entry) => {
setCurrentEntry(entry);
setShowEditPanel(true);
@@ -293,6 +229,58 @@ const WorldBook = () => {
}
};
const handleTriggerStrategyChange = async (strategy) => {
if (!currentEntry || !currentWorldBook) return;
setActiveTriggerStrategy(strategy);
const updatedTriggers = {
constant: [false, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
};
if (strategy !== 'constant') {
updatedTriggers[strategy][0] = true;
} else {
updatedTriggers.constant[0] = true;
}
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: updatedTriggers
};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
...currentEntry,
trigger_config: updatedTriggerConfig
});
setCurrentEntry({
...currentEntry,
trigger_config: updatedTriggerConfig
});
} catch (err) {
console.error('更新触发策略失败:', err);
}
};
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
@@ -334,7 +322,6 @@ const WorldBook = () => {
try {
await useWorldBookStore.getState().importWorldBook(name, file);
await fetchWorldBooks();
// 导入成功后选择新导入的世界书
const importedBook = worldBooks.find(wb => wb.name === name);
if (importedBook) {
handleSelectWorldBook(importedBook);
@@ -356,11 +343,22 @@ const WorldBook = () => {
}
};
const getPositionInfo = (position) => {
const positions = {
0: { label: '角色定义之后', weight: '高', desc: 'AI读完人设紧接着就读到这里非常适合补充角色的详细设定、性格细节或特殊规则' },
1: { label: '角色定义之前', weight: '中', desc: '在角色卡内容的最上方,通常用于定义角色的基础背景,让人设部分来解释这些背景' },
2: { label: '示例对话之前', weight: '低', desc: '在对话示例的最上方' },
3: { label: '示例对话之后', weight: '低', desc: '用于在对话开始前提供最后的上下文补充' },
4: { label: '系统提示/作者注释', weight: '极高', desc: 'AI对最近看到的信息记忆最清晰适合动态信息、当前场景描述或临时规则' },
5: { label: '作为系统消息', weight: '最高', desc: '强制作为System Prompt插入通常用于强制指令' }
};
return positions[position] || positions[0];
};
return (
<div className="worldbook-content">
{/* 全局世界书区域 */}
<div className="worldbook-selector-section">
{/* 全局世界书展示区域 */}
<div className="global-books-display">
<div className="global-books-header">
<span className="title-text">全局世界书</span>
@@ -423,7 +421,6 @@ const WorldBook = () => {
key={book.name}
className={`dropdown-item ${currentWorldBook?.name === book.name ? 'active' : ''}`}
onClick={(e) => {
// 防止点击复选框时触发选择世界书
if (e.target.type !== 'checkbox') {
handleSelectWorldBook(book);
}
@@ -432,16 +429,13 @@ const WorldBook = () => {
<label className="checkbox-label">
<input
type="checkbox"
checked={book.is_global}
checked={globalWorldBooks.some(wb => wb.name === book.name)}
onChange={(e) => {
e.stopPropagation();
handleToggleGlobal(book.name, e.target.checked);
}}
/>
<span className="book-name">{book.name}</span>
{book.description && (
<span className="book-desc">{book.description}</span>
)}
</label>
</div>
))}
@@ -464,8 +458,7 @@ const WorldBook = () => {
) : error ? (
<div className="error">{error}</div>
) : currentWorldBook ? (
<div className="entries-container" style={{ maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' }}>
<div className="entries-container">
{currentEntries.length > 0 ? (
currentEntries.map(entry => (
<div
@@ -477,14 +470,27 @@ const WorldBook = () => {
<span className="entry-name">
{entry.comment || `条目 #${entry.uid}`}
</span>
<span className={`entry-status ${!entry.disable ? 'enabled' : ''}`}>
{!entry.disable ? '启用' : '禁用'}
<span className="entry-status">
{entry.trigger_config.triggers.constant[0] ? '常驻' : '触发'}
</span>
</div>
<div className="entry-meta">
<span>策略: {entry.trigger_strategy}</span>
<span>位置: {entry.position}</span>
<span>顺序: {entry.order}</span>
<div className="compact-params">
<div className="param-item">
<span className="param-label">位置:</span>
<span className="param-value">{getPositionInfo(entry.position).label}</span>
</div>
<div className="param-item">
<span className="param-label">权重:</span>
<span className="param-value">{getPositionInfo(entry.position).weight}</span>
</div>
<div className="param-item">
<span className="param-label">顺序:</span>
<span className="param-value">{entry.order}</span>
</div>
<div className="param-item">
<span className="param-label">深度:</span>
<span className="param-value">{entry.depth}</span>
</div>
</div>
</div>
))
@@ -511,6 +517,114 @@ const WorldBook = () => {
</button>
</div>
<div className="form-group">
<label className="form-label">条目名称</label>
<input
type="text"
className="form-input"
value={currentEntry.comment || ''}
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">内容</label>
<textarea
className="form-input"
value={currentEntry.content || ''}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
rows={10}
/>
</div>
<div className="form-group">
<label className="form-label">插入位置</label>
<div className="position-selector">
{[0, 1, 2, 3, 4, 5].map(pos => {
const info = getPositionInfo(pos);
return (
<div
key={pos}
className={`position-option ${currentEntry.position === pos ? 'active' : ''}`}
onClick={() => handleEntryUpdate('position', pos)}
>
<div className="position-tooltip" data-tooltip={info.desc}>
<span className="position-label">{info.label}</span>
<span className="position-weight">{info.weight}</span>
</div>
</div>
);
})}
</div>
</div>
<div className="form-group">
<label className="form-label">顺序权重</label>
<input
type="number"
className="form-input"
value={currentEntry.order || 100}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
/>
</div>
<div className="form-group">
<label className="form-label">扫描深度</label>
<input
type="number"
className="form-input"
value={currentEntry.depth || 4}
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
/>
</div>
<div className="form-group">
<label className="form-label">角色匹配</label>
<select
className="form-input"
value={currentEntry.role || 0}
onChange={(e) => handleEntryUpdate('role', parseInt(e.target.value))}
>
<option value={0}>Both</option>
<option value={1}>User</option>
<option value={2}>Assistant</option>
</select>
</div>
{/* 触发策略选择器 */}
<div className="form-group">
<label className="form-label">触发策略</label>
<div className="trigger-strategy-selector">
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'constant' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('constant')}
>
常驻触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'keyword' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('keyword')}
>
关键词触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'rag' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('rag')}
>
RAG触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'condition' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('condition')}
>
条件触发
</button>
</div>
</div>
{/* 根据选择的触发策略显示对应的配置表单 */}
{activeTriggerStrategy === 'keyword' && (
<>
<div className="form-group">
<label className="form-label">主关键词</label>
<input
@@ -523,7 +637,7 @@ const WorldBook = () => {
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
key: e.target.value.split(',').map(k => k.trim())
@@ -548,7 +662,7 @@ const WorldBook = () => {
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
keysecondary: e.target.value.split(',').map(k => k.trim())
@@ -572,7 +686,7 @@ const WorldBook = () => {
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
selective: e.target.checked
@@ -598,7 +712,7 @@ const WorldBook = () => {
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
matchWholeWords: e.target.checked
@@ -624,7 +738,7 @@ const WorldBook = () => {
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
caseSensitive: e.target.checked
@@ -638,63 +752,177 @@ const WorldBook = () => {
区分大小写
</label>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.useProbability}
onChange={(e) => handleEntryUpdate('useProbability', e.target.checked)}
/>
使用概率判定
</label>
</div>
{currentEntry.useProbability && (
<div className="form-group">
<label className="form-label">触发概率 (%)</label>
<input
type="number"
className="form-input"
min="0"
max="100"
value={currentEntry.probability}
onChange={(e) => handleEntryUpdate('probability', parseInt(e.target.value))}
/>
</div>
</>
)}
{activeTriggerStrategy === 'rag' && (
<>
<div className="form-group">
<label className="form-label">分组</label>
<label className="form-label">相似度阈值</label>
<input
type="number"
step="0.01"
min="0"
max="1"
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.threshold || 0.75}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
threshold: parseFloat(e.target.value)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">返回条目数</label>
<input
type="number"
min="1"
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.top_k || 5}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
top_k: parseInt(e.target.value)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">查询模板</label>
<textarea
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.query_template || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
query_template: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
rows={3}
/>
</div>
</>
)}
{activeTriggerStrategy === 'condition' && (
<>
<div className="form-group">
<label className="form-label">变量A</label>
<input
type="text"
className="form-input"
value={currentEntry.group || ''}
onChange={(e) => handleEntryUpdate('group', e.target.value)}
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_a: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">分组权重</label>
<input
type="number"
<label className="form-label">运算符</label>
<select
className="form-input"
value={currentEntry.groupWeight}
onChange={(e) => handleEntryUpdate('groupWeight', parseInt(e.target.value))}
/>
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
operator: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
>
<option value="等于">等于</option>
<option value="大于">大于</option>
<option value="小于">小于</option>
<option value="不小于">不小于</option>
<option value="不大于">不大于</option>
<option value="不等于">不等于</option>
<option value="包括">包括</option>
</select>
</div>
<div className="form-group">
<label className="form-label">
<label className="form-label">变量B</label>
<input
type="checkbox"
checked={currentEntry.groupOverride}
onChange={(e) => handleEntryUpdate('groupOverride', e.target.checked)}
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_b: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
覆盖分组限制
</label>
</div>
</>
)}
<button
className="btn btn-danger"

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;
}