feat(agent): support file attachments and local file replies

This commit is contained in:
jxxghp
2026-04-14 15:22:01 +08:00
parent 81828948dd
commit 7a5e513f25
18 changed files with 1268 additions and 84 deletions

View File

@@ -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