添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute
|
||||
from utils.file_utils import get_all_roles_and_chats
|
||||
from core.config import settings
|
||||
from pathlib import Path
|
||||
@@ -17,6 +17,7 @@ router.include_router(charactersRoute.router)
|
||||
router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
router.include_router(chatSummaryRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
router.include_router(chatWsRoute.router)
|
||||
|
||||
154
backend/api/routes/chatSummaryRoute.py
Normal file
154
backend/api/routes/chatSummaryRoute.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
聊天总结 API 路由
|
||||
|
||||
处理聊天记录的总结请求
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from services.chat_service import chat_service
|
||||
from services.chat_summary_service import chat_summary_service
|
||||
from models.internal import SummaryConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chats", tags=["chat-summary"])
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/summarize")
|
||||
async def summarize_chat_history(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any] = Body(...)
|
||||
):
|
||||
"""
|
||||
总结聊天历史记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
request_data: {
|
||||
"startFloor": int, # 总结起始楼层
|
||||
"endFloor": int, # 总结结束楼层
|
||||
"summaryConfig": {...}, # 总结配置
|
||||
"apiConfig": {...} # API配置
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"summaryText": str, # 总结文本
|
||||
"startFloor": int,
|
||||
"endFloor": int,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1. 提取请求参数
|
||||
start_floor = request_data.get("startFloor")
|
||||
end_floor = request_data.get("endFloor")
|
||||
summary_config_data = request_data.get("summaryConfig", {})
|
||||
api_config = request_data.get("apiConfig", {})
|
||||
|
||||
if not start_floor or not end_floor:
|
||||
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
|
||||
|
||||
# 2. 加载聊天记录
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
|
||||
|
||||
messages = chat_log.messages
|
||||
total_messages = len(messages)
|
||||
|
||||
# 3. 验证楼层范围
|
||||
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
|
||||
)
|
||||
|
||||
# 4. 构建SummaryConfig对象
|
||||
summary_config = SummaryConfig(**summary_config_data)
|
||||
|
||||
logger.info(
|
||||
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
|
||||
f"楼层范围: {start_floor}-{end_floor}, "
|
||||
f"包含用户输入: {summary_config.includeUserInput}"
|
||||
)
|
||||
|
||||
# 5. 调用总结服务
|
||||
summary_text = await chat_summary_service.summarize_messages(
|
||||
messages=messages,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_config=summary_config,
|
||||
api_config=api_config
|
||||
)
|
||||
|
||||
if not summary_text:
|
||||
raise HTTPException(status_code=500, detail="总结生成失败")
|
||||
|
||||
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
|
||||
|
||||
# 6. 更新聊天记录(清空原文 + 替换总结)
|
||||
chat_service.summarize_chat_messages(
|
||||
role_name=role_name,
|
||||
chat_name=chat_name,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_text=summary_text
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summaryText": summary_text,
|
||||
"startFloor": start_floor,
|
||||
"endFloor": end_floor,
|
||||
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/summary-status")
|
||||
async def get_summary_status(role_name: str, chat_name: str):
|
||||
"""
|
||||
获取聊天总结状态
|
||||
|
||||
Returns:
|
||||
{
|
||||
"historyMode": str,
|
||||
"summaryCounter": int,
|
||||
"lastSummaryFloor": int,
|
||||
"summaryConfig": {...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail="聊天记录不存在")
|
||||
|
||||
header = chat_log.header
|
||||
|
||||
return {
|
||||
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
|
||||
"summaryCounter": header.summaryCounter or 0,
|
||||
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
|
||||
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -266,9 +266,11 @@ async def _handle_stream_chat(
|
||||
# ✅ 发送激活的世界书条目信息(在LLM调用前)
|
||||
if active_entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
# 将 Pydantic 模型转换为字典
|
||||
entries_dict = [entry.model_dump() for entry in active_entries]
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
"entries": entries_dict
|
||||
})
|
||||
|
||||
# ✅ TODO: RAG检索(暂时为空,待实现)
|
||||
@@ -310,7 +312,7 @@ async def _handle_stream_chat(
|
||||
})
|
||||
|
||||
# ✅ 第3步:调用LLM流式生成
|
||||
chunk_count = 0
|
||||
chunk_count = [0] # 使用列表以便在闭包中修改
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
on_chunk=lambda chunk: asyncio.create_task(
|
||||
|
||||
@@ -40,19 +40,45 @@ async def get_preset(preset_name: str):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_preset(preset_name: str, preset_data: dict):
|
||||
async def create_preset(preset_data: dict):
|
||||
"""创建新预设"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
try:
|
||||
preset_name = preset_data.get("name")
|
||||
if not preset_name:
|
||||
raise HTTPException(status_code=400, detail="preset name is required")
|
||||
|
||||
# 使用 create_preset 方法保存预设
|
||||
saved_preset = PresetService.create_preset(preset_name, preset_data)
|
||||
return {"success": True, "preset": saved_preset}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/{preset_name}")
|
||||
async def update_preset(preset_name: str, update_data: dict):
|
||||
"""更新预设配置"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
try:
|
||||
updated_preset = PresetService.update_preset(preset_name, update_data)
|
||||
return {"success": True, "preset": updated_preset}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
try:
|
||||
success = PresetService.delete_preset(preset_name)
|
||||
if success:
|
||||
return {"success": True, "message": f"Preset '{preset_name}' deleted"}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||
async def add_preset_component(preset_name: str, component_data: dict):
|
||||
@@ -68,3 +94,18 @@ async def update_preset_component(preset_name: str, component_id: str, update_da
|
||||
async def delete_preset_component(preset_name: str, component_id: str):
|
||||
"""从预设中删除指定组件"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.post("/{preset_name}/reorder")
|
||||
async def reorder_preset_components(preset_name: str, order_data: dict):
|
||||
"""重新排序预设组件"""
|
||||
try:
|
||||
component_order = order_data.get("component_order", [])
|
||||
if not component_order:
|
||||
raise HTTPException(status_code=400, detail="component_order is required")
|
||||
|
||||
updated_preset = PresetService.reorder_components(preset_name, component_order)
|
||||
return {"success": True, "preset": updated_preset}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -149,6 +149,10 @@ class CharacterCard(BaseModel):
|
||||
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
|
||||
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
|
||||
|
||||
# ✅ 动态表格数据(SillyTavern 关键字机制扩展)
|
||||
tableHeaders: Optional[List[str]] = Field(None, description="动态表格表头数组")
|
||||
tableDefaults: Optional[Dict[str, Any]] = Field(None, description="动态表格默认值对象")
|
||||
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||
@@ -158,11 +162,41 @@ class CharacterCard(BaseModel):
|
||||
|
||||
# ==================== 聊天记录 (Chat Log) ====================
|
||||
|
||||
# 历史记录模式枚举
|
||||
class HistoryMode(str, Enum):
|
||||
"""
|
||||
历史记录处理模式
|
||||
|
||||
- FULL: 全量模式,保留所有消息(需经正则处理)
|
||||
- SUMMARY: 总结模式,定期用LLM总结历史消息
|
||||
- RAG: RAG模式,基于向量检索(暂不实现)
|
||||
"""
|
||||
FULL = 'full' # 全量模式
|
||||
SUMMARY = 'summary' # 总结模式
|
||||
RAG = 'rag' # RAG模式(预留)
|
||||
|
||||
|
||||
class SummaryConfig(BaseModel):
|
||||
"""
|
||||
总结配置
|
||||
|
||||
用于控制历史消息的总结行为
|
||||
"""
|
||||
enabled: bool = Field(True, description="是否启用总结")
|
||||
interval: int = Field(10, ge=2, description="总结间隔(每隔多少条消息总结一次)")
|
||||
includeUserInput: bool = Field(True, description="总结时是否包含用户输入")
|
||||
summaryPrompt: str = Field(
|
||||
"请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||
description="总结提示词"
|
||||
)
|
||||
maxSummaryLength: int = Field(500, ge=100, description="总结文本的最大长度(字符数)")
|
||||
|
||||
|
||||
class ChatHeader(BaseModel):
|
||||
"""
|
||||
项目内部聊天记录头
|
||||
|
||||
包含聊天的元数据,如参与角色、创建时间等。
|
||||
包含聊天的元数据,如参与角色、创建时间等。
|
||||
"""
|
||||
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||
@@ -170,6 +204,12 @@ class ChatHeader(BaseModel):
|
||||
userName: str = Field("User", description="用户角色名")
|
||||
characterName: str = Field(..., description="AI 角色名称")
|
||||
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
|
||||
|
||||
# ✅ 历史记录模式配置
|
||||
historyMode: HistoryMode = Field(HistoryMode.FULL, description="历史记录处理模式 (full/summary/rag)")
|
||||
summaryConfig: Optional[SummaryConfig] = Field(None, description="总结配置 (当 historyMode='summary' 时使用)")
|
||||
summaryCounter: int = Field(0, ge=0, description="总结计数器(独立于楼层,用于跟踪需要总结的消息数)")
|
||||
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
messageCount: int = Field(0, description="消息数量")
|
||||
@@ -180,7 +220,7 @@ class ChatMessage(BaseModel):
|
||||
"""
|
||||
项目内部聊天消息
|
||||
|
||||
单条对话消息,支持多版本 (swipes)、token 统计等功能。
|
||||
单条对话消息,支持多版本 (swipes)、token 统计、历史记录总结等功能。
|
||||
"""
|
||||
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="发送者名称")
|
||||
@@ -193,6 +233,11 @@ class ChatMessage(BaseModel):
|
||||
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
||||
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
||||
|
||||
# ✅ 历史记录总结相关字段
|
||||
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
|
||||
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
|
||||
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
|
||||
|
||||
|
||||
class ChatLog(BaseModel):
|
||||
|
||||
76
backend/models/summary_message.py
Normal file
76
backend/models/summary_message.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
聊天总结消息数据模型
|
||||
|
||||
用于存储总结后的历史消息记录
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SummaryMessage(BaseModel):
|
||||
"""
|
||||
总结消息
|
||||
|
||||
保存总结后的文本和元数据
|
||||
"""
|
||||
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="关联的聊天 ID")
|
||||
|
||||
# 总结内容
|
||||
summaryText: str = Field(..., description="总结后的文本内容")
|
||||
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
|
||||
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
|
||||
|
||||
# 元数据
|
||||
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
|
||||
messageCount: int = Field(..., description="被总结的消息数量")
|
||||
includeUserInput: bool = Field(True, description="是否包含用户输入")
|
||||
|
||||
# 统计信息
|
||||
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
|
||||
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
|
||||
|
||||
# 版本控制
|
||||
version: int = Field(1, description="总结版本号(用于追溯)")
|
||||
|
||||
@classmethod
|
||||
def create_summary(
|
||||
cls,
|
||||
chat_id: str,
|
||||
summary_text: str,
|
||||
message_ids: List[str],
|
||||
include_user_input: bool = True,
|
||||
version: int = 1
|
||||
) -> 'SummaryMessage':
|
||||
"""
|
||||
创建总结消息的工厂方法
|
||||
|
||||
Args:
|
||||
chat_id: 聊天 ID
|
||||
summary_text: 总结文本
|
||||
message_ids: 被总结的消息 ID 列表
|
||||
include_user_input: 是否包含用户输入
|
||||
version: 版本号
|
||||
|
||||
Returns:
|
||||
SummaryMessage 实例
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# 生成消息范围描述
|
||||
if len(message_ids) > 0:
|
||||
message_range = f"{len(message_ids)}条消息"
|
||||
else:
|
||||
message_range = "无消息"
|
||||
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
summaryText=summary_text,
|
||||
originalMessageIds=message_ids,
|
||||
messageRange=message_range,
|
||||
messageCount=len(message_ids),
|
||||
includeUserInput=include_user_input,
|
||||
version=version
|
||||
)
|
||||
@@ -6,6 +6,7 @@ requests>=2.31.0
|
||||
|
||||
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
||||
langchain>=0.1.0
|
||||
langchain-core>=0.1.0
|
||||
langchain-openai>=0.0.5
|
||||
langchain-anthropic>=0.1.1
|
||||
openai>=1.12.0
|
||||
|
||||
@@ -100,6 +100,10 @@ class CharacterService:
|
||||
outputSchema=data.get('outputSchema'),
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
|
||||
imageGenerationPrompt=data.get('imageGenerationPrompt'),
|
||||
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
|
||||
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
|
||||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||
lastChatAt=last_chat_at,
|
||||
|
||||
@@ -10,6 +10,8 @@ import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -453,3 +455,79 @@ class ChatService:
|
||||
except Exception as e:
|
||||
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def summarize_chat_messages(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_text: str
|
||||
) -> bool:
|
||||
"""
|
||||
总结聊天消息:清空原文,将总结放到最后一个楼层
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_text: 总结文本
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor # header在第0行,所以L1在第1行
|
||||
end_idx = end_floor
|
||||
|
||||
# 验证范围
|
||||
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
|
||||
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
|
||||
|
||||
# 处理消息
|
||||
for i in range(start_idx, end_idx + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
|
||||
if i < end_idx:
|
||||
# 中间楼层:清空内容
|
||||
msg_data['mes'] = ""
|
||||
msg_data['is_summarized'] = True
|
||||
else:
|
||||
# 最后一个楼层:放入总结文本
|
||||
msg_data['mes'] = summary_text
|
||||
msg_data['is_summary'] = True
|
||||
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
|
||||
|
||||
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
|
||||
f"楼层 {start_floor}-{end_floor}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_service = ChatService(Path(settings.DATA_PATH))
|
||||
|
||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
聊天总结服务
|
||||
|
||||
负责调用LLM对历史消息进行总结
|
||||
"""
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from models.internal import ChatMessage, SummaryConfig
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ChatSummaryService:
|
||||
"""聊天总结服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def summarize_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_config: SummaryConfig,
|
||||
api_config: Dict[str, str]
|
||||
) -> str:
|
||||
"""
|
||||
对指定范围的消息进行总结
|
||||
|
||||
Args:
|
||||
messages: 完整的消息列表
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_config: 总结配置
|
||||
api_config: API配置 {api_url, api_key, model}
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
# 1. 提取需要总结的消息
|
||||
messages_to_summarize = ChatSummaryService._extract_messages(
|
||||
messages, start_floor, end_floor, summary_config.includeUserInput
|
||||
)
|
||||
|
||||
if not messages_to_summarize:
|
||||
return ""
|
||||
|
||||
# 2. 构建总结提示词
|
||||
prompt = ChatSummaryService._build_summary_prompt(
|
||||
messages_to_summarize, summary_config
|
||||
)
|
||||
|
||||
# 3. 调用LLM生成总结
|
||||
summary_text = await ChatSummaryService._call_llm_for_summary(
|
||||
prompt, api_config, summary_config.maxSummaryLength
|
||||
)
|
||||
|
||||
return summary_text
|
||||
|
||||
@staticmethod
|
||||
def _extract_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
include_user_input: bool
|
||||
) -> List[ChatMessage]:
|
||||
"""
|
||||
提取需要总结的消息
|
||||
|
||||
✅ 根据用户需求:后端不筛选,全部输入给LLM
|
||||
|
||||
Args:
|
||||
messages: 完整消息列表
|
||||
start_floor: 起始楼层
|
||||
end_floor: 结束楼层
|
||||
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
|
||||
|
||||
Returns:
|
||||
需要总结的消息列表(全部消息,不做筛选)
|
||||
"""
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor - 1
|
||||
end_idx = end_floor - 1
|
||||
|
||||
# 提取范围内的所有消息(不筛选)
|
||||
return messages[start_idx:end_idx + 1]
|
||||
|
||||
@staticmethod
|
||||
def _build_summary_prompt(
|
||||
messages: List[ChatMessage],
|
||||
summary_config: SummaryConfig
|
||||
) -> str:
|
||||
"""
|
||||
构建总结提示词
|
||||
|
||||
Args:
|
||||
messages: 需要总结的消息列表
|
||||
summary_config: 总结配置
|
||||
|
||||
Returns:
|
||||
完整的提示词
|
||||
"""
|
||||
# 使用用户自定义的总结提示词,或默认提示词
|
||||
base_prompt = summary_config.summaryPrompt or (
|
||||
"请总结以下对话内容,保留关键信息和上下文。"
|
||||
"用简洁的语言概括主要事件、人物状态和重要细节。"
|
||||
)
|
||||
|
||||
# 构建对话内容
|
||||
conversation_text = "\n\n".join([
|
||||
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# 组合完整提示词
|
||||
full_prompt = f"""{base_prompt}
|
||||
|
||||
对话内容:
|
||||
{conversation_text}
|
||||
|
||||
总结要求:
|
||||
1. 保持简洁明了
|
||||
2. 保留关键情节和设定
|
||||
3. 不超过{summary_config.maxSummaryLength}字
|
||||
4. 使用客观叙述语气
|
||||
|
||||
总结:"""
|
||||
|
||||
return full_prompt
|
||||
|
||||
@staticmethod
|
||||
async def _call_llm_for_summary(
|
||||
prompt: str,
|
||||
api_config: Dict[str, str],
|
||||
max_length: int
|
||||
) -> str:
|
||||
"""
|
||||
调用LLM生成总结
|
||||
|
||||
Args:
|
||||
prompt: 总结提示词
|
||||
api_config: API配置
|
||||
max_length: 最大长度限制
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
try:
|
||||
# 导入LLM客户端
|
||||
from utils.llm_client import llm_client
|
||||
|
||||
# 构建消息
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
# 调用LLM
|
||||
response = await llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", "gpt-3.5-turbo"),
|
||||
temperature=0.3, # 总结需要较低的随机性
|
||||
max_tokens=max_length,
|
||||
request_timeout=30
|
||||
)
|
||||
|
||||
# 提取总结文本
|
||||
if isinstance(response, dict):
|
||||
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
else:
|
||||
summary = str(response)
|
||||
|
||||
# 清理和截断
|
||||
summary = summary.strip()
|
||||
if len(summary) > max_length:
|
||||
summary = summary[:max_length] + "..."
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
|
||||
# 返回降级总结(基于规则的简单摘要)
|
||||
return ChatSummaryService._fallback_summary(prompt)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_summary(prompt: str) -> str:
|
||||
"""
|
||||
降级总结(当LLM调用失败时使用)
|
||||
|
||||
Args:
|
||||
prompt: 原始提示词
|
||||
|
||||
Returns:
|
||||
简单的降级总结
|
||||
"""
|
||||
# 提取对话中的关键信息
|
||||
lines = prompt.split("\n")
|
||||
user_lines = [l for l in lines if l.startswith("用户:")]
|
||||
ai_lines = [l for l in lines if l.startswith("AI:")]
|
||||
|
||||
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_summary_service = ChatSummaryService()
|
||||
@@ -123,28 +123,7 @@ class ChatWorkflowService:
|
||||
print(f"[TableCondition] 解析条件失败: {condition}, 错误: {e}")
|
||||
return False
|
||||
|
||||
def _check_keyword_trigger(self, keywords: List[str], text: str) -> bool:
|
||||
"""
|
||||
检查关键词触发
|
||||
|
||||
Args:
|
||||
keywords: 关键词列表(支持正则)
|
||||
text: 要检查的文本
|
||||
|
||||
Returns:
|
||||
bool: 是否匹配
|
||||
"""
|
||||
for keyword in keywords:
|
||||
try:
|
||||
# 尝试作为正则表达式匹配
|
||||
if re.search(keyword, text, re.IGNORECASE):
|
||||
return True
|
||||
except re.error:
|
||||
# 如果不是合法正则,则进行普通字符串匹配
|
||||
if keyword.lower() in text.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def process_chat_request(
|
||||
self,
|
||||
request_data: Dict[str, Any]
|
||||
@@ -315,15 +294,25 @@ class ChatWorkflowService:
|
||||
"""
|
||||
normalized = entry_data.copy()
|
||||
|
||||
# ✅ content 必须是字符串(如果是字典或列表,转换为 JSON 字符串)
|
||||
if 'content' in normalized:
|
||||
content = normalized['content']
|
||||
if not isinstance(content, str):
|
||||
import json
|
||||
normalized['content'] = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
# uid 必须是字符串
|
||||
if 'uid' in normalized and not isinstance(normalized['uid'], str):
|
||||
normalized['uid'] = str(normalized['uid'])
|
||||
|
||||
# position 必须是字符串或 None
|
||||
# position 必须是整数或 None
|
||||
if 'position' in normalized:
|
||||
pos = normalized['position']
|
||||
if pos is not None and not isinstance(pos, str):
|
||||
normalized['position'] = str(pos)
|
||||
if pos is not None:
|
||||
try:
|
||||
normalized['position'] = int(pos)
|
||||
except (ValueError, TypeError):
|
||||
normalized['position'] = 0
|
||||
|
||||
# group 必须是列表或 None
|
||||
if 'group' in normalized:
|
||||
@@ -340,6 +329,68 @@ class ChatWorkflowService:
|
||||
|
||||
return normalized
|
||||
|
||||
def _get_trigger_type(self, entry_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
获取条目的触发类型
|
||||
|
||||
Args:
|
||||
entry_data: 世界书条目数据
|
||||
|
||||
Returns:
|
||||
str: 触发类型 ('constant', 'keyword', 'condition', 'rag', 'unknown')
|
||||
"""
|
||||
trigger_config = entry_data.get("trigger_config", {})
|
||||
triggers = trigger_config.get("triggers", {})
|
||||
|
||||
# 检查常驻触发
|
||||
constant_trigger = triggers.get("constant", [False, None])
|
||||
if constant_trigger[0]:
|
||||
return "constant"
|
||||
|
||||
# 检查关键词触发
|
||||
keyword_trigger = triggers.get("keyword", [False, None])
|
||||
if keyword_trigger[0]:
|
||||
return "keyword"
|
||||
|
||||
# 检查条件触发
|
||||
condition_trigger = triggers.get("condition", [False, None])
|
||||
if condition_trigger[0]:
|
||||
return "condition"
|
||||
|
||||
# 检查RAG触发
|
||||
rag_trigger = triggers.get("rag", [False, None])
|
||||
if rag_trigger[0]:
|
||||
return "rag"
|
||||
|
||||
return "unknown"
|
||||
|
||||
def _get_position_label(self, position) -> str:
|
||||
"""
|
||||
获取位置的中文标签
|
||||
|
||||
Args:
|
||||
position: 位置编号 (0-7)
|
||||
|
||||
Returns:
|
||||
str: 位置标签
|
||||
"""
|
||||
position_labels = {
|
||||
0: '角色定义之后',
|
||||
1: '角色定义之前',
|
||||
2: '示例对话之前',
|
||||
3: '示例对话之后',
|
||||
4: '系统提示/作者注释',
|
||||
5: '作为系统消息',
|
||||
6: '深度插入',
|
||||
7: '宏替换'
|
||||
}
|
||||
|
||||
try:
|
||||
pos_int = int(position) if position is not None else 0
|
||||
return position_labels.get(pos_int, f'未知位置({position})')
|
||||
except (ValueError, TypeError):
|
||||
return f'未知位置({position})'
|
||||
|
||||
async def _collect_and_activate_worldbooks(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
@@ -359,7 +410,7 @@ class ChatWorkflowService:
|
||||
character: 角色卡对象
|
||||
|
||||
Returns:
|
||||
List[WorldInfoEntry]: 激活的条目列表
|
||||
List[WorldInfoEntry]: 激活的条目列表(已按位置和顺序排序)
|
||||
"""
|
||||
try:
|
||||
from backend.models.internal import WorldInfoEntry
|
||||
@@ -376,16 +427,31 @@ class ChatWorkflowService:
|
||||
|
||||
world_book_data = request_data.get("worldBookData", {})
|
||||
if not world_book_data:
|
||||
print(f"[WorldBook] ⚠️ 未收到 worldBookData")
|
||||
return active_entries
|
||||
|
||||
# ✅ 详细日志:检查接收到的世界书数据
|
||||
global_books = world_book_data.get("globalBooks", [])
|
||||
character_book_id = world_book_data.get("characterBookId") or (character.worldInfoId if character else None)
|
||||
|
||||
print(f"[WorldBook] 📋 接收到的世界书数据:")
|
||||
print(f" - 全局世界书数量: {len(global_books)}")
|
||||
for wb in global_books:
|
||||
print(f" * {wb.get('name')}")
|
||||
print(f" - 角色绑定世界书: {character_book_id}")
|
||||
|
||||
if not global_books and not character_book_id:
|
||||
print(f"[WorldBook] ⚠️ 既没有全局世界书,也没有角色绑定世界书")
|
||||
return active_entries
|
||||
|
||||
# === 1. 加载全局世界书 ===
|
||||
global_books = world_book_data.get("globalBooks", [])
|
||||
for wb in global_books:
|
||||
try:
|
||||
wb_name = wb.get("name")
|
||||
if wb_name:
|
||||
wb_data = self.worldbook_service._load_worldbook(wb_name)
|
||||
if wb_data and "entries" in wb_data:
|
||||
print(f"[WorldBook] 📖 加载全局世界书 '{wb_name}',共 {len(wb_data['entries'])} 个条目")
|
||||
for entry_data in wb_data["entries"]:
|
||||
# 检查是否禁用
|
||||
if entry_data.get("disable", False):
|
||||
@@ -399,18 +465,21 @@ class ChatWorkflowService:
|
||||
try:
|
||||
entry = WorldInfoEntry(**normalized_entry)
|
||||
active_entries.append(entry)
|
||||
print(f"[WorldBook] 全局世界书 '{wb_name}' 条目 {entry_data.get('uid')} 已激活")
|
||||
trigger_type = self._get_trigger_type(normalized_entry)
|
||||
position_label = self._get_position_label(normalized_entry.get("position", 0))
|
||||
print(f"[WorldBook] ✅ 全局世界书 '{wb_name}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
|
||||
except Exception as validation_error:
|
||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||
except Exception as e:
|
||||
print(f"[WorldBook] 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
||||
print(f"[WorldBook] ❌ 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
||||
|
||||
# === 2. 加载角色绑定的世界书 ===
|
||||
character_book_id = character.worldInfoId
|
||||
if character_book_id:
|
||||
print(f"[WorldBook] 📖 开始加载角色绑定世界书: {character_book_id}")
|
||||
try:
|
||||
wb_data = self.worldbook_service._load_worldbook(character_book_id)
|
||||
if wb_data and "entries" in wb_data:
|
||||
print(f"[WorldBook] ✅ 成功加载世界书 '{character_book_id}',共 {len(wb_data['entries'])} 个条目")
|
||||
for entry_data in wb_data["entries"]:
|
||||
if entry_data.get("disable", False):
|
||||
continue
|
||||
@@ -422,16 +491,42 @@ class ChatWorkflowService:
|
||||
try:
|
||||
entry = WorldInfoEntry(**normalized_entry)
|
||||
active_entries.append(entry)
|
||||
print(f"[WorldBook] 角色世界书 '{character_book_id}' 条目 {entry_data.get('uid')} 已激活")
|
||||
trigger_type = self._get_trigger_type(normalized_entry)
|
||||
position_label = self._get_position_label(normalized_entry.get("position", 0))
|
||||
print(f"[WorldBook] ✅ 角色世界书 '{character_book_id}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
|
||||
except Exception as validation_error:
|
||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||
else:
|
||||
print(f"[WorldBook] ⚠️ 世界书 '{character_book_id}' 不存在或无条目")
|
||||
except Exception as e:
|
||||
print(f"[WorldBook] 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
||||
print(f"[WorldBook] ❌ 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
||||
else:
|
||||
print(f"[WorldBook] ℹ️ 角色未绑定世界书")
|
||||
|
||||
# === 3. 按位置和顺序排序 ===
|
||||
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
|
||||
|
||||
print(f"[WorldBook] 总共激活 {len(active_entries)} 个条目")
|
||||
# === 4. 统计激活条目的分布情况 ===
|
||||
position_stats = {}
|
||||
trigger_stats = {}
|
||||
for entry in active_entries:
|
||||
pos = entry.position or 0
|
||||
position_stats[pos] = position_stats.get(pos, 0) + 1
|
||||
|
||||
trigger_type = self._get_trigger_type(entry.model_dump())
|
||||
trigger_stats[trigger_type] = trigger_stats.get(trigger_type, 0) + 1
|
||||
|
||||
print(f"\n[WorldBook] 📊 激活统计:")
|
||||
print(f" - 总激活条目数: {len(active_entries)}")
|
||||
print(f" - 按位置分布:")
|
||||
for pos, count in sorted(position_stats.items()):
|
||||
pos_label = self._get_position_label(pos)
|
||||
print(f" * {pos_label} (pos={pos}): {count} 个")
|
||||
print(f" - 按触发类型分布:")
|
||||
for trigger_type, count in sorted(trigger_stats.items()):
|
||||
print(f" * {trigger_type}: {count} 个")
|
||||
print()
|
||||
|
||||
return active_entries
|
||||
|
||||
def _check_entry_activation(
|
||||
@@ -456,23 +551,28 @@ class ChatWorkflowService:
|
||||
trigger_config = entry_data.get("trigger_config", {})
|
||||
triggers = trigger_config.get("triggers", {})
|
||||
|
||||
# 1. 常驻触发 (constant)
|
||||
# 1. 常驻触发 (constant) - 总是激活
|
||||
constant_trigger = triggers.get("constant", [False, None])
|
||||
if constant_trigger[0]: # [true, null]
|
||||
print(f"[WorldBook-Check] 📌 常驻触发 | UID: {entry_data.get('uid')}")
|
||||
return True
|
||||
|
||||
# 2. 关键词触发 (keyword)
|
||||
# 2. 关键词触发 (keyword) - 检查用户输入和聊天历史
|
||||
keyword_trigger = triggers.get("keyword", [False, None])
|
||||
if keyword_trigger[0] and keyword_trigger[1]:
|
||||
keywords = keyword_trigger[1].get("keys", [])
|
||||
if keywords:
|
||||
keyword_config = keyword_trigger[1]
|
||||
keys = keyword_config.get("key", []) or keyword_config.get("keys", [])
|
||||
|
||||
if keys:
|
||||
# 检查用户输入
|
||||
if self._check_keyword_trigger(keywords, user_message):
|
||||
if self._check_keyword_trigger_with_config(keys, user_message, keyword_config):
|
||||
print(f"[WorldBook-Check] 🔑 关键词触发(用户输入) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
|
||||
return True
|
||||
|
||||
# 检查聊天历史
|
||||
for history_text in chat_history:
|
||||
if self._check_keyword_trigger(keywords, history_text):
|
||||
for i, history_text in enumerate(chat_history):
|
||||
if self._check_keyword_trigger_with_config(keys, history_text, keyword_config):
|
||||
print(f"[WorldBook-Check] 🔑 关键词触发(历史消息#{i}) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
|
||||
return True
|
||||
|
||||
# 3. 条件触发 (condition) - 支持动态表格语法
|
||||
@@ -481,19 +581,68 @@ class ChatWorkflowService:
|
||||
conditions = condition_trigger[1].get("conditions", [])
|
||||
if conditions:
|
||||
# 所有条件都必须满足 (AND逻辑)
|
||||
all_conditions_met = True
|
||||
for condition in conditions:
|
||||
cond_text = condition.get("text", "")
|
||||
if cond_text:
|
||||
# 解析条件,如 (tb.力量 > 10)
|
||||
if not self._parse_table_condition(cond_text, table_data):
|
||||
return False
|
||||
return True
|
||||
all_conditions_met = False
|
||||
break
|
||||
|
||||
if all_conditions_met:
|
||||
print(f"[WorldBook-Check] ⚙️ 条件触发 | UID: {entry_data.get('uid')} | 条件数: {len(conditions)}")
|
||||
return True
|
||||
|
||||
# 4. RAG触发 (暂不实现)
|
||||
# rag_trigger = triggers.get("rag", [False, None])
|
||||
|
||||
return False
|
||||
|
||||
def _check_keyword_trigger_with_config(self, keywords: List[str], text: str, config: Dict[str, Any] = None) -> bool:
|
||||
"""
|
||||
检查文本中是否包含关键词(带配置)
|
||||
|
||||
Args:
|
||||
keywords: 关键词列表
|
||||
text: 要检查的文本
|
||||
config: 关键词配置(可选)
|
||||
|
||||
Returns:
|
||||
bool: 是否匹配
|
||||
"""
|
||||
if not keywords or not text:
|
||||
return False
|
||||
|
||||
config = config or {}
|
||||
case_sensitive = config.get("caseSensitive", False)
|
||||
match_whole_words = config.get("matchWholeWords", False)
|
||||
|
||||
# 处理大小写
|
||||
if not case_sensitive:
|
||||
text_lower = text.lower()
|
||||
keywords_lower = [kw.lower() for kw in keywords]
|
||||
else:
|
||||
text_lower = text
|
||||
keywords_lower = keywords
|
||||
|
||||
# 检查每个关键词
|
||||
for keyword in keywords_lower:
|
||||
if not keyword:
|
||||
continue
|
||||
|
||||
if match_whole_words:
|
||||
# 全词匹配:使用正则表达式
|
||||
pattern = r'\b' + re.escape(keyword) + r'\b'
|
||||
if re.search(pattern, text_lower):
|
||||
return True
|
||||
else:
|
||||
# 部分匹配:直接检查子串
|
||||
if keyword in text_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _load_chat_history(
|
||||
self,
|
||||
role_name: str,
|
||||
@@ -851,7 +1000,7 @@ class ChatWorkflowService:
|
||||
)
|
||||
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
|
||||
for i, entry in enumerate(active_entries, 1):
|
||||
print(f" {i}. {entry.get('name', 'N/A')} (UID: {entry.get('uid', 'N/A')})")
|
||||
print(f" {i}. {getattr(entry, 'name', 'N/A')} (UID: {getattr(entry, 'uid', 'N/A')})")
|
||||
|
||||
# === 第4步:加载聊天历史 ===
|
||||
chat_history = await self._load_chat_history(current_role, current_chat)
|
||||
@@ -900,7 +1049,7 @@ class ChatWorkflowService:
|
||||
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
|
||||
|
||||
try:
|
||||
async for chunk in self.llm_client.stream_chat(
|
||||
async for chunk_dict in self.llm_client.stream_chat(
|
||||
messages=prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
@@ -909,7 +1058,22 @@ class ChatWorkflowService:
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
|
||||
):
|
||||
generated_content += chunk
|
||||
# ✅ 处理 LLM 返回的字典格式数据
|
||||
if isinstance(chunk_dict, dict):
|
||||
if chunk_dict.get("type") == "chunk":
|
||||
chunk_content = chunk_dict.get("content", "")
|
||||
elif chunk_dict.get("type") == "usage":
|
||||
# 跳过 usage 信息,不拼接到内容中
|
||||
continue
|
||||
else:
|
||||
# 兼容旧格式或未知类型,尝试直接获取 content
|
||||
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||
else:
|
||||
# 如果直接返回字符串(兼容情况)
|
||||
chunk_content = str(chunk_dict)
|
||||
|
||||
# ✅ 拼接纯文本内容
|
||||
generated_content += chunk_content
|
||||
chunk_count += 1
|
||||
|
||||
# 第一个 chunk 到达时记录
|
||||
@@ -922,8 +1086,8 @@ class ChatWorkflowService:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
|
||||
|
||||
# 调用回调函数发送 chunk
|
||||
await on_chunk(chunk)
|
||||
# ✅ 调用回调函数发送纯文本 chunk
|
||||
await on_chunk(chunk_content)
|
||||
|
||||
except Exception as stream_error:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
@@ -201,3 +201,42 @@ class PresetService:
|
||||
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
重新排序预设组件
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
component_order: 组件 identifier 列表,按新顺序排列
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
# 支持内部结构的 entries
|
||||
if "entries" in data and isinstance(data["entries"], list):
|
||||
# 创建 identifier 到 entry 的映射
|
||||
entry_map = {entry["identifier"]: entry for entry in data["entries"]}
|
||||
|
||||
# 按新顺序重新排列
|
||||
reordered_entries = []
|
||||
for identifier in component_order:
|
||||
if identifier in entry_map:
|
||||
reordered_entries.append(entry_map[identifier])
|
||||
|
||||
# 更新 order 字段
|
||||
for index, entry in enumerate(reordered_entries):
|
||||
entry["order"] = index
|
||||
|
||||
data["entries"] = reordered_entries
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
PresetService._save_preset(name, data)
|
||||
return data
|
||||
|
||||
@@ -132,14 +132,14 @@ class PromptAssembler:
|
||||
|
||||
# Pos 4: AN Top
|
||||
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||
parts.append(entry.content)
|
||||
parts.append(str(entry.content) if entry.content else "")
|
||||
|
||||
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||
parts.append(f"[Author's note at depth {depth}]")
|
||||
|
||||
# Pos 5: AN Bottom
|
||||
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
||||
parts.append(entry.content)
|
||||
parts.append(str(entry.content) if entry.content else "")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -147,10 +147,15 @@ class PromptAssembler:
|
||||
"""
|
||||
在聊天历史的指定深度插入条目 (Pos 6)
|
||||
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||
|
||||
✅ 过滤已被总结的消息(is_summarized=True 且 mes="")
|
||||
"""
|
||||
# 先将历史转换为中间格式
|
||||
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
|
||||
msg_list = []
|
||||
for msg in history:
|
||||
# ✅ 跳过已被总结的空消息
|
||||
if msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
|
||||
continue
|
||||
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
||||
|
||||
# 按 depth 分组插入
|
||||
|
||||
Reference in New Issue
Block a user