mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-27 11:11:07 +08:00
feat(agent): support file attachments and local file replies
This commit is contained in:
@@ -2,8 +2,10 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Dict, Union, List
|
||||
from urllib.parse import unquote
|
||||
import uuid
|
||||
|
||||
import base64
|
||||
|
||||
@@ -135,7 +137,8 @@ class MessageChain(ChainBase):
|
||||
text = str(info.text).strip() if info.text else ""
|
||||
images = info.images
|
||||
audio_refs = info.audio_refs
|
||||
if not text and not images and not audio_refs:
|
||||
files = info.files
|
||||
if not text and not images and not audio_refs and not files:
|
||||
logger.debug(f"未识别到消息内容::{body}{form}{args}")
|
||||
return
|
||||
|
||||
@@ -154,6 +157,7 @@ class MessageChain(ChainBase):
|
||||
original_chat_id=original_chat_id,
|
||||
images=images,
|
||||
audio_refs=audio_refs,
|
||||
files=files,
|
||||
)
|
||||
|
||||
def handle_message(
|
||||
@@ -167,6 +171,7 @@ class MessageChain(ChainBase):
|
||||
original_chat_id: Optional[str] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
audio_refs: Optional[List[str]] = None,
|
||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
识别消息内容,执行操作
|
||||
@@ -253,9 +258,12 @@ class MessageChain(ChainBase):
|
||||
userid=userid,
|
||||
username=username,
|
||||
images=images,
|
||||
files=files,
|
||||
reply_with_voice=reply_with_voice,
|
||||
)
|
||||
elif settings.AI_AGENT_ENABLE and settings.AI_AGENT_GLOBAL:
|
||||
elif settings.AI_AGENT_ENABLE and (
|
||||
settings.AI_AGENT_GLOBAL or images or files
|
||||
):
|
||||
# 普通消息,全局智能体响应
|
||||
self._handle_ai_message(
|
||||
text=text,
|
||||
@@ -264,6 +272,7 @@ class MessageChain(ChainBase):
|
||||
userid=userid,
|
||||
username=username,
|
||||
images=images,
|
||||
files=files,
|
||||
reply_with_voice=reply_with_voice,
|
||||
)
|
||||
else:
|
||||
@@ -1230,6 +1239,7 @@ class MessageChain(ChainBase):
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
images: Optional[List[str]] = None,
|
||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||
reply_with_voice: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -1255,7 +1265,7 @@ class MessageChain(ChainBase):
|
||||
else:
|
||||
user_message = text.strip() # 按原消息处理
|
||||
|
||||
if not user_message and not images:
|
||||
if not user_message and not images and not files:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
@@ -1274,7 +1284,7 @@ class MessageChain(ChainBase):
|
||||
original_images = images
|
||||
if images:
|
||||
images = self._download_images_to_base64(images, channel, source)
|
||||
if original_images and not images and not user_message:
|
||||
if original_images and not images and not user_message and not files:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
@@ -1286,6 +1296,24 @@ class MessageChain(ChainBase):
|
||||
)
|
||||
return
|
||||
|
||||
prepared_files = self._prepare_agent_files(
|
||||
session_id=session_id,
|
||||
files=files,
|
||||
channel=channel,
|
||||
source=source,
|
||||
)
|
||||
if files and not prepared_files and not user_message and not images:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="文件读取失败,请稍后重试",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# 在事件循环中处理
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
agent_manager.process_message(
|
||||
@@ -1293,6 +1321,7 @@ class MessageChain(ChainBase):
|
||||
user_id=str(userid),
|
||||
message=user_message,
|
||||
images=images,
|
||||
files=prepared_files,
|
||||
channel=channel.value if channel else None,
|
||||
source=source,
|
||||
username=username,
|
||||
@@ -1481,3 +1510,148 @@ class MessageChain(ChainBase):
|
||||
except Exception as e:
|
||||
logger.error(f"下载图片失败: {img}, error: {e}")
|
||||
return base64_images if base64_images else None
|
||||
|
||||
def _prepare_agent_files(
|
||||
self,
|
||||
session_id: str,
|
||||
files: Optional[List[CommingMessage.MessageAttachment]],
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
) -> Optional[List[dict]]:
|
||||
"""
|
||||
下载用户上传的文件,落盘到临时目录,并生成文本镜像供 Agent 使用。
|
||||
"""
|
||||
if not files:
|
||||
return None
|
||||
|
||||
prepared_files = []
|
||||
for attachment in files:
|
||||
payload = {
|
||||
"name": attachment.name,
|
||||
"mime_type": attachment.mime_type,
|
||||
"size": attachment.size,
|
||||
"ref": attachment.ref,
|
||||
"status": "download_failed",
|
||||
}
|
||||
try:
|
||||
content = self._download_message_file_bytes(
|
||||
file_ref=attachment.ref,
|
||||
channel=channel,
|
||||
source=source,
|
||||
)
|
||||
if not content:
|
||||
prepared_files.append(payload)
|
||||
continue
|
||||
|
||||
local_path = self._save_agent_attachment(
|
||||
session_id=session_id,
|
||||
filename=attachment.name,
|
||||
content=content,
|
||||
mime_type=attachment.mime_type,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"local_path": str(local_path),
|
||||
"status": "ready",
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"准备文件上下文失败: {attachment.ref}, error: {err}")
|
||||
payload["error"] = str(err)
|
||||
prepared_files.append(payload)
|
||||
|
||||
return prepared_files or None
|
||||
|
||||
def _download_message_file_bytes(
|
||||
self, file_ref: str, channel: MessageChannel, source: str
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
下载消息附件的原始字节。
|
||||
"""
|
||||
if not file_ref:
|
||||
return None
|
||||
if file_ref.startswith("tg://document_file_id/"):
|
||||
file_id = file_ref.replace("tg://document_file_id/", "", 1)
|
||||
return self.run_module(
|
||||
"download_telegram_file_bytes", file_id=file_id, source=source
|
||||
)
|
||||
if file_ref.startswith("wxwork://file_media_id/"):
|
||||
return self.run_module(
|
||||
"download_wechat_media_bytes", media_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("wxbot://file/"):
|
||||
file_url = unquote(file_ref.replace("wxbot://file/", "", 1))
|
||||
resp = RequestUtils(timeout=30).get_res(file_url)
|
||||
return resp.content if resp and resp.content else None
|
||||
if file_ref.startswith("slack://file/"):
|
||||
return self.run_module(
|
||||
"download_slack_file_bytes", file_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("discord://file/"):
|
||||
return self.run_module(
|
||||
"download_discord_file_bytes", file_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("qq://file/"):
|
||||
return self.run_module(
|
||||
"download_qq_file_bytes", file_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("vocechat://file/"):
|
||||
return self.run_module(
|
||||
"download_vocechat_file_bytes", file_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("synology://file/"):
|
||||
return self.run_module(
|
||||
"download_synologychat_file_bytes", file_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("http"):
|
||||
resp = RequestUtils(timeout=30).get_res(file_ref)
|
||||
return resp.content if resp and resp.content else None
|
||||
logger.debug(
|
||||
"暂不支持的文件引用: channel=%s, source=%s, ref=%s",
|
||||
channel.value if channel else None,
|
||||
source,
|
||||
file_ref,
|
||||
)
|
||||
return None
|
||||
|
||||
def _save_agent_attachment(
|
||||
self,
|
||||
session_id: str,
|
||||
filename: Optional[str],
|
||||
content: bytes,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
将用户上传文件写入临时目录,并返回本地路径。
|
||||
"""
|
||||
safe_name = self._sanitize_attachment_name(filename, mime_type)
|
||||
base_dir = settings.TEMP_PATH / "agent_uploads" / session_id
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_id = uuid.uuid4().hex[:8]
|
||||
local_path = base_dir / f"{file_id}_{safe_name}"
|
||||
local_path.write_bytes(content or b"")
|
||||
return local_path
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_attachment_name(
|
||||
filename: Optional[str], mime_type: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
规范化附件文件名,避免路径穿越和非法字符。
|
||||
"""
|
||||
name = Path(filename or "attachment").name
|
||||
name = re.sub(r"[^\w.\-]+", "_", name, flags=re.ASCII).strip("._")
|
||||
if not name:
|
||||
name = "attachment"
|
||||
if "." not in name:
|
||||
mime = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
default_ext = {
|
||||
"application/json": ".json",
|
||||
"text/plain": ".txt",
|
||||
"text/markdown": ".md",
|
||||
"text/csv": ".csv",
|
||||
}.get(mime)
|
||||
if default_ext:
|
||||
name = f"{name}{default_ext}"
|
||||
return name
|
||||
|
||||
Reference in New Issue
Block a user