mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-12 16:02:35 +08:00
feat: persist agent chat history
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user