添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 分组插入
|
||||
|
||||
Reference in New Issue
Block a user