世界书部分基本完成,剩余编辑条目部分,可以推进到下一步
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
# 测试用例2:RAG触发
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user