Files
SillyTavern_replica/backend/services/chat_workflow_service.py

1491 lines
60 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
聊天工作流服务
负责处理完整的LLM对话生成流程
"""
import json
import re
import asyncio
import time
from typing import Dict, Any, List, Optional
from pathlib import Path
from datetime import datetime
try:
from backend.services.character_service import CharacterService
from backend.services.worldbook_service import WorldBookService
from backend.services.prompt_assembler import PromptAssembler, PromptConfig
from backend.utils.llm_client import LLMClient
from backend.services.task_queue_manager import task_queue_manager, TaskType
from backend.core.config import settings
except ImportError:
from services.character_service import CharacterService
from services.worldbook_service import WorldBookService
from services.prompt_assembler import PromptAssembler, PromptConfig
from utils.llm_client import LLMClient
from services.task_queue_manager import task_queue_manager, TaskType
from core.config import settings
class ChatWorkflowService:
"""
聊天工作流服务
负责:
1. 接收前端发送的完整数据
2. 加载角色卡和世界书
3. 激活世界书条目
4. 组装提示词
5. 调用LLM生成回复
6. 返回生成的内容
"""
def __init__(self):
self.character_service = CharacterService()
self.worldbook_service = WorldBookService()
self.prompt_assembler = PromptAssembler()
self.llm_client = LLMClient()
# ==================== 动态表格条件判断 ====================
def _parse_table_condition(self, condition: str, table_data: Dict[str, Any]) -> bool:
"""
解析动态表格条件
支持的语法:
- (tb.力量 > 10) # 数值比较
- (tb.态度 包括 "爱情") # 字符串包含
- (tb.状态 = "战斗") # 字符串相等
Args:
condition: 条件字符串,如 "(tb.力量 > 10)"
table_data: 动态表格数据 {"力量": 15, "态度": "友好", ...}
Returns:
bool: 条件是否满足
"""
try:
# 去除括号
condition = condition.strip().strip('()')
# 匹配 tb.字段名 操作符 值
# 模式1: tb.字段 > 数值
match_num = re.match(r'tb\.(\S+)\s*(>|<|>=|<=|=|!=)\s*(\d+(?:\.\d+)?)', condition)
if match_num:
field_name = match_num.group(1)
operator = match_num.group(2)
target_value = float(match_num.group(3))
actual_value = table_data.get(field_name)
if actual_value is None:
return False
# 转换为数值比较
try:
actual_value = float(actual_value)
except (ValueError, TypeError):
return False
# 执行比较
if operator == '>':
return actual_value > target_value
elif operator == '<':
return actual_value < target_value
elif operator == '>=':
return actual_value >= target_value
elif operator == '<=':
return actual_value <= target_value
elif operator == '=':
return actual_value == target_value
elif operator == '!=':
return actual_value != target_value
# 模式2: tb.字段 包括 "字符串"
match_str_include = re.match(r'tb\.(\S+)\s*包括\s*["\'](.+?)["\']', condition)
if match_str_include:
field_name = match_str_include.group(1)
target_str = match_str_include.group(2)
actual_value = table_data.get(field_name, '')
return target_str in str(actual_value)
# 模式3: tb.字段 = "字符串"
match_str_eq = re.match(r'tb\.(\S+)\s*=\s*["\'](.+?)["\']', condition)
if match_str_eq:
field_name = match_str_eq.group(1)
target_str = match_str_eq.group(2)
actual_value = str(table_data.get(field_name, ''))
return actual_value == target_str
return False
except Exception as e:
print(f"[TableCondition] 解析条件失败: {condition}, 错误: {e}")
return False
async def process_chat_request(
self,
request_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
处理聊天请求的核心工作流
Args:
request_data: 前端发送的完整数据
Returns:
{
"success": bool,
"content": str, # 生成的回复内容
"error": str | None
}
"""
try:
# === 第1步解析请求数据 ===
current_role = request_data.get("currentRole")
current_chat = request_data.get("currentChat")
user_message = request_data.get("mes", "")
if not current_role or not user_message:
return {
"success": False,
"content": "",
"error": "缺少必要的参数currentRole 或 mes"
}
print(f"[ChatWorkflow] 开始处理请求: role={current_role}, chat={current_chat}")
# ✅ 获取预设名称(用于加载预设绑定的正则规则)
preset_config = request_data.get("presetConfig", {})
preset_name = preset_config.get("selectedPreset")
# ✅ 第1.5步:应用用户输入的正则规则
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
processed_user_message = regex_service.apply_rules_by_placement(
text=user_message,
placement=RegexPlacement.USER_INPUT.value,
character_name=current_role,
preset_name=preset_name,
message_depth=0,
is_for_llm=True, # ✅ 用户输入会发送给LLM
is_markdown_rendered=False
)
if processed_user_message != user_message:
print(f"[Regex] ✅ 已应用用户输入正则规则")
user_message = processed_user_message
# === 第2步加载角色卡 ===
character_data = request_data.get("characterData")
if not character_data:
# 如果没有提供角色卡数据,从后端加载
character = self.character_service.get_character_by_name(current_role)
if not character:
return {
"success": False,
"content": "",
"error": f"角色 '{current_role}' 不存在"
}
else:
# 使用前端提供的角色卡数据(可能包含用户修改)
try:
from backend.models.internal import CharacterCard
except ImportError:
from models.internal import CharacterCard
character = CharacterCard(**character_data)
print(f"[ChatWorkflow] 已加载角色卡: {character.name}")
# === 第3步收集并激活世界书条目 ===
active_entries = await self._collect_and_activate_worldbooks(
request_data,
character
)
print(f"[ChatWorkflow] 激活了 {len(active_entries)} 个世界书条目")
# === 第4步加载聊天历史 ===
chat_history = await self._load_chat_history(current_role, current_chat)
print(f"[ChatWorkflow] 加载了 {len(chat_history)} 条历史消息")
# === 第5步组装提示词 ===
prompt_messages = self._assemble_prompt(
character,
chat_history,
user_message,
active_entries,
request_data
)
print(f"[ChatWorkflow] 组装了 {len(prompt_messages)} 条提示消息")
# === 第6步调用LLM生成回复 ===
api_config = request_data.get("apiConfig", {})
preset_config = request_data.get("presetConfig", {})
stream_output = request_data.get("stream", False)
result = await self._generate_response(
prompt_messages,
api_config,
preset_config,
stream_output
)
generated_content = result["content"]
token_usage = result.get("usage", {})
duration = result.get("duration")
print(f"[ChatWorkflow] 生成完成,内容长度: {len(generated_content)}")
print(f"[ChatWorkflow] Token 使用: {token_usage}")
# ✅ 第6.5步:应用 AI 输出的正则规则
processed_ai_output = regex_service.apply_rules_by_placement(
text=generated_content,
placement=RegexPlacement.AI_OUTPUT.value,
character_name=current_role,
preset_name=preset_name,
message_depth=0,
is_for_llm=False, # ✅ AI输出是显示给用户的
is_markdown_rendered=False
)
if processed_ai_output != generated_content:
print(f"[Regex] ✅ 已应用 AI 输出正则规则")
generated_content = processed_ai_output
# === 第7步记录 Token 使用 ===
chat_id = f"{current_role}/{request_data.get('currentChat', '')}"
floor = request_data.get("floor", 0)
try:
try:
from backend.services.token_usage_service import token_usage_service
from backend.models.internal import TokenUsageStatus
except ImportError:
from services.token_usage_service import token_usage_service
from models.internal import TokenUsageStatus
await token_usage_service.record_usage(
chat_id=chat_id,
role_name=current_role,
chat_name=request_data.get('currentChat', ''),
prompt_tokens=token_usage.get("prompt_tokens", 0),
completion_tokens=token_usage.get("completion_tokens", 0),
total_tokens=token_usage.get("total_tokens", 0),
status=TokenUsageStatus.COMPLETED,
floor=floor + 1, # AI 回复的楼层
duration=duration,
model=api_config.get("model"),
api_provider="openai", # TODO: 从 API URL 检测提供商
api_url=api_config.get("api_url") # ✅ 记录 API URL
)
except Exception as e:
print(f"[ChatWorkflow] 记录 Token 使用失败: {e}")
# === 第8步启动异步并行任务 ===
# 创建任务ID
image_task_id = None
table_task_id = None
options = request_data.get("options", {})
if options.get("imageWorkflow", False):
import uuid
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
if options.get("dynamicTable", False):
import uuid
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id)
return {
"success": True,
"content": generated_content,
"error": None,
"activeEntries": active_entries,
"taskIds": {
"imageWorkflow": image_task_id,
"dynamicTable": table_task_id
}
}
except Exception as e:
print(f"[ChatWorkflow] 错误: {str(e)}")
import traceback
traceback.print_exc()
return {
"success": False,
"content": "",
"error": f"工作流执行失败: {str(e)}"
}
def _normalize_worldbook_entry(self, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
标准化世界书条目数据,修复类型不匹配问题
Args:
entry_data: 原始条目数据
Returns:
标准化后的条目数据
"""
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
if 'position' in normalized:
pos = normalized['position']
if pos is not None:
try:
normalized['position'] = int(pos)
except (ValueError, TypeError):
normalized['position'] = 0
# group 必须是列表或 None
if 'group' in normalized:
grp = normalized['group']
if grp is not None and not isinstance(grp, list):
# 如果是空字符串转为None否则尝试解析
if grp == '' or grp is None:
normalized['group'] = None
elif isinstance(grp, str):
# 尝试将逗号分隔的字符串转为列表
normalized['group'] = [g.strip() for g in grp.split(',') if g.strip()]
else:
normalized['group'] = None
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],
character
) -> List:
"""
收集并激活世界书条目
支持的触发类型:
1. permanent/constant - 永久激活
2. keyword - 关键词匹配(用户输入、历史消息)
3. condition - 条件判断(动态表格、用户输入等)
4. rag - RAG检索暂不实现
Args:
request_data: 前端发送的请求数据
character: 角色卡对象
Returns:
List[WorldInfoEntry]: 激活的条目列表(已按位置和顺序排序)
"""
try:
from backend.models.internal import WorldInfoEntry
except ImportError:
from models.internal import WorldInfoEntry
active_entries = []
# 提取必要的数据
user_message = request_data.get("mes", "")
dynamic_table_data = request_data.get("dynamicTableData") or {}
table_data = dynamic_table_data.get("currentValues", {}) if dynamic_table_data else {}
chat_history = request_data.get("chatHistory", []) # 前端传来的历史消息文本
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. 加载全局世界书 ===
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):
continue
# ✅ 标准化数据类型
normalized_entry = self._normalize_worldbook_entry(entry_data)
# 检查激活条件
if self._check_entry_activation(normalized_entry, user_message, table_data, chat_history):
try:
entry = WorldInfoEntry(**normalized_entry)
active_entries.append(entry)
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}")
# === 2. 加载角色绑定的世界书 ===
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
# ✅ 标准化数据类型
normalized_entry = self._normalize_worldbook_entry(entry_data)
if self._check_entry_activation(normalized_entry, user_message, table_data, chat_history):
try:
entry = WorldInfoEntry(**normalized_entry)
active_entries.append(entry)
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}")
else:
print(f"[WorldBook] 角色未绑定世界书")
# === 3. 按位置和顺序排序 ===
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
# === 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(
self,
entry_data: Dict[str, Any],
user_message: str,
table_data: Dict[str, Any],
chat_history: List[str]
) -> bool:
"""
检查世界书条目的激活条件
Args:
entry_data: 世界书条目数据
user_message: 用户当前输入
table_data: 动态表格数据
chat_history: 聊天历史文本列表
Returns:
bool: 是否应该激活
"""
trigger_config = entry_data.get("trigger_config", {})
triggers = trigger_config.get("triggers", {})
# 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) - 检查用户输入和聊天历史
keyword_trigger = triggers.get("keyword", [False, None])
if keyword_trigger[0] and keyword_trigger[1]:
keyword_config = keyword_trigger[1]
keys = keyword_config.get("key", []) or keyword_config.get("keys", [])
if keys:
# 检查用户输入
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 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) - 支持动态表格语法
condition_trigger = triggers.get("condition", [False, None])
if condition_trigger[0] and condition_trigger[1]:
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):
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,
chat_name: str
) -> List:
"""
加载聊天历史
TODO: 从chat_service加载完整的聊天历史
"""
try:
from backend.models.internal import ChatMessage
except ImportError:
from models.internal import ChatMessage
try:
# 读取聊天文件
chat_dir = settings.CHATS_PATH / role_name
chat_file = chat_dir / f"{chat_name}.jsonl"
if not chat_file.exists():
return []
messages = []
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 跳过第一行header
for line in lines[1:]:
if line.strip():
try:
msg_data = json.loads(line)
# ✅ 确保必需字段存在
if 'id' not in msg_data:
msg_data['id'] = f"msg_{len(messages)}_{msg_data.get('name', 'unknown')}"
if 'sendDate' not in msg_data:
from datetime import datetime
msg_data['sendDate'] = datetime.now().isoformat()
if 'chatId' not in msg_data:
msg_data['chatId'] = f"{role_name}/{chat_name}"
msg = ChatMessage(**msg_data)
messages.append(msg)
except Exception as msg_error:
print(f"[ChatWorkflow] ⚠️ 解析消息失败: {msg_error}")
continue
return messages
except Exception as e:
print(f"[ChatWorkflow] 加载聊天历史失败: {e}")
return []
def _assemble_prompt(
self,
character,
chat_history: List,
user_message: str,
active_entries: List,
request_data: Dict[str, Any]
) -> List:
"""
组装提示词
根据预设组件promptComponents动态组装LLM消息列表
"""
# 从预设配置中获取prompt components
preset_config = request_data.get("presetConfig", {})
prompt_components = preset_config.get("promptComponents", [])
# ✅ 检查是否启用调试模式
debug_prompt = request_data.get("debugPrompt", False)
if debug_prompt:
print(f"\n{'='*80}")
print(f"[Prompt Debug] 🧩 预设组件配置")
print(f"{'='*80}")
print(f"[Prompt Debug] 组件数量: {len(prompt_components)}")
for i, comp in enumerate(prompt_components, 1):
print(f" {i}. {comp.get('name', 'Unknown')} (enabled={comp.get('enabled', True)}, type={comp.get('type', 'N/A')})")
print(f"{'='*80}\n")
# ✅ 如果有预设组件,使用预设组件组装
if prompt_components and len(prompt_components) > 0:
return self._assemble_prompt_from_components(
character,
chat_history,
user_message,
active_entries,
prompt_components,
debug_prompt
)
else:
# ✅ 否则使用默认的 SillyTavern 规范组装
print(f"[PromptAssembler] ⚠️ 未检测到预设组件使用默认SillyTavern规范")
# 创建配置
config = PromptConfig(
an_position="after_history", # 默认在历史之后
an_depth=4,
post_history_instructions=None
)
# 组装提示词
messages = self.prompt_assembler.assemble(
character=character,
chat_history=chat_history,
user_input=user_message,
active_entries=active_entries,
config=config
)
return messages
def _assemble_prompt_from_components(
self,
character,
chat_history: List,
user_message: str,
active_entries: List,
prompt_components: List[Dict],
debug_prompt: bool = False
) -> List:
"""
根据预设组件组装提示词
Args:
character: 角色卡数据
chat_history: 聊天历史
user_message: 用户输入
active_entries: 激活的世界书条目
prompt_components: 预设组件列表
debug_prompt: 是否输出调试信息
Returns:
List[BaseMessage]: 组装好的消息列表
"""
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
messages = []
# ✅ 按顺序处理每个启用的组件
enabled_components = [comp for comp in prompt_components if comp.get('enabled', True)]
if debug_prompt:
print(f"\n[Prompt Debug] 🔄 开始组装 {len(enabled_components)} 个启用的组件")
for i, component in enumerate(enabled_components, 1):
comp_name = component.get('name', 'Unknown')
comp_type = component.get('type', 'text') # text, system, user, assistant
comp_content = component.get('content', '')
if debug_prompt:
print(f"\n[Prompt Debug] --- 组件 {i}: {comp_name} ---")
print(f" 类型: {comp_type}")
print(f" 内容长度: {len(comp_content)} 字符")
# ✅ 替换模板变量
processed_content = self._process_component_content(
comp_content,
character,
chat_history,
user_message,
active_entries,
comp_type # ✅ 传递组件类型
)
if debug_prompt:
print(f" 处理后长度: {len(processed_content)} 字符")
if len(processed_content) < 500:
print(f" 完整内容:\n{processed_content}")
else:
print(f" 预览:\n{processed_content[:200]}...")
# ✅ 跳过空内容
if not processed_content or processed_content.strip() == "":
if debug_prompt:
print(f" ⚠️ 内容为空,跳过")
continue
# ✅ 根据组件类型创建对应的消息
if comp_type == 'system':
messages.append(SystemMessage(content=processed_content))
elif comp_type == 'user':
messages.append(HumanMessage(content=processed_content))
elif comp_type == 'assistant':
messages.append(AIMessage(content=processed_content))
else:
# 默认为 system
messages.append(SystemMessage(content=processed_content))
# ✅ 最后添加用户输入(如果还没有添加)
# 检查最后一个消息是否是用户输入
if messages and isinstance(messages[-1], HumanMessage):
# 已经包含用户输入,不需要再添加
pass
else:
# 添加用户输入
messages.append(HumanMessage(content=user_message))
if debug_prompt:
print(f"\n[Prompt Debug] ✅ 组装完成,总消息数: {len(messages)}")
print(f"{'='*80}\n")
return messages
def _process_component_content(
self,
content: str,
character,
chat_history: List,
user_message: str,
active_entries: List,
component_type: str = 'system' # ✅ 新增组件类型system/user/assistant
) -> str:
"""
处理组件内容,替换模板变量
支持的变量:
- {{char}}: 角色名称
- {{user}}: 用户名称(暂时用 User
- {{description}}: 角色描述
- {{personality}}: 角色性格
- {{scenario}}: 场景
- {{mes_example}}: 对话示例
- {{first_mes}}: 第一条消息
- {{history}}: 聊天历史
- {{world_info}}: 世界书信息
Args:
content: 原始内容
character: 角色卡数据
chat_history: 聊天历史
user_message: 用户输入
active_entries: 激活的世界书条目
component_type: 组件类型system/user/assistant
Returns:
str: 处理后的内容
"""
if not content:
return ""
# 替换角色相关变量
content = content.replace('{{char}}', getattr(character, 'name', 'Character'))
content = content.replace('{{user}}', 'User') # TODO: 从配置获取用户名
content = content.replace('{{description}}', getattr(character, 'description', ''))
content = content.replace('{{personality}}', getattr(character, 'personality', ''))
content = content.replace('{{scenario}}', getattr(character, 'scenario', ''))
content = content.replace('{{mes_example}}', getattr(character, 'mes_example', ''))
content = content.replace('{{first_mes}}', getattr(character, 'first_mes', ''))
# 替换聊天历史
if '{{history}}' in content:
history_text = self._format_chat_history(chat_history)
content = content.replace('{{history}}', history_text)
# 替换世界书信息
if '{{world_info}}' in content and active_entries:
world_info_text = self._format_world_info(active_entries)
content = content.replace('{{world_info}}', world_info_text)
# ✅ 应用 System Prompt 正则规则placement=0
if component_type == 'system':
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
# 获取预设名称
# TODO: 这里应该从请求数据中获取 preset_name暂时传 None
content = regex_service.apply_rules_by_placement(
text=content,
placement=RegexPlacement.SYSTEM_PROMPT.value,
character_name=getattr(character, 'name', None),
preset_name=None, # TODO: 从请求中获取
message_depth=0,
is_for_llm=True, # ✅ 系统提示词会发送给 LLM
is_markdown_rendered=False
)
return content
def _format_chat_history(self, chat_history: List) -> str:
"""
格式化聊天历史为文本
Args:
chat_history: 聊天历史列表
Returns:
str: 格式化后的历史文本
"""
lines = []
for msg in chat_history:
# ✅ 过滤已被总结的空消息
if hasattr(msg, 'is_summarized') and msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
continue
name = getattr(msg, 'name', 'User' if msg.is_user else 'Assistant')
mes = getattr(msg, 'mes', '')
lines.append(f"{name}: {mes}")
return "\n".join(lines)
def _format_world_info(self, active_entries: List) -> str:
"""
格式化世界书信息为文本
Args:
active_entries: 激活的世界书条目列表
Returns:
str: 格式化后的世界书文本
"""
lines = []
for entry in active_entries:
name = getattr(entry, 'name', 'Unknown')
content = getattr(entry, 'content', '')
lines.append(f"[{name}]\n{content}")
return "\n\n".join(lines)
async def _generate_response(
self,
prompt_messages: List,
api_config: Dict[str, str],
preset_config: Dict[str, Any],
stream: bool = False
) -> Dict[str, Any]:
"""
调用LLM生成回复
Args:
prompt_messages: 组装好的提示词消息列表
api_config: API配置 {api_url, api_key, model}
preset_config: 预设配置 {parameters, ...}
stream: 是否启用流式输出
Returns:
{
"content": str, # 生成的文本内容
"usage": dict, # Token 使用信息
"duration": float # 请求耗时
}
"""
try:
# 提取API配置
api_url = api_config.get("api_url", "")
api_key = api_config.get("api_key", "")
model = api_config.get("model", "gpt-3.5-turbo")
# 提取生成参数
parameters = preset_config.get("parameters", {})
temperature = parameters.get("temperature", 1.0)
max_tokens = parameters.get("max_tokens", 500)
# 调用LLM客户端
response = await self.llm_client.chat_completion(
messages=prompt_messages,
api_url=api_url,
api_key=api_key,
model=model,
temperature=temperature,
max_tokens=max_tokens,
request_timeout=parameters.get("request_timeout", 60), # ✅ 传递超时时间
stream=stream
)
# 提取回复内容
if isinstance(response, dict):
content = response.get("choices", [{}])[0].get("message", {}).get("content", "")
usage = response.get("usage", {})
duration = response.get("duration", 0)
else:
content = str(response)
usage = {}
duration = 0
return {
"content": content,
"usage": usage,
"duration": duration
}
except Exception as e:
print(f"[ChatWorkflow] LLM调用失败: {e}")
raise
# ==================== 异步并行任务 ====================
async def _start_parallel_tasks(
self,
request_data: Dict[str, Any],
ai_response: str,
image_task_id: str = None,
table_task_id: str = None
):
"""
启动异步并行任务
包括:
1. LLM维护动态表格如果启用
2. 生图工作流(如果启用)
这些任务与主工作流互不干扰,在后台异步执行
Args:
request_data: 前端发送的请求数据
ai_response: AI生成的回复内容
image_task_id: 生图任务ID
table_task_id: 动态表格任务ID
"""
options = request_data.get("options", {})
# 创建任务列表
tasks = []
# 1. 动态表格维护任务
if options.get("dynamicTable", False) and table_task_id:
task = asyncio.create_task(
self._update_dynamic_table(request_data, ai_response, table_task_id)
)
tasks.append(task)
print(f"[ParallelTask] 启动动态表格维护任务: {table_task_id}")
# 2. 生图工作流任务
if options.get("imageWorkflow", False) and image_task_id:
task = asyncio.create_task(
self._generate_image(request_data, ai_response, image_task_id)
)
tasks.append(task)
print(f"[ParallelTask] 启动生图工作流任务: {image_task_id}")
# 等待所有任务完成(但不阻塞主流程)
if tasks:
try:
await asyncio.gather(*tasks, return_exceptions=True)
print("[ParallelTask] 所有并行任务完成")
except Exception as e:
print(f"[ParallelTask] 并行任务执行失败: {e}")
async def _update_dynamic_table(
self,
request_data: Dict[str, Any],
ai_response: str,
task_id: str
):
"""
使用LLM维护动态表格
TODO: 实现具体逻辑
- 分析AI回复提取表格字段的变化
- 根据时间戳解决冲突(前端优先)
- 更新聊天文件中的表格数据
Args:
request_data: 请求数据
ai_response: AI回复
task_id: 任务ID
"""
try:
await task_queue_manager.start_task(task_id)
print(f"[DynamicTable] 开始维护动态表格: {task_id}")
# 获取当前表格数据和时间戳
table_data = request_data.get("dynamicTableData", {})
client_timestamp = request_data.get("timestamp", datetime.now().timestamp())
# TODO: 调用LLM分析AI回复提取表格变化
# TODO: 检查时间戳,如果前端有更新则跳过
# TODO: 更新表格数据到聊天文件
await task_queue_manager.complete_task(task_id, {
"modifiedFields": [], # TODO: 实际修改的字段
"prompt": "TODO: 使用的提示词"
})
print(f"[DynamicTable] 动态表格维护完成: {task_id}")
except asyncio.CancelledError:
await task_queue_manager.cancel_task(task_id)
print(f"[DynamicTable] 任务被取消: {task_id}")
except Exception as e:
await task_queue_manager.fail_task(task_id, str(e))
print(f"[DynamicTable] 维护失败: {e}")
async def _generate_image(
self,
request_data: Dict[str, Any],
ai_response: str,
task_id: str
):
"""
生图工作流
TODO: 实现具体逻辑
- 从AI回复中提取场景描述
- 调用ComfyUI API生成图片
- 保存图片并返回路径
Args:
request_data: 请求数据
ai_response: AI回复
task_id: 任务ID
"""
try:
await task_queue_manager.start_task(task_id)
print(f"[ImageWorkflow] 开始生图: {task_id}")
# TODO: 提取场景描述
# TODO: 调用ComfyUI
# TODO: 保存图片
await task_queue_manager.complete_task(task_id, {
"imagePath": "TODO: 生成的图片路径",
"prompt": "TODO: 使用的提示词"
})
print(f"[ImageWorkflow] 生图完成: {task_id}")
except asyncio.CancelledError:
await task_queue_manager.cancel_task(task_id)
print(f"[ImageWorkflow] 任务被取消: {task_id}")
except Exception as e:
await task_queue_manager.fail_task(task_id, str(e))
print(f"[ImageWorkflow] 生图失败: {e}")
async def process_chat_request_stream(
self,
request_data: Dict[str, Any],
on_chunk
) -> Dict[str, Any]:
"""
处理流式聊天请求
Args:
request_data: 前端发送的完整数据
on_chunk: 回调函数,每次收到 chunk 时调用
Returns:
{
"success": bool,
"content": str, # 完整的生成内容
"error": str | None,
"activeEntries": List,
"taskIds": Dict
}
"""
try:
# === 第1步解析请求数据 ===
current_role = request_data.get("currentRole")
current_chat = request_data.get("currentChat")
user_message = request_data.get("mes", "")
if not current_role or not user_message:
return {
"success": False,
"content": "",
"error": "缺少必要的参数currentRole 或 mes"
}
print(f"\n{'#'*80}")
print(f"[ChatWorkflow-Stream] 🚀 开始处理请求")
print(f" - Role: {current_role}")
print(f" - Chat: {current_chat}")
print(f" - Message Length: {len(user_message)}")
print(f"{'#'*80}\n")
# ✅ 获取预设名称(用于加载预设绑定的正则规则)
preset_config = request_data.get("presetConfig", {})
preset_name = preset_config.get("selectedPreset")
# ✅ 第1.5步:应用用户输入的正则规则
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
processed_user_message = regex_service.apply_rules_by_placement(
text=user_message,
placement=RegexPlacement.USER_INPUT.value,
character_name=current_role,
preset_name=preset_name,
message_depth=0,
is_for_llm=True, # ✅ 用户输入会发送给LLM
is_markdown_rendered=False
)
if processed_user_message != user_message:
print(f"[Regex] ✅ 已应用用户输入正则规则")
user_message = processed_user_message
# === 第2步加载角色卡 ===
character_data = request_data.get("characterData")
if not character_data:
character = self.character_service.get_character_by_name(current_role)
if not character:
return {
"success": False,
"content": "",
"error": f"角色 '{current_role}' 不存在"
}
else:
try:
from backend.models.internal import CharacterCard
except ImportError:
from models.internal import CharacterCard
character = CharacterCard(**character_data)
print(f"[ChatWorkflow-Stream] ✅ 已加载角色卡: {character.name}")
# === 第3步收集并激活世界书条目 ===
active_entries = await self._collect_and_activate_worldbooks(
request_data,
character
)
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
for i, entry in enumerate(active_entries, 1):
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)
print(f"[ChatWorkflow-Stream] 💬 聊天历史加载结果: {len(chat_history)} 条消息")
# === 第5步组装提示词 ===
prompt_messages = self._assemble_prompt(
character,
chat_history,
user_message,
active_entries,
request_data
)
print(f"[ChatWorkflow-Stream] 📝 提示词组装结果: {len(prompt_messages)} 条消息")
for i, msg in enumerate(prompt_messages, 1):
role = getattr(msg, 'role', 'unknown')
content_preview = str(getattr(msg, 'content', ''))[:50]
print(f" {i}. [{role}] {content_preview}...")
# === 第6步流式调用LLM生成回复 ===
api_config = request_data.get("apiConfig", {})
preset_config = request_data.get("presetConfig", {})
print(f"\n[ChatWorkflow-Stream] 🤖 开始流式调用 LLM")
print(f" - Model: {api_config.get('model', 'N/A')}")
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}...")
print(f" - API Key: {'已设置' if api_config.get('api_key') else '⚠️ 未设置'}")
print(f" - Temperature: {preset_config.get('parameters', {}).get('temperature', 1.0)}")
print(f" - Max Tokens: {preset_config.get('parameters', {}).get('max_tokens', 30000)}")
print(f" - Request Timeout: {preset_config.get('parameters', {}).get('request_timeout', 60)}s")
print(f"{'~'*80}")
# ✅ 验证 API Key
if not api_config.get('api_key'):
print(f"[ChatWorkflow-Stream] ❌ 错误: API Key 为空!")
print(f" - 请检查配置文件中是否保存了 API Key")
print(f" - Profile ID: {request_data.get('currentProfile', {}).get('id', 'N/A')}")
raise Exception("API Key 未配置,请先在 API 配置页面保存密钥")
generated_content = ""
token_usage = {}
duration = 0
chunk_count = 0
start_time = time.time()
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
try:
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", ""),
model=api_config.get("model", ""),
temperature=preset_config.get("parameters", {}).get("temperature", 1.0),
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
):
# ✅ 处理 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 到达时记录
if chunk_count == 1:
first_chunk_time = time.time()
print(f"[ChatWorkflow-Stream] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
# 每20个chunk记录一次进度
if chunk_count % 20 == 0:
elapsed = time.time() - start_time
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
# ✅ 调用回调函数发送纯文本 chunk
await on_chunk(chunk_content)
except Exception as stream_error:
elapsed = time.time() - start_time
print(f"[ChatWorkflow-Stream] ❌ 流式调用失败 (耗时: {elapsed:.2f}s)")
print(f" - 错误类型: {type(stream_error).__name__}")
print(f" - 错误信息: {str(stream_error)}")
raise
elapsed = time.time() - start_time
print(f"{'~'*80}")
print(f"[ChatWorkflow-Stream] ✅ LLM 流式调用完成")
print(f" - 总 Chunks: {chunk_count}")
print(f" - 内容长度: {len(generated_content)}")
print(f" - 总耗时: {elapsed:.2f}s")
print(f" - 平均速度: {len(generated_content)/elapsed if elapsed > 0 else 0:.0f} chars/s")
print(f"{'#'*80}\n")
# ✅ 第6.5步:应用 AI 输出的正则规则
processed_ai_output = regex_service.apply_rules_by_placement(
text=generated_content,
placement=RegexPlacement.AI_OUTPUT.value,
character_name=current_role,
preset_name=preset_name,
message_depth=0,
is_for_llm=False, # ✅ AI输出是显示给用户的
is_markdown_rendered=False
)
if processed_ai_output != generated_content:
print(f"[Regex] ✅ 已应用 AI 输出正则规则")
generated_content = processed_ai_output
# === 第7步记录 Token 使用(估算) ===
chat_id = f"{current_role}/{request_data.get('currentChat', '')}"
floor = request_data.get("floor", 0)
try:
try:
from backend.services.token_usage_service import token_usage_service
from backend.models.internal import TokenUsageStatus
except ImportError:
from services.token_usage_service import token_usage_service
from models.internal import TokenUsageStatus
# 估算 token 数量
prompt_tokens = len(str(prompt_messages)) // 4
completion_tokens = len(generated_content) // 4
await token_usage_service.record_usage(
chat_id=chat_id,
role_name=current_role,
chat_name=request_data.get('currentChat', ''),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
status=TokenUsageStatus.COMPLETED,
floor=floor + 1,
duration=duration,
model=api_config.get("model"),
api_provider="openai",
api_url=api_config.get("api_url") # ✅ 记录 API URL
)
except Exception as e:
print(f"[ChatWorkflow-Stream] 记录 Token 使用失败: {e}")
# === 第8步启动异步并行任务 ===
image_task_id = None
table_task_id = None
options = request_data.get("options", {})
if options.get("imageWorkflow", False):
import uuid
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
if options.get("dynamicTable", False):
import uuid
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id)
return {
"success": True,
"content": generated_content,
"error": None,
"activeEntries": active_entries,
"taskIds": {
"imageWorkflow": image_task_id,
"dynamicTable": table_task_id
}
}
except Exception as e:
print(f"[ChatWorkflow-Stream] 错误: {str(e)}")
import traceback
traceback.print_exc()
return {
"success": False,
"content": "",
"error": f"工作流执行失败: {str(e)}"
}