feat: persist agent chat history

This commit is contained in:
jxxghp
2026-06-18 11:45:50 +08:00
parent bfa2cf5c1f
commit 80d440f6a0
12 changed files with 1406 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ from langchain.agents.middleware import (
from langchain_core.messages import ( # noqa: F401
HumanMessage,
BaseMessage,
SystemMessage,
)
import warnings
@@ -50,6 +51,7 @@ from app.agent.tools.factory import MoviePilotToolFactory
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
from app.db.agentchat_oper import AgentChatOper
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
@@ -229,6 +231,11 @@ HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
AGENT_EXECUTION_ERROR_MESSAGE = "智能助手执行失败,请稍后重试。"
AGENT_DISPLAY_HISTORY_SKIP_CHANNELS = {MessageChannel.WebAgent.value}
AGENT_CHAT_TITLE_PROMPT = (
"你是 MoviePilot 智能助手的会话标题生成器。请根据用户的第一条消息生成一个简洁中文标题,"
"不超过 18 个汉字或 36 个英文字符,只输出标题本身,不要引号、编号或解释。"
)
class MoviePilotAgent:
@@ -269,6 +276,129 @@ class MoviePilotAgent:
# 流式token管理
self.stream_handler = StreamingHandler()
@staticmethod
def _current_timestamp_ms() -> int:
"""返回当前毫秒时间戳。"""
return int(datetime.now().timestamp() * 1000)
@classmethod
def build_display_message(
cls,
role: str,
content: str = "",
attachments: Optional[List[dict]] = None,
status: str = "done",
) -> dict[str, Any]:
"""
构造可展示的 Agent 会话消息。
"""
return {
"id": f"{role}-{uuid.uuid4().hex}",
"role": role,
"content": content or "",
"createdAt": cls._current_timestamp_ms(),
"status": status,
"tools": [],
"attachments": attachments or [],
"choices": [],
}
def _should_save_display_history(self) -> bool:
"""
判断当前 Agent 是否由通用渠道保存展示历史。
"""
return bool(
self.channel
and self.source
and self.channel not in AGENT_DISPLAY_HISTORY_SKIP_CHANNELS
)
def _save_display_history_messages(self, messages: List[dict]) -> None:
"""
将一组可见消息追加到 Agent 会话历史表。
"""
if not messages or not self._should_save_display_history():
return
try:
AgentChatOper().append_display_messages(
session_id=self.session_id,
user_id=self.user_id,
username=self.username,
channel=self.channel,
source=self.source,
original_chat_id=self.original_chat_id,
messages=messages,
)
except Exception as e:
logger.debug(f"写入Agent展示历史失败: {e}")
def _save_assistant_display_message_once(self, message: str) -> None:
"""
保存一条助手回复展示记录,并标记本轮已写入。
"""
if not message or self._tool_context.get("assistant_display_saved"):
return
self._save_display_history_messages(
[self.build_display_message(role="assistant", content=message)]
)
self._tool_context["assistant_display_saved"] = True
@staticmethod
def _sanitize_chat_title(value: str) -> str:
"""清理模型返回的会话标题。"""
title = str(value or "").strip()
title = re.sub(r"^[#\-*\d.、\s]+", "", title)
title = title.strip("「」『』“”\"'` \n\t")
title = re.sub(r"\s+", " ", title)
return title[:120]
async def _generate_chat_title(self, message: str) -> str:
"""
使用当前 Agent 模型生成会话标题。
"""
if not str(message or "").strip():
return ""
model = await self._initialize_llm(streaming=False)
response = await model.ainvoke(
[
SystemMessage(content=AGENT_CHAT_TITLE_PROMPT),
HumanMessage(content=str(message).strip()[:1000]),
]
)
content = LLMHelper._extract_text_content(getattr(response, "content", response))
return self._sanitize_chat_title(content)
async def prepare_chat_title(self, message: str) -> None:
"""
首次对话时生成并保存会话标题。
"""
if self._tool_context.get("chat_title_prepared"):
return
self._tool_context["chat_title_prepared"] = True
try:
chat = await run_in_threadpool(
AgentChatOper().get,
session_id=self.session_id,
user_id=self.user_id,
)
if chat and AgentChatOper.has_custom_title(chat.title):
return
title = await self._generate_chat_title(message)
if not title:
return
await run_in_threadpool(
AgentChatOper().update_title_if_empty,
session_id=self.session_id,
user_id=self.user_id,
title=title,
username=self.username,
channel=self.channel,
source=self.source,
original_chat_id=self.original_chat_id,
)
except Exception as e:
logger.debug(f"生成Agent会话标题失败: {e}")
@staticmethod
def _coerce_int(value: Any) -> Optional[int]:
if value is None:
@@ -944,6 +1074,7 @@ class MoviePilotAgent:
"""
处理用户消息,流式推理并返回 Agent 回复
"""
user_display_saved = False
try:
logger.info(
f"Agent推理: session_id={self.session_id}, input={message}, "
@@ -982,6 +1113,21 @@ class MoviePilotAgent:
for img in images or []:
content.append({"type": "image_url", "image_url": {"url": img}})
messages.append(HumanMessage(content=content))
await self.prepare_chat_title(message)
self._save_display_history_messages(
[
self.build_display_message(
role="user",
content=message,
attachments=self._build_input_display_attachments(
images=images,
files=files,
has_audio_input=has_audio_input,
),
)
]
)
user_display_saved = True
# 执行推理
result = await self._execute_agent(messages)
@@ -992,11 +1138,63 @@ class MoviePilotAgent:
except Exception as e:
error_message = f"处理消息时发生错误: {str(e)}"
logger.error(error_message)
if not user_display_saved:
self._save_display_history_messages(
[self.build_display_message(role="user", content=message)]
)
if not self.should_dispatch_reply:
raise
await self.send_agent_message(error_message)
return error_message
@staticmethod
def _guess_file_attachment_kind(mime_type: Optional[str], fallback: str = "file") -> str:
"""
根据 MIME 类型推断展示附件类型。
"""
if mime_type and mime_type.startswith("image/"):
return "image"
if mime_type and mime_type.startswith("audio/"):
return "audio"
return fallback
def _build_input_display_attachments(
self,
images: Optional[List[str]] = None,
files: Optional[List[dict]] = None,
has_audio_input: bool = False,
) -> List[dict]:
"""
构造用户输入附件的展示记录。
"""
attachments: List[dict] = []
for index, image in enumerate(images or [], start=1):
attachments.append(
{
"kind": "image",
"url": image,
"download_url": image,
"name": f"image-{index}",
"mime_type": "image/*",
}
)
for index, file in enumerate(files or [], start=1):
ref = file.get("ref") or file.get("local_path") or ""
mime_type = file.get("mime_type")
fallback = "audio" if has_audio_input and mime_type == "audio/*" else "file"
attachments.append(
{
"kind": self._guess_file_attachment_kind(mime_type, fallback=fallback),
"url": ref,
"download_url": ref,
"name": file.get("name") or f"attachment-{index}",
"mime_type": mime_type,
"size": file.get("size"),
"local_path": file.get("local_path"),
}
)
return attachments
async def _stream_agent_tokens(
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
):
@@ -1164,6 +1362,17 @@ class MoviePilotAgent:
# 非流式渠道:发送最终回复
await self.send_agent_message(final_text)
display_text = self._streamed_output
if not display_text:
final_messages = agent.get_state(agent_config).values.get(
"messages", []
)
for msg in reversed(final_messages):
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
display_text = self._extract_text_content(msg.content).strip()
break
self._save_assistant_display_message_once(display_text)
# 保存消息
memory_manager.save_agent_messages(
session_id=self.session_id,
@@ -1200,6 +1409,7 @@ class MoviePilotAgent:
"""
通过原渠道发送消息给用户
"""
self._save_assistant_display_message_once(message)
await AgentChain().async_post_message(
Notification(
channel=self.channel,

View File

@@ -4,9 +4,10 @@ import asyncio
from datetime import datetime
from typing import Dict, List, Optional
from langchain_core.messages import BaseMessage
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
from app.core.config import settings
from app.db.agentchat_oper import AgentChatOper
from app.log import logger
from app.schemas.agent import ConversationMemory
@@ -70,24 +71,43 @@ class MemoryManager:
self, session_id: str, user_id: str
) -> List[BaseMessage]:
"""
为Agent获取最近的消息(仅内存缓存)
为Agent获取最近的消息
如果消息Token数量超过模型最大上下文长度的阀值会自动进行摘要裁剪
优先使用内存缓存,缓存不存在时从数据库恢复上一轮持久化的原始 messages。
"""
memory = self.get_memory(session_id, user_id)
if not memory:
if memory:
return memory.messages
try:
chat = AgentChatOper().get(session_id=session_id, user_id=user_id)
if not chat:
chat = AgentChatOper().get(session_id=session_id)
except Exception as e:
logger.debug(f"读取持久化Agent会话失败: {e}")
return []
if not chat or not chat.agent_messages:
return []
# 获取所有消息
try:
messages = messages_from_dict(chat.agent_messages)
except Exception as e:
logger.debug(f"恢复持久化Agent消息失败: {e}")
return []
memory = ConversationMemory(
session_id=session_id,
user_id=user_id,
messages=messages,
)
self.save_memory(memory)
return memory.messages
def save_agent_messages(
self, session_id: str, user_id: str, messages: List[BaseMessage]
):
"""
保存Agent消息(仅内存缓存
注意Redis中的记忆通过TTL机制自动过期这里只更新内存缓存Redis会在下次访问时自动过期
保存Agent消息内存缓存与持久化会话表。
"""
memory = self.get_memory(session_id, user_id)
if not memory:
@@ -98,6 +118,14 @@ class MemoryManager:
# 更新内存缓存
self.save_memory(memory)
try:
AgentChatOper().save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages_to_dict(messages),
)
except Exception as e:
logger.debug(f"持久化Agent消息失败: {e}")
def save_memory(self, memory: ConversationMemory):
"""