重构后端成功
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# 1. 定义请求体模型
|
||||
class ChatRequest(BaseModel):
|
||||
# --- 基础信息 ---
|
||||
mes: str = Field(..., description="用户输入的消息内容")
|
||||
is_user: bool = Field(..., description="标识发送者是否为用户(True为用户,False为AI)")
|
||||
floor_number: int = Field(..., description="当前对话的楼层号,用于判断是否为重试(Regenerate)请求")
|
||||
|
||||
# --- 身份与会话 ---
|
||||
name: str = Field("default", description="发送者的显示名称,默认为'default'")
|
||||
role_name: Optional[str] = Field(None, description="当前绑定的角色名称")
|
||||
chat_name: Optional[str] = Field(None, description="当前会话的标识名称")
|
||||
preset: Optional[str] = Field(None, description="预设的提示词或系统指令")
|
||||
|
||||
# --- 功能开关 ---
|
||||
stream: bool = Field(False, description="是否开启流式输出")
|
||||
img_switch: bool = Field(False, description="是否开启图片生成功能")
|
||||
table_switch: bool = Field(False, description="是否开启表格生成功能")
|
||||
|
||||
# 其他可能需要的参数,比如历史记录,可以在这里加
|
||||
# history: Optional[List[Dict]] = None
|
||||
@@ -1,65 +0,0 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class PromptComponent(BaseModel):
|
||||
"""预设组件类,代表一个独立的提示词模块"""
|
||||
|
||||
identifier: str = Field(..., description="唯一标识符,用于引用和定位组件")
|
||||
name: str = Field(..., description="组件显示名称")
|
||||
content: str = Field("", description="组件内容文本")
|
||||
# 0:System,1:User,2:Assistant
|
||||
role: int = Field(0, description="角色身份(0:System,1:User,2:Assistant)")
|
||||
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
||||
marker: bool = Field(False, description="是否为动态插入点占位符")
|
||||
|
||||
@validator('role')
|
||||
def validate_role(cls, v):
|
||||
"""验证角色值是否在有效范围内"""
|
||||
if not isinstance(v, int) or v not in [0, 1, 2]:
|
||||
raise ValueError("角色值必须是0(System)、1(User)或2(Assistant)")
|
||||
return v
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
"""
|
||||
更新组件属性
|
||||
|
||||
参数:
|
||||
**kwargs: 要更新的字段和值
|
||||
|
||||
异常:
|
||||
ValueError: 当尝试更新identifier时抛出
|
||||
"""
|
||||
if 'identifier' in kwargs:
|
||||
raise ValueError("组件标识符不可修改")
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将组件转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 组件的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PromptComponent':
|
||||
"""
|
||||
从字典创建组件实例,自动处理role字段的类型转换
|
||||
|
||||
参数:
|
||||
data: 包含组件数据的字典
|
||||
|
||||
返回:
|
||||
PromptComponent: 组件实例
|
||||
"""
|
||||
# 处理role字段,将字符串转换为整数
|
||||
if 'role' in data and isinstance(data['role'], str):
|
||||
role_map = {'system': 0, 'user': 1, 'assistant': 2}
|
||||
data['role'] = role_map.get(data['role'].lower(), 0)
|
||||
|
||||
return cls(**data)
|
||||
@@ -1,591 +0,0 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import json
|
||||
from .PromptComponent import PromptComponent
|
||||
|
||||
|
||||
class AIDesignSpec(BaseModel):
|
||||
"""AI设计规范类,包含模型生成的核心参数和动态结构配置"""
|
||||
|
||||
# [Base] 基础核心参数
|
||||
temperature: float = Field(1.0, description="生成温度,控制随机性(0-2)")
|
||||
frequency_penalty: float = Field(0.0, description="频率惩罚,降低重复token概率")
|
||||
presence_penalty: float = Field(0.0, description="存在惩罚,鼓励谈论新话题")
|
||||
top_p: float = Field(1.0, description="核采样,控制词汇选择范围")
|
||||
top_k: int = Field(0, description="随机采样范围,从概率最高的K个词中选择")
|
||||
top_a: float = Field(0.0, description="基于平方概率分布的采样")
|
||||
min_p: float = Field(0.0, description="最小概率阈值")
|
||||
repetition_penalty: float = Field(1.0, description="重复惩罚系数(1.0-1.2)")
|
||||
max_context: int = Field(2048, description="上下文窗口大小(Token上限)")
|
||||
max_tokens: int = Field(250, description="单次回复的最大长度")
|
||||
max_context_unlocked: bool = Field(False, description="是否允许超出限制的上下文")
|
||||
names_behavior: int = Field(0, description="名字处理行为(0=默认,1=始终包含,2=仅角色)")
|
||||
send_if_empty: str = Field("", description="用户发送空消息时自动填充的内容")
|
||||
impersonation_prompt: str = Field("", description="模仿模式下使用的提示词")
|
||||
new_chat_prompt: str = Field("", description="开启新聊天时自动发送的系统提示")
|
||||
new_group_chat_prompt: str = Field("", description="开启新群组聊天时的提示")
|
||||
new_example_chat_prompt: str = Field("", description="新示例聊天的提示")
|
||||
continue_nudge_prompt: str = Field("", description="续写功能触发的提示词")
|
||||
bias_preset_selected: str = Field("", description="选用的偏见预设")
|
||||
wi_format: str = Field("{0}", description="世界书条目的格式化字符串")
|
||||
scenario_format: str = Field("{{scenario}}", description="场景描述的格式化字符串")
|
||||
personality_format: str = Field("", description="角色性格的格式化字符串")
|
||||
group_nudge_prompt: str = Field("", description="群组聊天中提示AI仅以特定角色回复的提示词")
|
||||
stream: bool = Field(True, description="是否使用流式输出")
|
||||
assistant_prefill: str = Field("", description="强制AI回复的开头内容")
|
||||
assistant_impersonation: str = Field("", description="模仿模式下强制AI回复的开头内容")
|
||||
use_sysprompt: bool = Field(True, description="是否强制将提示词注入系统层")
|
||||
squash_system_messages: bool = Field(False, description="是否压缩系统消息")
|
||||
media_inlining: bool = Field(False, description="是否内联媒体描述")
|
||||
continue_prefill: bool = Field(True, description="续写时是否预填充内容")
|
||||
continue_postfix: str = Field(" ", description="续写时添加的后缀")
|
||||
seed: int = Field(-1, description="随机种子(-1为随机)")
|
||||
n: int = Field(1, description="生成回复的数量")
|
||||
|
||||
# [Dynamic] 动态结构
|
||||
prompts: List[PromptComponent] = Field(
|
||||
default_factory=list,
|
||||
description="组件库,定义所有可用的积木块"
|
||||
)
|
||||
prompt_order: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="组装说明书,定义构建最终提示词的顺序"
|
||||
)
|
||||
|
||||
@validator('prompts')
|
||||
def validate_prompts_unique_identifier(cls, v):
|
||||
"""验证组件标识符唯一性"""
|
||||
identifiers = [comp.identifier for comp in v]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise ValueError("组件标识符必须唯一")
|
||||
return v
|
||||
|
||||
@validator('prompt_order')
|
||||
def validate_prompt_order_exists(cls, v, values):
|
||||
"""验证prompt_order中的组件ID是否存在于prompts中"""
|
||||
if 'prompts' in values:
|
||||
prompt_ids = {comp.identifier for comp in values['prompts']}
|
||||
invalid_ids = set(v) - prompt_ids
|
||||
if invalid_ids:
|
||||
raise ValueError(f"prompt_order中包含不存在的组件ID: {invalid_ids}")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def get_preset_dir(cls) -> Path:
|
||||
"""获取预设目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
preset_dir = settings.DATA_PATH / "preset"
|
||||
# 如果路径不存在,尝试使用相对路径
|
||||
if not preset_dir.exists():
|
||||
# 尝试从当前工作目录构建路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
return preset_dir
|
||||
except ImportError:
|
||||
# 如果无法导入settings,尝试使用相对路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
|
||||
@classmethod
|
||||
async def list_all_presets(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
if not preset_dir.exists():
|
||||
return {"presets": []}
|
||||
|
||||
presets = []
|
||||
for preset_file in preset_dir.glob("*.json"):
|
||||
try:
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
presets.append({
|
||||
"name": preset_file.stem,
|
||||
"description": preset_data.get("description", ""),
|
||||
"component_count": len(preset_data.get("prompts", [])),
|
||||
"temperature": preset_data.get("temperature", 1.0)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的预设文件
|
||||
return {"presets": presets}
|
||||
|
||||
@classmethod
|
||||
async def get_preset(cls, preset_name: str) -> Dict[str, Any]:
|
||||
"""获取指定预设的完整内容"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 处理prompt_order,简化为单角色配置
|
||||
if 'prompt_order' in preset_data and isinstance(preset_data['prompt_order'], list) and len(
|
||||
preset_data['prompt_order']) > 0:
|
||||
# 检查第一个元素是否为字典(多角色配置)
|
||||
first_item = preset_data['prompt_order'][0]
|
||||
if isinstance(first_item, dict) and 'order' in first_item:
|
||||
# 提取第一个角色的order配置
|
||||
first_role_order = first_item
|
||||
if isinstance(first_role_order['order'], list):
|
||||
# 简化为只包含enabled为True的identifier列表
|
||||
simplified_order = [
|
||||
item.get('identifier')
|
||||
for item in first_role_order['order']
|
||||
if item.get('enabled', True)
|
||||
]
|
||||
preset_data['prompt_order'] = simplified_order
|
||||
|
||||
# 转换为AIDesignSpec对象进行验证
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 构建返回数据,确保格式与前端期望的一致
|
||||
result = {
|
||||
# 基础参数
|
||||
"temperature": ai_design_spec.temperature,
|
||||
"frequency_penalty": ai_design_spec.frequency_penalty,
|
||||
"presence_penalty": ai_design_spec.presence_penalty,
|
||||
"top_p": ai_design_spec.top_p,
|
||||
"top_k": ai_design_spec.top_k,
|
||||
"max_context": ai_design_spec.max_context,
|
||||
"max_tokens": ai_design_spec.max_tokens,
|
||||
"max_context_unlocked": ai_design_spec.max_context_unlocked,
|
||||
"stream_openai": ai_design_spec.stream,
|
||||
"seed": ai_design_spec.seed,
|
||||
"n": ai_design_spec.n,
|
||||
|
||||
# 兼容旧格式
|
||||
"openai_max_context": ai_design_spec.max_context,
|
||||
"openai_max_tokens": ai_design_spec.max_tokens,
|
||||
|
||||
# 其他参数
|
||||
"top_a": ai_design_spec.top_a,
|
||||
"min_p": ai_design_spec.min_p,
|
||||
"repetition_penalty": ai_design_spec.repetition_penalty,
|
||||
"names_behavior": ai_design_spec.names_behavior,
|
||||
"send_if_empty": ai_design_spec.send_if_empty,
|
||||
"impersonation_prompt": ai_design_spec.impersonation_prompt,
|
||||
"new_chat_prompt": ai_design_spec.new_chat_prompt,
|
||||
"new_group_chat_prompt": ai_design_spec.new_group_chat_prompt,
|
||||
"new_example_chat_prompt": ai_design_spec.new_example_chat_prompt,
|
||||
"continue_nudge_prompt": ai_design_spec.continue_nudge_prompt,
|
||||
"bias_preset_selected": ai_design_spec.bias_preset_selected,
|
||||
"wi_format": ai_design_spec.wi_format,
|
||||
"scenario_format": ai_design_spec.scenario_format,
|
||||
"personality_format": ai_design_spec.personality_format,
|
||||
"group_nudge_prompt": ai_design_spec.group_nudge_prompt,
|
||||
"assistant_prefill": ai_design_spec.assistant_prefill,
|
||||
"assistant_impersonation": ai_design_spec.assistant_impersonation,
|
||||
"use_sysprompt": ai_design_spec.use_sysprompt,
|
||||
"squash_system_messages": ai_design_spec.squash_system_messages,
|
||||
"media_inlining": ai_design_spec.media_inlining,
|
||||
"continue_prefill": ai_design_spec.continue_prefill,
|
||||
"continue_postfix": ai_design_spec.continue_postfix,
|
||||
|
||||
# 处理组件
|
||||
"prompts": []
|
||||
}
|
||||
|
||||
# 处理组件列表
|
||||
if ai_design_spec.prompts:
|
||||
# 获取当前角色的prompt_order(简化后的字符串列表)
|
||||
current_order = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
# 构建组件列表
|
||||
for prompt in ai_design_spec.prompts:
|
||||
# 检查组件是否在order中
|
||||
is_in_order = prompt.identifier in current_order
|
||||
|
||||
# 构建组件对象
|
||||
component = {
|
||||
"identifier": prompt.identifier,
|
||||
"name": prompt.name,
|
||||
"content": prompt.content if hasattr(prompt, 'content') else "",
|
||||
"role": prompt.role if hasattr(prompt, 'role') else (0 if prompt.system_prompt else 1),
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"marker": prompt.marker,
|
||||
"enabled": is_in_order if current_order else True
|
||||
}
|
||||
|
||||
result["prompts"].append(component)
|
||||
|
||||
# 按照order排序组件
|
||||
if current_order:
|
||||
result["prompts"].sort(
|
||||
key=lambda x: current_order.index(x["identifier"]) if x[
|
||||
"identifier"] in current_order else len(
|
||||
current_order))
|
||||
|
||||
# 添加prompt_order
|
||||
result["prompt_order"] = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def create_preset(cls, preset_name: str, preset_data: Dict) -> Dict[str, str]:
|
||||
"""创建新预设"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_path = preset_dir / f"{preset_name}.json"
|
||||
|
||||
if preset_path.exists():
|
||||
raise FileExistsError(f"Preset already exists: {preset_name}")
|
||||
|
||||
try:
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存到文件
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset created successfully", "name": preset_name}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_preset(cls, preset_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新预设配置"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载现有预设
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 更新字段
|
||||
for key, value in update_data.items():
|
||||
preset_data[key] = value
|
||||
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset updated successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_preset(cls, preset_name: str) -> Dict[str, str]:
|
||||
"""删除指定预设"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
preset_path.unlink()
|
||||
return {"message": "Preset deleted successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def list_components(cls, preset_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取预设中的所有组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 获取组件列表
|
||||
components = preset_data.get("prompts", [])
|
||||
return {"components": components}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load components: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def get_component(cls, preset_name: str, component_id: str) -> Dict[str, Any]:
|
||||
"""获取指定组件的详情"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component = next((c for c in components if c.get("identifier") == component_id), None)
|
||||
|
||||
if not component:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
return component
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def add_component_to_preset(cls, preset_name: str, component_data: Dict) -> Dict[str, str]:
|
||||
"""向预设添加新组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 验证组件数据
|
||||
component = PromptComponent(**component_data)
|
||||
|
||||
# 检查组件ID是否已存在
|
||||
components = preset_data.get("prompts", [])
|
||||
if any(c.get("identifier") == component.identifier for c in components):
|
||||
raise ValueError(f"Component identifier already exists: {component.identifier}")
|
||||
|
||||
# 添加组件
|
||||
components.append(component.dict())
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component added successfully", "identifier": component.identifier}
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to add component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_component_in_preset(cls, preset_name: str, component_id: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并更新组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component_index = next((i for i, c in enumerate(components) if c.get("identifier") == component_id), None)
|
||||
|
||||
if component_index is None:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新组件字段
|
||||
for key, value in update_data.items():
|
||||
components[component_index][key] = value
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component updated successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_component_from_preset(cls, preset_name: str, component_id: str) -> Dict[str, str]:
|
||||
"""从预设中删除指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并删除组件
|
||||
components = preset_data.get("prompts", [])
|
||||
original_length = len(components)
|
||||
components = [c for c in components if c.get("identifier") != component_id]
|
||||
|
||||
if len(components) == original_length:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新预设数据
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component deleted successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete component: {str(e)}")
|
||||
|
||||
# ========== 组件管理方法 ==========
|
||||
|
||||
def add_component(self, component: PromptComponent) -> None:
|
||||
"""
|
||||
添加新组件
|
||||
|
||||
参数:
|
||||
component: 要添加的组件
|
||||
|
||||
异常:
|
||||
ValueError: 当组件标识符已存在时抛出
|
||||
"""
|
||||
if any(c.identifier == component.identifier for c in self.prompts):
|
||||
raise ValueError(f"组件标识符 {component.identifier} 已存在")
|
||||
self.prompts.append(component)
|
||||
|
||||
def remove_component(self, identifier: str) -> bool:
|
||||
"""
|
||||
移除指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
original_length = len(self.prompts)
|
||||
self.prompts = [c for c in self.prompts if c.identifier != identifier]
|
||||
|
||||
# 同时从prompt_order中移除
|
||||
self.prompt_order = [id for id in self.prompt_order if id != identifier]
|
||||
|
||||
return len(self.prompts) < original_length
|
||||
|
||||
def get_component(self, identifier: str) -> Optional[PromptComponent]:
|
||||
"""
|
||||
获取指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
Optional[PromptComponent]: 找到的组件,未找到返回None
|
||||
"""
|
||||
for component in self.prompts:
|
||||
if component.identifier == identifier:
|
||||
return component
|
||||
return None
|
||||
|
||||
def update_component(self, identifier: str, **kwargs) -> bool:
|
||||
"""
|
||||
更新指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
返回:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
component = self.get_component(identifier)
|
||||
if component is None:
|
||||
return False
|
||||
|
||||
component.update(**kwargs)
|
||||
return True
|
||||
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有组件
|
||||
|
||||
返回:
|
||||
List[Dict[str, Any]]: 组件字典列表
|
||||
"""
|
||||
return [component.to_dict() for component in self.prompts]
|
||||
|
||||
def reorder_components(self, new_order: List[str]) -> None:
|
||||
"""
|
||||
重新排序组件
|
||||
|
||||
参数:
|
||||
new_order: 新的组件标识符顺序
|
||||
|
||||
异常:
|
||||
ValueError: 当包含不存在的组件ID时抛出
|
||||
"""
|
||||
# 验证所有ID都存在
|
||||
existing_ids = {c.identifier for c in self.prompts}
|
||||
invalid_ids = set(new_order) - existing_ids
|
||||
|
||||
if invalid_ids:
|
||||
raise ValueError(f"包含不存在的组件ID: {invalid_ids}")
|
||||
|
||||
self.prompt_order = new_order
|
||||
|
||||
def get_ordered_components(self) -> List[PromptComponent]:
|
||||
"""
|
||||
获取按prompt_order排序的组件列表
|
||||
|
||||
返回:
|
||||
List[PromptComponent]: 排序后的组件列表
|
||||
"""
|
||||
component_map = {c.identifier: c for c in self.prompts}
|
||||
ordered_components = []
|
||||
|
||||
for identifier in self.prompt_order:
|
||||
if identifier in component_map:
|
||||
ordered_components.append(component_map[identifier])
|
||||
|
||||
# 添加未在prompt_order中的组件
|
||||
ordered_components.extend([
|
||||
c for c in self.prompts
|
||||
if c.identifier not in self.prompt_order
|
||||
])
|
||||
|
||||
return ordered_components
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将设计规范转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 设计规范的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'AIDesignSpec':
|
||||
"""
|
||||
从字典创建设计规范实例
|
||||
|
||||
参数:
|
||||
data: 包含设计规范数据的字典
|
||||
|
||||
返回:
|
||||
AIDesignSpec: 设计规范实例
|
||||
"""
|
||||
# 处理prompts字段
|
||||
if 'prompts' in data:
|
||||
data['prompts'] = [
|
||||
PromptComponent.from_dict(comp) if isinstance(comp, dict) else comp
|
||||
for comp in data['prompts']
|
||||
]
|
||||
|
||||
return cls(**data)
|
||||
@@ -1,438 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from .WorldItem import WorldItem, TriggerStrategy
|
||||
from backend.core.config import settings
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldBook(BaseModel):
|
||||
"""
|
||||
世界书集合模型
|
||||
管理多个世界书条目,支持导入导出 SillyTavern 格式
|
||||
"""
|
||||
# 世界书基本信息
|
||||
name: str = Field(..., description="世界书名称")
|
||||
|
||||
# 条目集合
|
||||
entries: Dict[str, WorldItem.Entry] = Field(
|
||||
default_factory=dict,
|
||||
description="世界书条目字典对象 (Key-Value Map)"
|
||||
)
|
||||
|
||||
@field_validator('entries')
|
||||
@classmethod
|
||||
def validate_entries_unique_uid(cls, v):
|
||||
"""验证条目 UID 的唯一性"""
|
||||
uids = [entry.uid for entry in v.values()]
|
||||
if len(uids) != len(set(uids)):
|
||||
logger.error("验证失败: 条目 UID 必须唯一")
|
||||
raise ValueError("条目 UID 必须唯一")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def get_file_path(cls, name: str) -> str:
|
||||
"""
|
||||
根据世界书名称获取文件路径
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
str: 完整的文件路径
|
||||
"""
|
||||
# 使用配置中的 WORLDBOOKS_PATH
|
||||
return str(settings.WORLDBOOKS_PATH / f"{name}.json")
|
||||
|
||||
@classmethod
|
||||
def exists(cls, name: str) -> bool:
|
||||
"""
|
||||
检查指定名称的世界书文件是否存在
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
bool: 文件是否存在
|
||||
"""
|
||||
file_path = cls.get_file_path(name)
|
||||
return os.path.exists(file_path)
|
||||
|
||||
@classmethod
|
||||
def create_empty(cls, name: str) -> 'WorldBook':
|
||||
"""
|
||||
创建并保存一个空白的世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
Returns:
|
||||
WorldBook: 创建的世界书对象
|
||||
|
||||
Raises:
|
||||
ValueError: 世界书已存在
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
# 检查世界书是否已存在
|
||||
if cls.exists(name):
|
||||
raise ValueError(f"世界书 '{name}' 已存在")
|
||||
|
||||
# 创建空白世界书对象
|
||||
world_book = cls(
|
||||
name=name,
|
||||
)
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
logger.info(f"创建空白世界书: {name}")
|
||||
return world_book
|
||||
|
||||
def add_entry(self, entry: WorldItem.Entry) -> None:
|
||||
"""
|
||||
添加世界书条目
|
||||
|
||||
Args:
|
||||
entry: 世界书条目对象
|
||||
|
||||
Raises:
|
||||
ValueError: 条目 UID 已存在
|
||||
"""
|
||||
entry_key = str(entry.uid)
|
||||
if entry_key in self.entries:
|
||||
error_msg = f"添加条目失败: 条目 UID {entry.uid} 已存在于世界书 {self.name}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
self.entries[entry_key] = entry
|
||||
logger.debug(f"已添加条目: UID={entry.uid}, 世界书={self.name}")
|
||||
|
||||
def remove_entry(self, uid: int) -> bool:
|
||||
"""
|
||||
移除世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
entry_key = str(uid)
|
||||
if entry_key in self.entries:
|
||||
del self.entries[entry_key]
|
||||
logger.info(f"已从世界书 {self.name} 移除条目: UID={uid}")
|
||||
return True
|
||||
logger.warning(f"尝试移除不存在的条目: 世界书 {self.name} 中未找到 UID={uid}")
|
||||
return False
|
||||
|
||||
def get_entry(self, uid: int) -> Optional[WorldItem.Entry]:
|
||||
"""
|
||||
获取指定 UID 的世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Optional[WorldItem.Entry]: 找到的条目,未找到返回 None
|
||||
"""
|
||||
entry_key = str(uid)
|
||||
entry = self.entries.get(entry_key)
|
||||
if entry:
|
||||
logger.debug(f"从世界书 {self.name} 获取条目: UID={uid}")
|
||||
else:
|
||||
logger.debug(f"在世界书 {self.name} 中未找到条目: UID={uid}")
|
||||
return entry
|
||||
|
||||
def update_entry(self, uid: int, **kwargs) -> bool:
|
||||
"""
|
||||
更新世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
Returns:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
entry = self.get_entry(uid)
|
||||
if entry is None:
|
||||
logger.warning(f"更新条目失败: 在世界书 {self.name} 中未找到 UID={uid}")
|
||||
return False
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(entry, key):
|
||||
setattr(entry, key, value)
|
||||
logger.info(f"已更新世界书 {self.name} 中的条目: UID={uid}, 更新字段={list(kwargs.keys())}")
|
||||
return True
|
||||
|
||||
def filter_by_position(self, position: int) -> List[WorldItem.Entry]:
|
||||
"""
|
||||
根据位置筛选条目
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
List[WorldItem.Entry]: 筛选后的条目列表
|
||||
"""
|
||||
filtered_entries = [
|
||||
entry for entry in self.entries.values()
|
||||
if entry.position == position
|
||||
]
|
||||
logger.debug(
|
||||
f"在世界书 {self.name} 中按位置筛选: 值={position}, 结果数量={len(filtered_entries)}")
|
||||
return filtered_entries
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书概要信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 概要信息字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
"trigger_strategies": {
|
||||
strategy.value: sum(1 for e in self.entries.values()
|
||||
if strategy in e.trigger_config.get_enabled_triggers())
|
||||
for strategy in TriggerStrategy
|
||||
}
|
||||
}
|
||||
logger.debug(f"获取世界书 {self.name} 的概要信息")
|
||||
return summary
|
||||
|
||||
def to_summary_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
生成世界书摘要信息,用于列表显示
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含基本信息的字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
}
|
||||
logger.debug(f"生成世界书 {self.name} 的摘要信息")
|
||||
return summary
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
将 WorldBook 转换为字典
|
||||
|
||||
Returns:
|
||||
Dict: 世界书数据字典
|
||||
"""
|
||||
result = {
|
||||
'name': self.name,
|
||||
'entries': {uid: entry.model_dump() for uid, entry in self.entries.items()}
|
||||
}
|
||||
logger.debug(f"将世界书 {self.name} 转换为字典")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def load(cls, name: str) -> 'WorldBook':
|
||||
"""
|
||||
从文件加载世界书(只有 entries 字段的格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
WorldBook: 世界书对象
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 文件不存在
|
||||
ValueError: 格式不符合标准
|
||||
json.JSONDecodeError: JSON 解析错误
|
||||
"""
|
||||
file_path = cls.get_file_path(name)
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"世界书文件未找到: {file_path}"
|
||||
logger.error(error_msg)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# 世界书名称始终使用文件名(不包括后缀名)
|
||||
world_name = name
|
||||
|
||||
# 直接使用 entries 字段
|
||||
entries_dict = raw_data.get("entries", {})
|
||||
if not isinstance(entries_dict, dict):
|
||||
error_msg = "无效的世界书格式:'entries' 字段必须是一个字典。"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# 创建世界书对象
|
||||
world_book = cls(
|
||||
name=world_name,
|
||||
)
|
||||
|
||||
# 转换标准格式的条目
|
||||
for uid, entry_data in entries_dict.items():
|
||||
try:
|
||||
# 先使用 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}")
|
||||
|
||||
logger.info(
|
||||
f"从文件加载世界书: 文件={file_path}, 名称={world_name}, 条目数={len(world_book.entries)}")
|
||||
|
||||
return world_book
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"JSON 解析错误: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"从文件加载世界书失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
保存世界书到文件(只有 entries 字段的格式)
|
||||
如果文件不存在,会创建新文件;如果文件存在,会更新现有文件
|
||||
|
||||
Raises:
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
file_path = self.get_file_path(self.name)
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(Path(file_path).parent, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 转换为标准格式
|
||||
entries_dict = {}
|
||||
for uid, entry in self.entries.items():
|
||||
entries_dict[uid] = entry.to_sillytavern_dict()
|
||||
|
||||
output_data = {
|
||||
"entries": entries_dict
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(
|
||||
f"世界书已保存: 文件={file_path}, 名称={self.name}, 条目数={len(self.entries)}")
|
||||
except Exception as e:
|
||||
error_msg = f"保存世界书失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise IOError(error_msg)
|
||||
|
||||
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
|
||||
"""
|
||||
result = []
|
||||
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)
|
||||
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
|
||||
|
||||
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
|
||||
|
||||
def get_all_entries(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有条目的核心信息(包括已禁用的条目)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含核心信息的条目列表
|
||||
"""
|
||||
result = [entry.to_dict() for entry in self.entries.values()]
|
||||
logger.debug(f"获取世界书 {self.name} 的所有条目: 条目数={len(result)}")
|
||||
return result
|
||||
|
||||
def merge_from_book(self, other_book: 'WorldBook') -> None:
|
||||
"""
|
||||
从另一个世界书合并条目
|
||||
|
||||
Args:
|
||||
other_book: 要合并的世界书对象
|
||||
"""
|
||||
for uid, entry in other_book.entries.items():
|
||||
if uid in self.entries:
|
||||
# 更新现有条目
|
||||
for key, value in entry.dict().items():
|
||||
if key != 'uid': # 不更新 UID
|
||||
setattr(self.entries[uid], key, value)
|
||||
else:
|
||||
# 添加新条目
|
||||
self.add_entry(entry)
|
||||
logger.info(f"合并世界书: 从 {other_book.name} 合并到 {self.name}")
|
||||
|
||||
def to_sillytavern_json(self, file_path: str) -> None:
|
||||
"""
|
||||
导出为 SillyTavern 格式的 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: 导出文件路径
|
||||
"""
|
||||
# 转换为 SillyTavern 格式
|
||||
entries_dict = {}
|
||||
for uid, entry in self.entries.items():
|
||||
entries_dict[uid] = entry.to_sillytavern_dict()
|
||||
|
||||
output_data = {
|
||||
"entries": entries_dict,
|
||||
"name": self.name
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"导出世界书为 SillyTavern 格式: 文件={file_path}")
|
||||
|
||||
|
||||
# --- 使用示例 ---
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 创建空白世界书
|
||||
world_book = WorldBook.create_empty("test_worldbook")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load("test_worldbook")
|
||||
|
||||
# 打印概要
|
||||
summary = world_book.get_summary()
|
||||
print(f"世界书名称: {summary['name']}")
|
||||
print(f"条目数量: {summary['entry_count']}")
|
||||
print(f"触发策略分布: {summary['trigger_strategies']}")
|
||||
|
||||
# 列出所有条目的触发词和内容预览
|
||||
print("\n--- 条目预览 ---")
|
||||
for item in world_book.list_triggers_and_content():
|
||||
triggers = item['triggers'] if item['triggers'] else ['(无关键词 - 常驻)']
|
||||
content_preview = item['content'][:50].replace('\n', ' ') + "..."
|
||||
print(f"[{item['position']}] TRIGGERS: {triggers} -> CONTENT: {content_preview}")
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
print(f"\n✅ 世界书已保存")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
@@ -1,826 +0,0 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
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):
|
||||
"""
|
||||
触发策略枚举
|
||||
"""
|
||||
CONSTANT = "constant" # 永久触发
|
||||
KEYWORD = "keyword" # 关键词匹配触发
|
||||
RAG = "rag" # 向量检索触发
|
||||
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):
|
||||
"""
|
||||
关键词触发配置
|
||||
"""
|
||||
key: List[str] = Field(default_factory=list, description="主关键词数组")
|
||||
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
|
||||
selective: bool = Field(True, description="是否开启选择性匹配")
|
||||
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
|
||||
matchWholeWords: bool = Field(False, description="是否全词匹配")
|
||||
caseSensitive: bool = Field(False, description="是否区分大小写")
|
||||
|
||||
|
||||
class ConditionTriggerConfig(BaseModel):
|
||||
"""
|
||||
条件触发配置
|
||||
"""
|
||||
variable_a: str = Field(..., description="变量a")
|
||||
operator: str = Field(..., description="运算符 (>, <, =, >=, <=, !=)")
|
||||
variable_b: str = Field(..., description="变量b")
|
||||
|
||||
|
||||
class TriggerConfig(BaseModel):
|
||||
"""
|
||||
触发配置
|
||||
使用字典结构,键为触发策略,值为[是否启用, 对应配置]的列表
|
||||
"""
|
||||
triggers: Dict[TriggerStrategy, List[
|
||||
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]] = Field(
|
||||
default_factory=lambda: {
|
||||
TriggerStrategy.CONSTANT: [True, None],
|
||||
TriggerStrategy.KEYWORD: [False, None],
|
||||
TriggerStrategy.RAG: [False, None],
|
||||
TriggerStrategy.CONDITION: [False, None]
|
||||
},
|
||||
description="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
def set_trigger(self, strategy: TriggerStrategy, enabled: bool,
|
||||
config: Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]] = None
|
||||
):
|
||||
"""
|
||||
设置触发策略
|
||||
|
||||
Args:
|
||||
strategy: 触发策略
|
||||
enabled: 是否启用
|
||||
config: 对应的配置对象
|
||||
"""
|
||||
self.triggers[strategy] = [enabled, config]
|
||||
|
||||
def get_trigger(self, strategy: TriggerStrategy) -> List[
|
||||
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]:
|
||||
"""
|
||||
获取触发策略
|
||||
|
||||
Args:
|
||||
strategy: 触发策略
|
||||
|
||||
Returns:
|
||||
List: [是否启用, 对应配置]
|
||||
"""
|
||||
return self.triggers.get(strategy, [False, None])
|
||||
|
||||
def get_enabled_triggers(self) -> List[TriggerStrategy]:
|
||||
"""
|
||||
获取所有启用的触发策略
|
||||
|
||||
Returns:
|
||||
List[TriggerStrategy]: 启用的触发策略列表
|
||||
"""
|
||||
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
|
||||
|
||||
|
||||
class WorldItem(BaseModel):
|
||||
"""
|
||||
世界书条目完整模型
|
||||
包含所有 SillyTavern 世界书条目属性,用于导入导出
|
||||
"""
|
||||
|
||||
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="唯一标识符")
|
||||
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
|
||||
}
|
||||
|
||||
# 测试用例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,443 +0,0 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
import json
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||
name: str = Field(..., description="发送者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息")
|
||||
is_system: bool = Field(False, description="是否为系统消息")
|
||||
send_date: str = Field(
|
||||
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||
description="消息发送时间戳"
|
||||
)
|
||||
floor: int = Field(0, description="对话楼层数")
|
||||
swipes: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="历史版本列表。用户消息:存编辑过的不同版本。AI消息:存重roll生成的不同版本"
|
||||
)
|
||||
swipe_id: int = Field(
|
||||
0,
|
||||
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
|
||||
)
|
||||
mes: str = Field(..., description="消息内容文本")
|
||||
extra: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="额外信息,包含推理内容、API、模型等"
|
||||
)
|
||||
force_avatar: Optional[str] = Field(None, description="强制头像URL")
|
||||
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
|
||||
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
|
||||
|
||||
# 以下属性仅在is_user为False时有值
|
||||
api: Optional[str] = Field(None, description="使用的API提供商")
|
||||
model: Optional[str] = Field(None, description="使用的AI模型")
|
||||
reasoning: Optional[str] = Field(None, description="推理内容")
|
||||
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
|
||||
reasoning_signature: Optional[str] = Field(None, description="推理签名")
|
||||
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
|
||||
bias: Optional[float] = Field(None, description="偏差值")
|
||||
|
||||
|
||||
class ChatMetadata(BaseModel):
|
||||
"""聊天元数据类,包含整个聊天的共享属性"""
|
||||
user_name: str = Field("User", description="用户名称")
|
||||
character_name: str = Field("Assistant", description="角色名称")
|
||||
|
||||
# 完整性校验相关
|
||||
integrity: str = Field("", description="完整性校验值")
|
||||
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||
|
||||
# 笔记相关
|
||||
note_prompt: str = Field("", description="作者笔记提示词")
|
||||
note_interval: int = Field(0, description="笔记插入间隔数")
|
||||
note_position: int = Field(0, description="笔记插入位置")
|
||||
note_depth: int = Field(0, description="笔记插入深度")
|
||||
# 0:System,1:User,2:Assistant
|
||||
note_role: int = Field("", description="笔记使用角色类型")
|
||||
|
||||
# 扩展信息
|
||||
extensions: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="扩展信息,如LittleWhiteBox等"
|
||||
)
|
||||
# 世界信息
|
||||
timedWorldInfo: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="定时世界信息"
|
||||
)
|
||||
# 变量
|
||||
variables: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="变量字典"
|
||||
)
|
||||
# 状态标记
|
||||
tainted: bool = Field(False, description="是否被修改标记")
|
||||
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
|
||||
|
||||
|
||||
class ChatHistory(BaseModel):
|
||||
"""聊天文件类,包含完整的聊天记录"""
|
||||
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
|
||||
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod
|
||||
def get_data_path(cls) -> Path:
|
||||
"""获取数据目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
return settings.DATA_PATH / "chat"
|
||||
except ImportError:
|
||||
return Path("data")
|
||||
|
||||
@classmethod
|
||||
async def list_all_chats(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
data_dir = cls.get_data_path()
|
||||
if not data_dir.exists():
|
||||
return {"chat": []}
|
||||
|
||||
chats = []
|
||||
for role_dir in data_dir.iterdir():
|
||||
if role_dir.is_dir():
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
# 读取第一行获取元数据
|
||||
first_line = f.readline()
|
||||
metadata = json.loads(first_line)
|
||||
chats.append({
|
||||
"role_name": role_dir.name,
|
||||
"chat_name": chat_file.stem,
|
||||
"user_name": metadata.get("user_name", "User"),
|
||||
"character_name": metadata.get("character_name", "Assistant"),
|
||||
"last_modified": metadata.get("last_modified", ""),
|
||||
"message_count": sum(1 for _ in f) # 统计剩余行数(消息数)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的聊天文件
|
||||
return {"chat": chats}
|
||||
|
||||
@classmethod
|
||||
async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||
"""获取指定聊天的完整内容"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {
|
||||
"metadata": chat_history.chat_metadata.dict(),
|
||||
"messages": chat_history.to_chatbox_format()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def create_chat(cls, role_name: str, chat_name: str, metadata: Optional[Dict] = None) -> Dict[str, str]:
|
||||
"""创建新聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
role_dir = base_path / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
chat_path = role_dir / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_path.exists():
|
||||
raise FileExistsError(f"Chat already exists: {chat_path}")
|
||||
|
||||
# 创建聊天历史对象
|
||||
chat_history = cls(
|
||||
chat_metadata=ChatMetadata(**(metadata or {})),
|
||||
messages=[]
|
||||
)
|
||||
|
||||
# 保存到文件
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat created successfully"}
|
||||
|
||||
@classmethod
|
||||
async def update_chat(cls, role_name: str, chat_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新聊天元数据"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 更新元数据
|
||||
if "metadata" in update_data:
|
||||
for key, value in update_data["metadata"].items():
|
||||
if hasattr(chat_history.chat_metadata, key):
|
||||
setattr(chat_history.chat_metadata, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat metadata updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_chat(cls, role_name: str, chat_name: str) -> Dict[str, str]:
|
||||
"""删除指定聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
chat_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_path.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {chat_path}")
|
||||
|
||||
chat_path.unlink()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
async def list_messages(cls, role_name: str, chat_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取聊天的所有消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {"messages": chat_history.to_chatbox_format()}
|
||||
|
||||
@classmethod
|
||||
async def get_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, Any]:
|
||||
"""获取指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
return message.dict()
|
||||
|
||||
@classmethod
|
||||
async def add_message(cls, role_name: str, chat_name: str, message_data: Dict) -> Dict[str, Any]:
|
||||
"""向聊天添加新消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 创建消息对象
|
||||
message = Message(**message_data)
|
||||
|
||||
# 检查楼层是否已存在
|
||||
if any(msg.floor == message.floor for msg in chat_history.messages):
|
||||
raise ValueError(f"Message floor already exists: {message.floor}")
|
||||
|
||||
# 添加消息
|
||||
chat_history.messages.append(message)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message added successfully", "floor": message.floor}
|
||||
|
||||
@classmethod
|
||||
async def update_message(cls, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 更新消息字段
|
||||
for key, value in update_data.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, str]:
|
||||
"""删除指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 查找并删除消息
|
||||
original_length = len(chat_history.messages)
|
||||
chat_history.messages = [msg for msg in chat_history.messages if msg.floor != floor]
|
||||
|
||||
if len(chat_history.messages) == original_length:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为配置中的DATA_PATH/chat
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
base_path = cls.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f):
|
||||
try:
|
||||
line_data = json.loads(line.strip())
|
||||
|
||||
# 第一行是元数据
|
||||
if line_num == 0:
|
||||
metadata = ChatMetadata(**line_data)
|
||||
else:
|
||||
# 后续行是消息
|
||||
messages.append(Message(**line_data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
|
||||
参数:
|
||||
file_path: JSONL文件路径
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f):
|
||||
try:
|
||||
line_data = json.loads(line.strip())
|
||||
|
||||
# 第一行是元数据
|
||||
if line_num == 0:
|
||||
# 处理元数据中的嵌套结构
|
||||
if 'chat_metadata' in line_data:
|
||||
metadata_dict = line_data['chat_metadata']
|
||||
# 合并顶层字段和chat_metadata中的字段
|
||||
metadata_dict.update(line_data)
|
||||
metadata = ChatMetadata(**metadata_dict)
|
||||
else:
|
||||
metadata = ChatMetadata(**line_data)
|
||||
else:
|
||||
# 后续行是消息
|
||||
# 处理extra字段中的内容
|
||||
extra_data = line_data.get('extra', {})
|
||||
|
||||
# 如果是AI消息(is_user=False),将extra中的某些字段提升到顶层
|
||||
if not line_data.get('is_user', True):
|
||||
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
|
||||
'reasoning_signature', 'time_to_first_token', 'bias']
|
||||
for field in ai_fields:
|
||||
if field in extra_data:
|
||||
line_data[field] = extra_data.pop(field)
|
||||
|
||||
# 创建Message实例
|
||||
message = Message(**line_data)
|
||||
# 将剩余的extra数据保存回extra字段
|
||||
message.extra = extra_data
|
||||
messages.append(message)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
def to_chatbox_format(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将聊天历史转换为适合前端chatbox显示的格式
|
||||
|
||||
返回:
|
||||
List[Dict[str, Any]]: 按floor排序的消息字典列表,每个字典包含:
|
||||
{
|
||||
"name": str,
|
||||
"is_user": bool,
|
||||
"floor": int,
|
||||
"mes": str,
|
||||
"swipes": List[str],
|
||||
"swipe_id": int
|
||||
}
|
||||
"""
|
||||
# 创建消息字典列表
|
||||
messages_list = []
|
||||
for msg in self.messages:
|
||||
# 获取当前消息内容:优先从swipes数组中获取,如果不存在则使用mes
|
||||
current_mes = msg.mes
|
||||
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
|
||||
current_mes = msg.swipes[msg.swipe_id]
|
||||
|
||||
msg_dict = {
|
||||
"name": msg.name,
|
||||
"is_user": msg.is_user,
|
||||
"floor": msg.floor,
|
||||
"mes": current_mes,
|
||||
"swipes": msg.swipes,
|
||||
"swipe_id": msg.swipe_id
|
||||
}
|
||||
messages_list.append(msg_dict)
|
||||
|
||||
# 按floor排序
|
||||
messages_list.sort(key=lambda x: x["floor"])
|
||||
|
||||
return messages_list
|
||||
|
||||
def save_to_file(self, role_name: str, chat_name: str, base_path: Path = None) -> None:
|
||||
"""
|
||||
将聊天历史保存到JSONL文件
|
||||
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为data/chat
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
base_path = self.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 确保目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
# 写入元数据
|
||||
f.write(json.dumps(self.chat_metadata.dict(), ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入消息
|
||||
for message in self.messages:
|
||||
f.write(json.dumps(message.dict(), ensure_ascii=False) + '\n')
|
||||
Reference in New Issue
Block a user