重构路由架构

This commit is contained in:
2026-04-05 12:04:18 +08:00
parent 01ca2bd0f9
commit 7a62139683
13 changed files with 2798 additions and 851 deletions

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator
from typing import Dict, Any
class PromptComponent(BaseModel):
@@ -12,4 +13,48 @@ class PromptComponent(BaseModel):
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
marker: bool = Field(False, description="是否为动态插入点占位符")
@validator('role')
def validate_role(cls, v):
"""验证角色值是否在有效范围内"""
if 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':
"""
从字典创建组件实例
参数:
data: 包含组件数据的字典
返回:
PromptComponent: 组件实例
"""
return cls(**data)

View File

@@ -1,6 +1,6 @@
from pydantic import BaseModel, Field
import PromptComponent
from typing import List, Dict, Any
from pydantic import BaseModel, Field, validator
from typing import List, Dict, Any, Optional
from .PromptComponent import PromptComponent
class AIDesignSpec(BaseModel):
@@ -50,3 +50,167 @@ class AIDesignSpec(BaseModel):
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
# ========== 组件管理方法 ==========
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)

View File

@@ -0,0 +1,391 @@
import json
import os
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field, validator
from .WorldItem import WorldInfoEntry, TriggerStrategy, PositionMode
class WorldBook(BaseModel):
"""
世界书集合模型
管理多个世界书条目,支持导入导出 SillyTavern 格式
"""
# 世界书基本信息
uid: str = Field(..., description="世界书唯一标识符")
name: str = Field(..., description="世界书名称")
description: str = Field("", description="世界书描述")
# 条目集合
entries: List[WorldInfoEntry] = Field(
default_factory=list,
description="世界书条目列表"
)
# 全局配置
enabled: bool = Field(True, description="是否启用")
scan_depth: int = Field(4, description="默认扫描深度")
rag_threshold: float = Field(0.75, description="默认RAG相似度阈值")
@validator('entries')
def validate_entries_unique_uid(cls, v):
uids = [entry.uid for entry in v]
if len(uids) != len(set(uids)):
raise ValueError("条目 UID 必须唯一")
return v
def add_entry(self, entry: WorldInfoEntry) -> None:
"""
添加世界书条目
Args:
entry: 世界书条目对象
"""
if any(e.uid == entry.uid for e in self.entries):
raise ValueError(f"条目 UID {entry.uid} 已存在")
self.entries.append(entry)
def remove_entry(self, uid: str) -> bool:
"""
移除世界书条目
Args:
uid: 条目 UID
Returns:
bool: 是否成功移除
"""
original_length = len(self.entries)
self.entries = [e for e in self.entries if e.uid != uid]
return len(self.entries) < original_length
def get_entry(self, uid: str) -> Optional[WorldInfoEntry]:
"""
获取指定 UID 的世界书条目
Args:
uid: 条目 UID
Returns:
Optional[WorldInfoEntry]: 找到的条目,未找到返回 None
"""
for entry in self.entries:
if entry.uid == uid:
return entry
return None
def update_entry(self, uid: str, **kwargs) -> bool:
"""
更新世界书条目
Args:
uid: 条目 UID
**kwargs: 要更新的字段
Returns:
bool: 是否成功更新
"""
entry = self.get_entry(uid)
if entry is None:
return False
for key, value in kwargs.items():
if hasattr(entry, key):
setattr(entry, key, value)
return True
def filter_by_position(self, position_mode: PositionMode, position_value: int) -> List[WorldInfoEntry]:
"""
根据位置筛选条目
Args:
position_mode: 位置模式
position_value: 位置值
Returns:
List[WorldInfoEntry]: 筛选后的条目列表
"""
return [
entry for entry in self.entries
if entry.position_mode == position_mode and
(entry.position_anchor == position_value or
(position_mode == PositionMode.ABSOLUTE_DEPTH and entry.position_dx == position_value))
]
def get_summary(self) -> Dict[str, Any]:
"""
获取世界书概要信息
Returns:
Dict[str, Any]: 概要信息字典
"""
return {
"uid": self.uid,
"name": self.name,
"description": self.description,
"entry_count": len(self.entries),
"enabled": self.enabled,
"scan_depth": self.scan_depth,
"rag_threshold": self.rag_threshold,
"trigger_strategies": {
strategy.value: sum(1 for e in self.entries if e.trigger_strategy == strategy)
for strategy in TriggerStrategy
}
}
def to_summary_dict(self) -> Dict[str, Any]:
"""
生成世界书摘要信息,用于列表显示
Returns:
Dict[str, Any]: 包含基本信息的字典
"""
return {
"uid": self.uid,
"name": self.name,
"description": self.description,
"entry_count": len(self.entries),
"enabled": self.enabled
}
@classmethod
def from_dict(cls, data: Dict) -> 'WorldBook':
"""
从字典创建 WorldBook 对象,支持多种数据格式
Args:
data: 包含世界书数据的字典
Returns:
WorldBook: 世界书对象
Raises:
ValueError: 数据格式无效
"""
# 处理包含 originalData 的格式
if 'originalData' in data:
original_data = data['originalData']
return cls(
uid=data.get('uid') or original_data.get('name', 'unknown'),
name=original_data.get('name', 'Unknown'),
description=original_data.get('description', ''),
enabled=data.get('enabled', True),
entries=[WorldInfoEntry(**entry) for entry in data.get('entries', {}).values()]
)
# 处理标准格式
return cls(**data)
def to_dict(self) -> Dict:
"""
将 WorldBook 转换为字典,兼容多种格式
Returns:
Dict: 世界书数据字典
"""
return {
'uid': self.uid,
'name': self.name,
'description': self.description,
'enabled': self.enabled,
'entries': {entry.uid: entry.dict() for entry in self.entries}
}
@classmethod
def from_sillytavern_json(cls, file_path: str) -> 'WorldBook':
"""
从 SillyTavern 格式的 JSON 文件加载世界书
Args:
file_path: JSON 文件路径
Returns:
WorldBook: 世界书对象
Raises:
FileNotFoundError: 文件不存在
ValueError: 格式不符合 SillyTavern 标准
json.JSONDecodeError: JSON 解析错误
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"世界书文件未找到: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
# 创建世界书对象
world_name = raw_data.get("name", "")
if not world_name:
# 如果 name 字段不存在或为空,从文件名中提取
file_name = os.path.splitext(os.path.basename(file_path))[0]
world_name = file_name
# 检查是否有 originalData 字段
if "originalData" in raw_data:
# 使用 originalData 格式
original_data = raw_data["originalData"]
world_book = cls(
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
name=world_name,
description=f"从 SillyTavern 导入: {file_path}"
)
# 转换 originalData 中的条目
for entry_data in original_data.get("entries", []):
try:
world_entry = WorldInfoEntry(
uid=str(entry_data.get("id", "")),
key=entry_data.get("keys", []),
keysecondary=entry_data.get("secondary_keys", []),
comment=entry_data.get("comment", ""),
content=entry_data.get("content", ""),
trigger_strategy=TriggerStrategy.KEYWORD,
position_mode=PositionMode.ANCHOR if entry_data.get(
"position") == "after_char" else PositionMode.ABSOLUTE_DEPTH,
position_anchor=0,
position_dx=None,
constant=entry_data.get("constant", False),
selective=entry_data.get("selective", True),
order=entry_data.get("insertion_order", 100),
probability=entry_data.get("extensions", {}).get("probability", 100),
useProbability=entry_data.get("extensions", {}).get("useProbability", False),
group=entry_data.get("extensions", {}).get("group", None),
match_whole_words=entry_data.get("use_regex", False),
use_regex=entry_data.get("use_regex", False),
vectorized=False
)
world_book.add_entry(world_entry)
except Exception as e:
print(f"警告: 跳过条目,解析失败: {e}")
else:
# 使用标准 SillyTavern 格式
entries_dict = raw_data.get("entries", {})
if not isinstance(entries_dict, dict):
raise ValueError("无效的世界书格式:'entries' 字段必须是一个字典。")
if not any(key.isdigit() for key in entries_dict.keys()):
raise ValueError("无效的世界书格式:'entries' 中未找到条目。请确认是 SillyTavern 导出的格式。")
world_book = cls(
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
name=world_name,
description=f"从 SillyTavern 导入: {file_path}"
)
# 转换标准格式的条目
for uid, entry_data in entries_dict.items():
try:
position = entry_data.get("position", 0)
position_mode = PositionMode.ANCHOR if position < 6 else PositionMode.ABSOLUTE_DEPTH
world_entry = WorldInfoEntry(
uid=uid,
key=entry_data.get("key", []),
keysecondary=entry_data.get("keysecondary", []),
comment=entry_data.get("comment", ""),
content=entry_data.get("content", ""),
trigger_strategy=TriggerStrategy.KEYWORD,
position_mode=position_mode,
position_anchor=position if position < 6 else 0,
position_dx=position if position >= 6 else None,
constant=entry_data.get("constant", False),
selective=entry_data.get("selective", True),
order=entry_data.get("order", 100),
probability=entry_data.get("probability", 100),
useProbability=entry_data.get("useProbability", False),
group=entry_data.get("group", None),
match_whole_words=entry_data.get("matchWholeWords", False),
use_regex=entry_data.get("useRegex", False),
vectorized=False
)
world_book.add_entry(world_entry)
except Exception as e:
print(f"警告: 跳过条目 {uid},解析失败: {e}")
return world_book
def to_sillytavern_json(self, file_path: str) -> None:
"""
导出为 SillyTavern 格式的 JSON 文件
Args:
file_path: 要保存的文件路径
"""
entries_dict = {}
for entry in self.entries:
# 映射 WorldInfoEntry 到 SillyTavern 格式
position = entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx
entries_dict[entry.uid] = {
"uid": entry.uid,
"key": entry.key,
"keysecondary": entry.keysecondary,
"comment": entry.comment,
"content": entry.content,
"constant": entry.constant,
"selective": entry.selective,
"position": position,
"order": entry.order,
"probability": entry.probability,
"useProbability": entry.useProbability,
"group": entry.group,
"matchWholeWords": entry.match_whole_words,
"useRegex": entry.use_regex,
"preventRecursion": True,
"excludeRecursion": True
}
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)
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
"""
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
Returns:
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
"""
result = []
for entry in self.entries:
result.append({
"uid": entry.uid,
"comment": entry.comment,
"triggers": entry.key,
"content": entry.content,
"position": entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx,
"constant": entry.constant,
"trigger_strategy": entry.trigger_strategy.value
})
return result
# --- 使用示例 ---
if __name__ == "__main__":
try:
# 从 SillyTavern 格式导入
world_book = WorldBook.from_sillytavern_json('entries.json')
# 打印概要
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}")
# 导出为 SillyTavern 格式
world_book.to_sillytavern_json('exported_world.json')
print("\n✅ 世界书已导出为 exported_world.json")
except Exception as e:
print(f"❌ 错误: {e}")

View File

@@ -0,0 +1,70 @@
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field, validator
class TriggerStrategy(str, Enum):
"""
触发策略枚举
"""
ALL = "all" # 全部触发
KEYWORD = "keyword" # 传统关键词匹配
RAG = "rag" # 向量检索触发
CONDITION = "condition" # 逻辑条件触发
class PositionMode(str, Enum):
"""
位置模式枚举
"""
ANCHOR = "anchor" # 锚点模式 (0-5)
ABSOLUTE_DEPTH = "dx" # 绝对深度模式 (Dx)
class WorldInfoEntry(BaseModel):
"""
世界书条目模型 v2.0
支持 RAG、条件触发及 Dx 绝对位置
兼容 SillyTavern 格式
"""
# --- 核心身份 ---
uid: str
key: List[str] = []
keysecondary: List[str] = []
comment: str = ""
content: str
# --- 策略配置 ---
trigger_strategy: TriggerStrategy = TriggerStrategy.KEYWORD
condition_expr: Optional[str] = None # 条件表达式
rag_threshold: float = 0.75 # RAG 相似度阈值
# --- 位置与扫描 ---
position_mode: PositionMode = PositionMode.ANCHOR
position_anchor: int = 0 # 锚点值 (0-5)
position_dx: Optional[int] = None # 绝对深度值
scan_depth: int = 4 # 向前扫描 N 条消息
# --- 其他配置 ---
constant: bool = False
selective: bool = True
order: int = 100
probability: int = 100
useProbability: bool = False
# 高级过滤
group: Optional[str] = None
match_whole_words: bool = False
use_regex: bool = False
vectorized: bool = False # 是否已生成向量 embedding
# SillyTavern 特定字段
enabled: bool = True
position: str = "after_char" # SillyTavern 位置字符串
insertion_order: int = 100 # SillyTavern 插入顺序
@validator('position_dx')
def validate_dx(cls, v, values):
if values.get('position_mode') == PositionMode.ABSOLUTE_DEPTH and v is None:
raise ValueError("当 position_mode 为 ABSOLUTE_DEPTH 时,必须指定 position_dx")
return v

View File

@@ -6,211 +6,211 @@ 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处理状态数组")
"""消息类代表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="偏差值")
# 以下属性仅在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="角色名称")
"""聊天元数据类,包含整个聊天的共享属性"""
user_name: str = Field("User", description="用户名称")
character_name: str = Field("Assistant", description="角色名称")
# 完整性校验相关
integrity: str = Field("", description="完整性校验值")
chat_id_hash: str = Field("", description="聊天ID哈希值")
# 完整性校验相关
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="笔记插入深度")
# 0System1User2Assistant
note_role: int = Field("", description="笔记使用角色类型")
# 笔记相关
note_prompt: str = Field("", description="作者笔记提示词")
note_interval: int = Field(0, description="笔记插入间隔数")
note_position: int = Field(0, description="笔记插入位置")
note_depth: int = Field(0, description="笔记插入深度")
# 0System1User2Assistant
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")
# 扩展信息
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="消息列表")
"""聊天文件类,包含完整的聊天记录"""
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
messages: List[Message] = Field(default_factory=list, description="消息列表")
class Config:
arbitrary_types_allowed = True
class Config:
arbitrary_types_allowed = True
@classmethod # 类方法装饰器,表示这是一个类方法,可以通过类名直接调用
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
@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
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为配置中的DATA_PATH/chat
返回:
ChatHistory: 加载的聊天历史对象
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 设置默认基础路径 - 如果未提供base_path则从配置中获取默认路径
if base_path is None:
from backend.core.config import settings # 延迟导入配置模块
base_path = settings.DATA_PATH / "chat" # 构建默认路径
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 设置默认基础路径 - 如果未提供base_path则从配置中获取默认路径
if base_path is None:
from backend.core.config import settings # 延迟导入配置模块
base_path = settings.DATA_PATH / "chat" # 构建默认路径
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 初始化结果数据
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())
# 读取文件内容
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
# 第一行是元数据
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
)
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
@classmethod
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
@classmethod
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
参数:
file_path: JSONL文件路径
参数:
file_path: JSONL文件路径
返回:
ChatHistory: 加载的聊天历史对象
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 初始化结果数据
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())
# 读取文件内容
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', {})
# 第一行是元数据
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)
# 如果是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)
# 创建Message实例
message = Message(**line_data)
# 将剩余的extra数据保存回extra字段
message.extra = extra_data
messages.append(message)
except json.JSONDecodeError:
continue
except json.JSONDecodeError:
continue
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
def to_chatbox_format(self) -> List[Dict[str, Any]]:
"""
def to_chatbox_format(self) -> List[Dict[str, Any]]:
"""
将聊天历史转换为适合前端chatbox显示的格式
返回:
@@ -224,26 +224,53 @@ class ChatHistory(BaseModel):
"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]
# 创建消息字典列表
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)
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"])
# 按floor排序
messages_list.sort(key=lambda x: x["floor"])
return messages_list
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 = Path("data")
# 构建文件路径
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')