添加测试、添加总结、全量、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

@@ -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 分组插入