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