添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现

This commit is contained in:
2026-05-05 03:01:20 +08:00
parent 2050a30a52
commit adb59da06d
46 changed files with 4484 additions and 882 deletions

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute
from utils.file_utils import get_all_roles_and_chats
from core.config import settings
from pathlib import Path
@@ -17,6 +17,7 @@ router.include_router(charactersRoute.router)
router.include_router(tokenUsageRoute.router)
router.include_router(imageGalleryRoute.router)
router.include_router(regexRoute.router)
router.include_router(chatSummaryRoute.router)
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
router.include_router(chatWsRoute.router)

View File

@@ -0,0 +1,154 @@
"""
聊天总结 API 路由
处理聊天记录的总结请求
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Body
from fastapi.responses import JSONResponse
from services.chat_service import chat_service
from services.chat_summary_service import chat_summary_service
from models.internal import SummaryConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chats", tags=["chat-summary"])
@router.post("/{role_name}/{chat_name}/summarize")
async def summarize_chat_history(
role_name: str,
chat_name: str,
request_data: Dict[str, Any] = Body(...)
):
"""
总结聊天历史记录
Args:
role_name: 角色名称
chat_name: 聊天名称
request_data: {
"startFloor": int, # 总结起始楼层
"endFloor": int, # 总结结束楼层
"summaryConfig": {...}, # 总结配置
"apiConfig": {...} # API配置
}
Returns:
{
"success": bool,
"summaryText": str, # 总结文本
"startFloor": int,
"endFloor": int,
"message": str
}
"""
try:
# 1. 提取请求参数
start_floor = request_data.get("startFloor")
end_floor = request_data.get("endFloor")
summary_config_data = request_data.get("summaryConfig", {})
api_config = request_data.get("apiConfig", {})
if not start_floor or not end_floor:
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
# 2. 加载聊天记录
chat_log = chat_service.get_chat_log(role_name, chat_name)
if not chat_log:
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
messages = chat_log.messages
total_messages = len(messages)
# 3. 验证楼层范围
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
raise HTTPException(
status_code=400,
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
)
# 4. 构建SummaryConfig对象
summary_config = SummaryConfig(**summary_config_data)
logger.info(
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
f"楼层范围: {start_floor}-{end_floor}, "
f"包含用户输入: {summary_config.includeUserInput}"
)
# 5. 调用总结服务
summary_text = await chat_summary_service.summarize_messages(
messages=messages,
start_floor=start_floor,
end_floor=end_floor,
summary_config=summary_config,
api_config=api_config
)
if not summary_text:
raise HTTPException(status_code=500, detail="总结生成失败")
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
# 6. 更新聊天记录(清空原文 + 替换总结)
chat_service.summarize_chat_messages(
role_name=role_name,
chat_name=chat_name,
start_floor=start_floor,
end_floor=end_floor,
summary_text=summary_text
)
return {
"success": True,
"summaryText": summary_text,
"startFloor": start_floor,
"endFloor": end_floor,
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
@router.get("/{role_name}/{chat_name}/summary-status")
async def get_summary_status(role_name: str, chat_name: str):
"""
获取聊天总结状态
Returns:
{
"historyMode": str,
"summaryCounter": int,
"lastSummaryFloor": int,
"summaryConfig": {...}
}
"""
try:
chat_log = chat_service.get_chat_log(role_name, chat_name)
if not chat_log:
raise HTTPException(status_code=404, detail="聊天记录不存在")
header = chat_log.header
return {
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
"summaryCounter": header.summaryCounter or 0,
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -266,9 +266,11 @@ async def _handle_stream_chat(
# ✅ 发送激活的世界书条目信息在LLM调用前
if active_entries:
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
# 将 Pydantic 模型转换为字典
entries_dict = [entry.model_dump() for entry in active_entries]
await websocket.send_json({
"type": "worldbook_active",
"entries": active_entries
"entries": entries_dict
})
# ✅ TODO: RAG检索暂时为空待实现
@@ -310,7 +312,7 @@ async def _handle_stream_chat(
})
# ✅ 第3步调用LLM流式生成
chunk_count = 0
chunk_count = [0] # 使用列表以便在闭包中修改
result = await workflow_service.process_chat_request_stream(
request_data,
on_chunk=lambda chunk: asyncio.create_task(

View File

@@ -40,19 +40,45 @@ async def get_preset(preset_name: str):
raise HTTPException(status_code=500, detail=str(e))
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_preset(preset_name: str, preset_data: dict):
async def create_preset(preset_data: dict):
"""创建新预设"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
preset_name = preset_data.get("name")
if not preset_name:
raise HTTPException(status_code=400, detail="preset name is required")
# 使用 create_preset 方法保存预设
saved_preset = PresetService.create_preset(preset_name, preset_data)
return {"success": True, "preset": saved_preset}
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{preset_name}")
async def update_preset(preset_name: str, update_data: dict):
"""更新预设配置"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
updated_preset = PresetService.update_preset(preset_name, update_data)
return {"success": True, "preset": updated_preset}
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{preset_name}")
async def delete_preset(preset_name: str):
"""删除指定预设"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
success = PresetService.delete_preset(preset_name)
if success:
return {"success": True, "message": f"Preset '{preset_name}' deleted"}
else:
raise HTTPException(status_code=404, detail="Preset not found")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
async def add_preset_component(preset_name: str, component_data: dict):
@@ -68,3 +94,18 @@ async def update_preset_component(preset_name: str, component_id: str, update_da
async def delete_preset_component(preset_name: str, component_id: str):
"""从预设中删除指定组件"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.post("/{preset_name}/reorder")
async def reorder_preset_components(preset_name: str, order_data: dict):
"""重新排序预设组件"""
try:
component_order = order_data.get("component_order", [])
if not component_order:
raise HTTPException(status_code=400, detail="component_order is required")
updated_preset = PresetService.reorder_components(preset_name, component_order)
return {"success": True, "preset": updated_preset}
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -149,6 +149,10 @@ class CharacterCard(BaseModel):
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
# ✅ 动态表格数据SillyTavern 关键字机制扩展)
tableHeaders: Optional[List[str]] = Field(None, description="动态表格表头数组")
tableDefaults: Optional[Dict[str, Any]] = Field(None, description="动态表格默认值对象")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
@@ -158,11 +162,41 @@ class CharacterCard(BaseModel):
# ==================== 聊天记录 (Chat Log) ====================
# 历史记录模式枚举
class HistoryMode(str, Enum):
"""
历史记录处理模式
- FULL: 全量模式,保留所有消息(需经正则处理)
- SUMMARY: 总结模式定期用LLM总结历史消息
- RAG: RAG模式基于向量检索暂不实现
"""
FULL = 'full' # 全量模式
SUMMARY = 'summary' # 总结模式
RAG = 'rag' # RAG模式预留
class SummaryConfig(BaseModel):
"""
总结配置
用于控制历史消息的总结行为
"""
enabled: bool = Field(True, description="是否启用总结")
interval: int = Field(10, ge=2, description="总结间隔(每隔多少条消息总结一次)")
includeUserInput: bool = Field(True, description="总结时是否包含用户输入")
summaryPrompt: str = Field(
"请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
description="总结提示词"
)
maxSummaryLength: int = Field(500, ge=100, description="总结文本的最大长度(字符数)")
class ChatHeader(BaseModel):
"""
项目内部聊天记录头
包含聊天的元数据,如参与角色、创建时间等。
包含聊天的元数据如参与角色、创建时间等。
"""
id: str = Field(..., description="聊天唯一标识符 (UUID)")
displayName: str = Field(..., description="显示名称 (聊天标题)")
@@ -170,6 +204,12 @@ class ChatHeader(BaseModel):
userName: str = Field("User", description="用户角色名")
characterName: str = Field(..., description="AI 角色名称")
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
# ✅ 历史记录模式配置
historyMode: HistoryMode = Field(HistoryMode.FULL, description="历史记录处理模式 (full/summary/rag)")
summaryConfig: Optional[SummaryConfig] = Field(None, description="总结配置 (当 historyMode='summary' 时使用)")
summaryCounter: int = Field(0, ge=0, description="总结计数器(独立于楼层,用于跟踪需要总结的消息数)")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
messageCount: int = Field(0, description="消息数量")
@@ -180,7 +220,7 @@ class ChatMessage(BaseModel):
"""
项目内部聊天消息
单条对话消息,支持多版本 (swipes)、token 统计等功能。
单条对话消息支持多版本 (swipes)、token 统计、历史记录总结等功能。
"""
id: str = Field(..., description="消息唯一标识符 (UUID)")
name: str = Field(..., description="发送者名称")
@@ -193,6 +233,11 @@ class ChatMessage(BaseModel):
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
# ✅ 历史记录总结相关字段
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
class ChatLog(BaseModel):

View File

@@ -0,0 +1,76 @@
"""
聊天总结消息数据模型
用于存储总结后的历史消息记录
"""
from typing import List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
class SummaryMessage(BaseModel):
"""
总结消息
保存总结后的文本和元数据
"""
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
chatId: str = Field(..., description="关联的聊天 ID")
# 总结内容
summaryText: str = Field(..., description="总结后的文本内容")
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
# 元数据
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
messageCount: int = Field(..., description="被总结的消息数量")
includeUserInput: bool = Field(True, description="是否包含用户输入")
# 统计信息
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
# 版本控制
version: int = Field(1, description="总结版本号(用于追溯)")
@classmethod
def create_summary(
cls,
chat_id: str,
summary_text: str,
message_ids: List[str],
include_user_input: bool = True,
version: int = 1
) -> 'SummaryMessage':
"""
创建总结消息的工厂方法
Args:
chat_id: 聊天 ID
summary_text: 总结文本
message_ids: 被总结的消息 ID 列表
include_user_input: 是否包含用户输入
version: 版本号
Returns:
SummaryMessage 实例
"""
import uuid
# 生成消息范围描述
if len(message_ids) > 0:
message_range = f"{len(message_ids)}条消息"
else:
message_range = "无消息"
return cls(
id=str(uuid.uuid4()),
chatId=chat_id,
summaryText=summary_text,
originalMessageIds=message_ids,
messageRange=message_range,
messageCount=len(message_ids),
includeUserInput=include_user_input,
version=version
)

View File

@@ -6,6 +6,7 @@ requests>=2.31.0
# LangChain for LLM integration (让 pip 自动解析兼容版本)
langchain>=0.1.0
langchain-core>=0.1.0
langchain-openai>=0.0.5
langchain-anthropic>=0.1.1
openai>=1.12.0

View File

@@ -100,6 +100,10 @@ class CharacterService:
outputSchema=data.get('outputSchema'),
avatarPath=avatar_path,
alternate_greetings=data.get('alternate_greetings', []),
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
imageGenerationPrompt=data.get('imageGenerationPrompt'),
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
lastChatAt=last_chat_at,

View File

@@ -10,6 +10,8 @@ import logging
from datetime import datetime
import uuid
from core.config import settings
logger = logging.getLogger(__name__)
@@ -453,3 +455,79 @@ class ChatService:
except Exception as e:
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
raise
def summarize_chat_messages(
self,
role_name: str,
chat_name: str,
start_floor: int,
end_floor: int,
summary_text: str
) -> bool:
"""
总结聊天消息:清空原文,将总结放到最后一个楼层
Args:
role_name: 角色名称
chat_name: 聊天名称
start_floor: 总结起始楼层1-based
end_floor: 总结结束楼层1-based
summary_text: 总结文本
Returns:
bool: 是否成功
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 转换为0-based索引
start_idx = start_floor # header在第0行所以L1在第1行
end_idx = end_floor
# 验证范围
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
# 处理消息
for i in range(start_idx, end_idx + 1):
msg_data = json.loads(lines[i])
if i < end_idx:
# 中间楼层:清空内容
msg_data['mes'] = ""
msg_data['is_summarized'] = True
else:
# 最后一个楼层:放入总结文本
msg_data['mes'] = summary_text
msg_data['is_summary'] = True
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
# 写回文件
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
f"楼层 {start_floor}-{end_floor}"
)
return True
except Exception as e:
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
raise
# 全局实例
chat_service = ChatService(Path(settings.DATA_PATH))

View File

@@ -0,0 +1,213 @@
"""
聊天总结服务
负责调用LLM对历史消息进行总结
"""
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from models.internal import ChatMessage, SummaryConfig
from core.config import settings
class ChatSummaryService:
"""聊天总结服务类"""
@staticmethod
async def summarize_messages(
messages: List[ChatMessage],
start_floor: int,
end_floor: int,
summary_config: SummaryConfig,
api_config: Dict[str, str]
) -> str:
"""
对指定范围的消息进行总结
Args:
messages: 完整的消息列表
start_floor: 总结起始楼层1-based
end_floor: 总结结束楼层1-based
summary_config: 总结配置
api_config: API配置 {api_url, api_key, model}
Returns:
总结文本
"""
# 1. 提取需要总结的消息
messages_to_summarize = ChatSummaryService._extract_messages(
messages, start_floor, end_floor, summary_config.includeUserInput
)
if not messages_to_summarize:
return ""
# 2. 构建总结提示词
prompt = ChatSummaryService._build_summary_prompt(
messages_to_summarize, summary_config
)
# 3. 调用LLM生成总结
summary_text = await ChatSummaryService._call_llm_for_summary(
prompt, api_config, summary_config.maxSummaryLength
)
return summary_text
@staticmethod
def _extract_messages(
messages: List[ChatMessage],
start_floor: int,
end_floor: int,
include_user_input: bool
) -> List[ChatMessage]:
"""
提取需要总结的消息
✅ 根据用户需求后端不筛选全部输入给LLM
Args:
messages: 完整消息列表
start_floor: 起始楼层
end_floor: 结束楼层
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
Returns:
需要总结的消息列表(全部消息,不做筛选)
"""
# 转换为0-based索引
start_idx = start_floor - 1
end_idx = end_floor - 1
# 提取范围内的所有消息(不筛选)
return messages[start_idx:end_idx + 1]
@staticmethod
def _build_summary_prompt(
messages: List[ChatMessage],
summary_config: SummaryConfig
) -> str:
"""
构建总结提示词
Args:
messages: 需要总结的消息列表
summary_config: 总结配置
Returns:
完整的提示词
"""
# 使用用户自定义的总结提示词,或默认提示词
base_prompt = summary_config.summaryPrompt or (
"请总结以下对话内容,保留关键信息和上下文。"
"用简洁的语言概括主要事件、人物状态和重要细节。"
)
# 构建对话内容
conversation_text = "\n\n".join([
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
for msg in messages
])
# 组合完整提示词
full_prompt = f"""{base_prompt}
对话内容:
{conversation_text}
总结要求:
1. 保持简洁明了
2. 保留关键情节和设定
3. 不超过{summary_config.maxSummaryLength}
4. 使用客观叙述语气
总结:"""
return full_prompt
@staticmethod
async def _call_llm_for_summary(
prompt: str,
api_config: Dict[str, str],
max_length: int
) -> str:
"""
调用LLM生成总结
Args:
prompt: 总结提示词
api_config: API配置
max_length: 最大长度限制
Returns:
总结文本
"""
try:
# 导入LLM客户端
from utils.llm_client import llm_client
# 构建消息
messages = [
{
"role": "system",
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
},
{
"role": "user",
"content": prompt
}
]
# 调用LLM
response = await llm_client.chat_completion(
messages=messages,
api_url=api_config.get("api_url", ""),
api_key=api_config.get("api_key", ""),
model=api_config.get("model", "gpt-3.5-turbo"),
temperature=0.3, # 总结需要较低的随机性
max_tokens=max_length,
request_timeout=30
)
# 提取总结文本
if isinstance(response, dict):
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
else:
summary = str(response)
# 清理和截断
summary = summary.strip()
if len(summary) > max_length:
summary = summary[:max_length] + "..."
return summary
except Exception as e:
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
# 返回降级总结(基于规则的简单摘要)
return ChatSummaryService._fallback_summary(prompt)
@staticmethod
def _fallback_summary(prompt: str) -> str:
"""
降级总结当LLM调用失败时使用
Args:
prompt: 原始提示词
Returns:
简单的降级总结
"""
# 提取对话中的关键信息
lines = prompt.split("\n")
user_lines = [l for l in lines if l.startswith("用户:")]
ai_lines = [l for l in lines if l.startswith("AI:")]
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
return fallback
# 全局实例
chat_summary_service = ChatSummaryService()

View File

@@ -123,28 +123,7 @@ class ChatWorkflowService:
print(f"[TableCondition] 解析条件失败: {condition}, 错误: {e}")
return False
def _check_keyword_trigger(self, keywords: List[str], text: str) -> bool:
"""
检查关键词触发
Args:
keywords: 关键词列表(支持正则)
text: 要检查的文本
Returns:
bool: 是否匹配
"""
for keyword in keywords:
try:
# 尝试作为正则表达式匹配
if re.search(keyword, text, re.IGNORECASE):
return True
except re.error:
# 如果不是合法正则,则进行普通字符串匹配
if keyword.lower() in text.lower():
return True
return False
async def process_chat_request(
self,
request_data: Dict[str, Any]
@@ -315,15 +294,25 @@ class ChatWorkflowService:
"""
normalized = entry_data.copy()
# ✅ content 必须是字符串(如果是字典或列表,转换为 JSON 字符串)
if 'content' in normalized:
content = normalized['content']
if not isinstance(content, str):
import json
normalized['content'] = json.dumps(content, ensure_ascii=False)
# uid 必须是字符串
if 'uid' in normalized and not isinstance(normalized['uid'], str):
normalized['uid'] = str(normalized['uid'])
# position 必须是字符串或 None
# position 必须是整数或 None
if 'position' in normalized:
pos = normalized['position']
if pos is not None and not isinstance(pos, str):
normalized['position'] = str(pos)
if pos is not None:
try:
normalized['position'] = int(pos)
except (ValueError, TypeError):
normalized['position'] = 0
# group 必须是列表或 None
if 'group' in normalized:
@@ -340,6 +329,68 @@ class ChatWorkflowService:
return normalized
def _get_trigger_type(self, entry_data: Dict[str, Any]) -> str:
"""
获取条目的触发类型
Args:
entry_data: 世界书条目数据
Returns:
str: 触发类型 ('constant', 'keyword', 'condition', 'rag', 'unknown')
"""
trigger_config = entry_data.get("trigger_config", {})
triggers = trigger_config.get("triggers", {})
# 检查常驻触发
constant_trigger = triggers.get("constant", [False, None])
if constant_trigger[0]:
return "constant"
# 检查关键词触发
keyword_trigger = triggers.get("keyword", [False, None])
if keyword_trigger[0]:
return "keyword"
# 检查条件触发
condition_trigger = triggers.get("condition", [False, None])
if condition_trigger[0]:
return "condition"
# 检查RAG触发
rag_trigger = triggers.get("rag", [False, None])
if rag_trigger[0]:
return "rag"
return "unknown"
def _get_position_label(self, position) -> str:
"""
获取位置的中文标签
Args:
position: 位置编号 (0-7)
Returns:
str: 位置标签
"""
position_labels = {
0: '角色定义之后',
1: '角色定义之前',
2: '示例对话之前',
3: '示例对话之后',
4: '系统提示/作者注释',
5: '作为系统消息',
6: '深度插入',
7: '宏替换'
}
try:
pos_int = int(position) if position is not None else 0
return position_labels.get(pos_int, f'未知位置({position})')
except (ValueError, TypeError):
return f'未知位置({position})'
async def _collect_and_activate_worldbooks(
self,
request_data: Dict[str, Any],
@@ -359,7 +410,7 @@ class ChatWorkflowService:
character: 角色卡对象
Returns:
List[WorldInfoEntry]: 激活的条目列表
List[WorldInfoEntry]: 激活的条目列表(已按位置和顺序排序)
"""
try:
from backend.models.internal import WorldInfoEntry
@@ -376,16 +427,31 @@ class ChatWorkflowService:
world_book_data = request_data.get("worldBookData", {})
if not world_book_data:
print(f"[WorldBook] ⚠️ 未收到 worldBookData")
return active_entries
# ✅ 详细日志:检查接收到的世界书数据
global_books = world_book_data.get("globalBooks", [])
character_book_id = world_book_data.get("characterBookId") or (character.worldInfoId if character else None)
print(f"[WorldBook] 📋 接收到的世界书数据:")
print(f" - 全局世界书数量: {len(global_books)}")
for wb in global_books:
print(f" * {wb.get('name')}")
print(f" - 角色绑定世界书: {character_book_id}")
if not global_books and not character_book_id:
print(f"[WorldBook] ⚠️ 既没有全局世界书,也没有角色绑定世界书")
return active_entries
# === 1. 加载全局世界书 ===
global_books = world_book_data.get("globalBooks", [])
for wb in global_books:
try:
wb_name = wb.get("name")
if wb_name:
wb_data = self.worldbook_service._load_worldbook(wb_name)
if wb_data and "entries" in wb_data:
print(f"[WorldBook] 📖 加载全局世界书 '{wb_name}',共 {len(wb_data['entries'])} 个条目")
for entry_data in wb_data["entries"]:
# 检查是否禁用
if entry_data.get("disable", False):
@@ -399,18 +465,21 @@ class ChatWorkflowService:
try:
entry = WorldInfoEntry(**normalized_entry)
active_entries.append(entry)
print(f"[WorldBook] 全局世界书 '{wb_name}' 条目 {entry_data.get('uid')} 已激活")
trigger_type = self._get_trigger_type(normalized_entry)
position_label = self._get_position_label(normalized_entry.get("position", 0))
print(f"[WorldBook] ✅ 全局世界书 '{wb_name}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
except Exception as validation_error:
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
except Exception as e:
print(f"[WorldBook] 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
print(f"[WorldBook] 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
# === 2. 加载角色绑定的世界书 ===
character_book_id = character.worldInfoId
if character_book_id:
print(f"[WorldBook] 📖 开始加载角色绑定世界书: {character_book_id}")
try:
wb_data = self.worldbook_service._load_worldbook(character_book_id)
if wb_data and "entries" in wb_data:
print(f"[WorldBook] ✅ 成功加载世界书 '{character_book_id}',共 {len(wb_data['entries'])} 个条目")
for entry_data in wb_data["entries"]:
if entry_data.get("disable", False):
continue
@@ -422,16 +491,42 @@ class ChatWorkflowService:
try:
entry = WorldInfoEntry(**normalized_entry)
active_entries.append(entry)
print(f"[WorldBook] 角色世界书 '{character_book_id}' 条目 {entry_data.get('uid')} 已激活")
trigger_type = self._get_trigger_type(normalized_entry)
position_label = self._get_position_label(normalized_entry.get("position", 0))
print(f"[WorldBook] ✅ 角色世界书 '{character_book_id}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
except Exception as validation_error:
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
else:
print(f"[WorldBook] ⚠️ 世界书 '{character_book_id}' 不存在或无条目")
except Exception as e:
print(f"[WorldBook] 加载角色世界书失败: {character_book_id}, 错误: {e}")
print(f"[WorldBook] 加载角色世界书失败: {character_book_id}, 错误: {e}")
else:
print(f"[WorldBook] 角色未绑定世界书")
# === 3. 按位置和顺序排序 ===
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
print(f"[WorldBook] 总共激活 {len(active_entries)} 个条目")
# === 4. 统计激活条目的分布情况 ===
position_stats = {}
trigger_stats = {}
for entry in active_entries:
pos = entry.position or 0
position_stats[pos] = position_stats.get(pos, 0) + 1
trigger_type = self._get_trigger_type(entry.model_dump())
trigger_stats[trigger_type] = trigger_stats.get(trigger_type, 0) + 1
print(f"\n[WorldBook] 📊 激活统计:")
print(f" - 总激活条目数: {len(active_entries)}")
print(f" - 按位置分布:")
for pos, count in sorted(position_stats.items()):
pos_label = self._get_position_label(pos)
print(f" * {pos_label} (pos={pos}): {count}")
print(f" - 按触发类型分布:")
for trigger_type, count in sorted(trigger_stats.items()):
print(f" * {trigger_type}: {count}")
print()
return active_entries
def _check_entry_activation(
@@ -456,23 +551,28 @@ class ChatWorkflowService:
trigger_config = entry_data.get("trigger_config", {})
triggers = trigger_config.get("triggers", {})
# 1. 常驻触发 (constant)
# 1. 常驻触发 (constant) - 总是激活
constant_trigger = triggers.get("constant", [False, None])
if constant_trigger[0]: # [true, null]
print(f"[WorldBook-Check] 📌 常驻触发 | UID: {entry_data.get('uid')}")
return True
# 2. 关键词触发 (keyword)
# 2. 关键词触发 (keyword) - 检查用户输入和聊天历史
keyword_trigger = triggers.get("keyword", [False, None])
if keyword_trigger[0] and keyword_trigger[1]:
keywords = keyword_trigger[1].get("keys", [])
if keywords:
keyword_config = keyword_trigger[1]
keys = keyword_config.get("key", []) or keyword_config.get("keys", [])
if keys:
# 检查用户输入
if self._check_keyword_trigger(keywords, user_message):
if self._check_keyword_trigger_with_config(keys, user_message, keyword_config):
print(f"[WorldBook-Check] 🔑 关键词触发(用户输入) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
return True
# 检查聊天历史
for history_text in chat_history:
if self._check_keyword_trigger(keywords, history_text):
for i, history_text in enumerate(chat_history):
if self._check_keyword_trigger_with_config(keys, history_text, keyword_config):
print(f"[WorldBook-Check] 🔑 关键词触发(历史消息#{i}) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
return True
# 3. 条件触发 (condition) - 支持动态表格语法
@@ -481,19 +581,68 @@ class ChatWorkflowService:
conditions = condition_trigger[1].get("conditions", [])
if conditions:
# 所有条件都必须满足 (AND逻辑)
all_conditions_met = True
for condition in conditions:
cond_text = condition.get("text", "")
if cond_text:
# 解析条件,如 (tb.力量 > 10)
if not self._parse_table_condition(cond_text, table_data):
return False
return True
all_conditions_met = False
break
if all_conditions_met:
print(f"[WorldBook-Check] ⚙️ 条件触发 | UID: {entry_data.get('uid')} | 条件数: {len(conditions)}")
return True
# 4. RAG触发 (暂不实现)
# rag_trigger = triggers.get("rag", [False, None])
return False
def _check_keyword_trigger_with_config(self, keywords: List[str], text: str, config: Dict[str, Any] = None) -> bool:
"""
检查文本中是否包含关键词(带配置)
Args:
keywords: 关键词列表
text: 要检查的文本
config: 关键词配置(可选)
Returns:
bool: 是否匹配
"""
if not keywords or not text:
return False
config = config or {}
case_sensitive = config.get("caseSensitive", False)
match_whole_words = config.get("matchWholeWords", False)
# 处理大小写
if not case_sensitive:
text_lower = text.lower()
keywords_lower = [kw.lower() for kw in keywords]
else:
text_lower = text
keywords_lower = keywords
# 检查每个关键词
for keyword in keywords_lower:
if not keyword:
continue
if match_whole_words:
# 全词匹配:使用正则表达式
pattern = r'\b' + re.escape(keyword) + r'\b'
if re.search(pattern, text_lower):
return True
else:
# 部分匹配:直接检查子串
if keyword in text_lower:
return True
return False
async def _load_chat_history(
self,
role_name: str,
@@ -851,7 +1000,7 @@ class ChatWorkflowService:
)
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
for i, entry in enumerate(active_entries, 1):
print(f" {i}. {entry.get('name', 'N/A')} (UID: {entry.get('uid', 'N/A')})")
print(f" {i}. {getattr(entry, 'name', 'N/A')} (UID: {getattr(entry, 'uid', 'N/A')})")
# === 第4步加载聊天历史 ===
chat_history = await self._load_chat_history(current_role, current_chat)
@@ -900,7 +1049,7 @@ class ChatWorkflowService:
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
try:
async for chunk in self.llm_client.stream_chat(
async for chunk_dict in self.llm_client.stream_chat(
messages=prompt_messages,
api_url=api_config.get("api_url", ""),
api_key=api_config.get("api_key", ""),
@@ -909,7 +1058,22 @@ class ChatWorkflowService:
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
):
generated_content += chunk
# ✅ 处理 LLM 返回的字典格式数据
if isinstance(chunk_dict, dict):
if chunk_dict.get("type") == "chunk":
chunk_content = chunk_dict.get("content", "")
elif chunk_dict.get("type") == "usage":
# 跳过 usage 信息,不拼接到内容中
continue
else:
# 兼容旧格式或未知类型,尝试直接获取 content
chunk_content = chunk_dict.get("content", str(chunk_dict))
else:
# 如果直接返回字符串(兼容情况)
chunk_content = str(chunk_dict)
# ✅ 拼接纯文本内容
generated_content += chunk_content
chunk_count += 1
# 第一个 chunk 到达时记录
@@ -922,8 +1086,8 @@ class ChatWorkflowService:
elapsed = time.time() - start_time
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
# 调用回调函数发送 chunk
await on_chunk(chunk)
# 调用回调函数发送纯文本 chunk
await on_chunk(chunk_content)
except Exception as stream_error:
elapsed = time.time() - start_time

View File

@@ -201,3 +201,42 @@ class PresetService:
path.unlink()
return True
@staticmethod
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
"""
重新排序预设组件
Args:
name: 预设名称
component_order: 组件 identifier 列表,按新顺序排列
Returns:
更新后的预设数据
"""
data = PresetService._load_preset(name)
if not data:
raise FileNotFoundError(f"Preset '{name}' not found")
# 支持内部结构的 entries
if "entries" in data and isinstance(data["entries"], list):
# 创建 identifier 到 entry 的映射
entry_map = {entry["identifier"]: entry for entry in data["entries"]}
# 按新顺序重新排列
reordered_entries = []
for identifier in component_order:
if identifier in entry_map:
reordered_entries.append(entry_map[identifier])
# 更新 order 字段
for index, entry in enumerate(reordered_entries):
entry["order"] = index
data["entries"] = reordered_entries
# 更新时间戳
data["updatedAt"] = int(datetime.now().timestamp())
PresetService._save_preset(name, data)
return data

View File

@@ -132,14 +132,14 @@ class PromptAssembler:
# Pos 4: AN Top
for entry in grouped.get(self.POS_AN_TOP, []):
parts.append(entry.content)
parts.append(str(entry.content) if entry.content else "")
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
parts.append(f"[Author's note at depth {depth}]")
# Pos 5: AN Bottom
for entry in grouped.get(self.POS_AN_BOTTOM, []):
parts.append(entry.content)
parts.append(str(entry.content) if entry.content else "")
return "\n".join(parts)
@@ -147,10 +147,15 @@ class PromptAssembler:
"""
在聊天历史的指定深度插入条目 (Pos 6)
返回一个包含 role 和 content 的字典列表,方便后续转换
✅ 过滤已被总结的消息is_summarized=True 且 mes=""
"""
# 先将历史转换为中间格式
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
msg_list = []
for msg in history:
# ✅ 跳过已被总结的空消息
if msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
continue
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
# 按 depth 分组插入