""" 聊天工作流服务 负责处理完整的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)}" }