2026-02-02 21:44:10 +08:00
|
|
|
|
"""附件注入中间件 - 使用 LangChain 标准中间件实现
|
|
|
|
|
|
|
|
|
|
|
|
支持两种模式:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
1. MinIO 模式(默认):将附件保存到 MinIO 存储,提示模型自主读取
|
|
|
|
|
|
2. 文件系统模式(已废弃):将附件保存到本地文件系统
|
2026-02-02 21:44:10 +08:00
|
|
|
|
"""
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from collections.abc import Callable, Sequence
|
|
|
|
|
|
from typing import NotRequired
|
|
|
|
|
|
|
|
|
|
|
|
from langchain.agents import AgentState
|
|
|
|
|
|
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
|
|
|
|
|
|
2026-02-03 02:28:51 +08:00
|
|
|
|
from src.agents.common.backends.minio_backend import MinIOBackend
|
2025-11-08 10:51:30 +08:00
|
|
|
|
from src.utils import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AttachmentState(AgentState):
|
|
|
|
|
|
"""扩展 AgentState 以支持附件"""
|
|
|
|
|
|
|
|
|
|
|
|
attachments: NotRequired[list[dict]]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str | None:
|
|
|
|
|
|
"""Render attachments into a system prompt block with file paths.
|
|
|
|
|
|
|
|
|
|
|
|
提示模型使用 read_file 工具读取附件内容。
|
|
|
|
|
|
"""
|
2025-11-08 10:51:30 +08:00
|
|
|
|
if not attachments:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
valid_attachments = [a for a in attachments if a.get("status") == "parsed" and a.get("markdown")]
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
if not valid_attachments:
|
|
|
|
|
|
return None
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
attachment_infos: list[str] = []
|
|
|
|
|
|
for idx, attachment in enumerate(valid_attachments, 1):
|
|
|
|
|
|
file_id = attachment.get("file_id", f"file_{idx}")
|
2025-11-08 10:51:30 +08:00
|
|
|
|
file_name = attachment.get("file_name") or f"附件 {idx}"
|
|
|
|
|
|
truncated = "(已截断)" if attachment.get("truncated") else ""
|
|
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
file_path = f"{thread_id}/{file_id}.md"
|
|
|
|
|
|
attachment_infos.append(f"- {file_name}{truncated}: /attachments/{file_path}")
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
lines = [
|
|
|
|
|
|
"用户上传了以下附件,已保存到文件系统中:",
|
|
|
|
|
|
"",
|
|
|
|
|
|
*attachment_infos,
|
|
|
|
|
|
"",
|
|
|
|
|
|
"请使用 read_file 工具读取附件内容后,再回答用户的问题。如果附件与问题无关,可以忽略附件内容。",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
|
|
|
|
|
"""
|
|
|
|
|
|
LangChain 标准中间件:从 State 中读取附件并注入到消息中。
|
|
|
|
|
|
|
|
|
|
|
|
根据官方文档示例:
|
|
|
|
|
|
https://docs.langchain.com/oss/python/langchain/middleware
|
|
|
|
|
|
|
|
|
|
|
|
从 request.state 中读取 attachments,将其转换为 SystemMessage 并注入到消息列表开头。
|
|
|
|
|
|
|
|
|
|
|
|
NOTE: 缺点是无法命中缓存了
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
state_schema = AttachmentState
|
|
|
|
|
|
|
|
|
|
|
|
async def awrap_model_call(
|
|
|
|
|
|
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
|
|
|
|
|
|
) -> ModelResponse:
|
|
|
|
|
|
# Read from State: get uploaded files metadata
|
2026-02-03 02:28:51 +08:00
|
|
|
|
# 首先尝试从 state 获取,如果为空则从 input_context 获取
|
2025-11-08 10:51:30 +08:00
|
|
|
|
attachments = request.state.get("attachments", [])
|
|
|
|
|
|
|
2026-02-03 02:28:51 +08:00
|
|
|
|
# 如果 state 中没有,尝试从 input_context 获取
|
|
|
|
|
|
if not attachments:
|
2026-02-02 21:44:10 +08:00
|
|
|
|
input_context = request.state.get("input_context", {})
|
2026-02-03 02:28:51 +08:00
|
|
|
|
attachments = input_context.get("attachments", [])
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: request.state keys = {list(request.state.keys())}")
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: found {len(attachments)} attachments in state")
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试从输入中获取 attachments(LangGraph 会将输入 state 合并)
|
|
|
|
|
|
if not attachments:
|
|
|
|
|
|
# 检查是否有其他方式传递的附件
|
2026-02-03 14:55:21 +08:00
|
|
|
|
logger.info("AttachmentMiddleware: checking for attachments in other locations...")
|
2026-02-03 02:28:51 +08:00
|
|
|
|
# 输入可能直接在 state 中
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: state type = {type(request.state)}")
|
|
|
|
|
|
|
|
|
|
|
|
if attachments:
|
|
|
|
|
|
# Get thread_id - 尝试从多个来源获取
|
|
|
|
|
|
thread_id = None
|
|
|
|
|
|
|
|
|
|
|
|
# 0. 尝试从 request.runtime 获取(LangChain runtime)
|
2026-02-03 14:55:21 +08:00
|
|
|
|
if hasattr(request, "runtime") and request.runtime:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
runtime = request.runtime
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime type = {type(runtime)}")
|
2026-02-03 14:55:21 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"AttachmentMiddleware: runtime attrs = {[a for a in dir(runtime) if not a.startswith('_')]}"
|
|
|
|
|
|
)
|
2026-02-03 02:28:51 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查 runtime.context
|
2026-02-03 14:55:21 +08:00
|
|
|
|
if hasattr(runtime, "context") and runtime.context:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
ctx = runtime.context
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime.context type = {type(ctx)}")
|
|
|
|
|
|
# 如果是 Pydantic 模型,使用 model_dump()
|
2026-02-03 14:55:21 +08:00
|
|
|
|
if hasattr(ctx, "model_dump"):
|
2026-02-03 02:28:51 +08:00
|
|
|
|
ctx_dict = ctx.model_dump()
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime.context keys = {list(ctx_dict.keys())}")
|
|
|
|
|
|
thread_id = ctx_dict.get("thread_id")
|
2026-02-03 14:55:21 +08:00
|
|
|
|
elif hasattr(ctx, "__dict__"):
|
2026-02-03 02:28:51 +08:00
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime.context __dict__ = {ctx.__dict__}")
|
|
|
|
|
|
thread_id = ctx.__dict__.get("thread_id")
|
|
|
|
|
|
elif isinstance(ctx, dict):
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime.context keys = {list(ctx.keys())}")
|
|
|
|
|
|
thread_id = ctx.get("thread_id")
|
|
|
|
|
|
|
|
|
|
|
|
# 如果还没有 thread_id,检查 runtime 其他属性
|
|
|
|
|
|
if not thread_id:
|
2026-02-03 14:55:21 +08:00
|
|
|
|
for attr in ["state", "config", "configurable"]:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
if hasattr(runtime, attr):
|
|
|
|
|
|
val = getattr(runtime, attr)
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: runtime.{attr} = {type(val)}")
|
|
|
|
|
|
if isinstance(val, dict):
|
|
|
|
|
|
thread_id = val.get("thread_id")
|
2026-02-03 14:55:21 +08:00
|
|
|
|
elif hasattr(val, "get"):
|
2026-02-03 02:28:51 +08:00
|
|
|
|
thread_id = val.get("thread_id")
|
|
|
|
|
|
if thread_id:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 尝试从 state 获取
|
|
|
|
|
|
if not thread_id:
|
|
|
|
|
|
thread_id = request.state.get("thread_id")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 尝试从 configurable 获取(LangGraph checkpointer 存储方式)
|
|
|
|
|
|
if not thread_id:
|
|
|
|
|
|
configurable = request.state.get("configurable", {})
|
|
|
|
|
|
if isinstance(configurable, dict):
|
|
|
|
|
|
thread_id = configurable.get("thread_id")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 尝试从 input_context 获取(如果存在)
|
|
|
|
|
|
if not thread_id:
|
|
|
|
|
|
input_context = request.state.get("input_context")
|
|
|
|
|
|
if input_context is not None:
|
|
|
|
|
|
if isinstance(input_context, dict):
|
|
|
|
|
|
thread_id = input_context.get("thread_id")
|
2026-02-03 14:55:21 +08:00
|
|
|
|
elif hasattr(input_context, "thread_id"):
|
|
|
|
|
|
thread_id = getattr(input_context, "thread_id", None)
|
2026-02-03 02:28:51 +08:00
|
|
|
|
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: thread_id = {thread_id}")
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: has config = {hasattr(request, 'config')}")
|
|
|
|
|
|
logger.info(f"AttachmentMiddleware: state keys = {list(request.state.keys())}")
|
|
|
|
|
|
|
|
|
|
|
|
if not thread_id:
|
|
|
|
|
|
logger.error(f"AttachmentMiddleware: thread_id not found. input_context type = {type(input_context)}")
|
|
|
|
|
|
logger.error(f"AttachmentMiddleware: request.state = {dict(request.state)}")
|
2026-02-02 21:44:10 +08:00
|
|
|
|
|
|
|
|
|
|
if not thread_id:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"AttachmentMiddleware requires thread_id in input_context. "
|
|
|
|
|
|
"Please ensure the conversation has a valid thread_id."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Save attachments to filesystem
|
|
|
|
|
|
attachment_paths = await _save_attachments_to_fs(attachments, thread_id)
|
|
|
|
|
|
|
|
|
|
|
|
# Build attachment prompt with file paths
|
|
|
|
|
|
attachment_prompt = _build_attachment_prompt(attachments, thread_id)
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
if attachment_prompt:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
logger.info(f"Saved {len(attachment_paths)} attachments to /attachments/{thread_id}/")
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
2026-01-22 05:57:13 +08:00
|
|
|
|
messages = list(request.messages)
|
|
|
|
|
|
insert_idx = 0
|
|
|
|
|
|
for idx, msg in enumerate(messages):
|
|
|
|
|
|
if isinstance(msg, dict):
|
|
|
|
|
|
role = msg.get("role") or msg.get("type")
|
|
|
|
|
|
is_system = role == "system"
|
|
|
|
|
|
else:
|
|
|
|
|
|
msg_type = getattr(msg, "type", None) or getattr(msg, "role", None)
|
|
|
|
|
|
is_system = msg_type == "system"
|
|
|
|
|
|
|
|
|
|
|
|
if not is_system:
|
|
|
|
|
|
break
|
|
|
|
|
|
insert_idx = idx + 1
|
|
|
|
|
|
|
|
|
|
|
|
messages.insert(insert_idx, {"role": "system", "content": attachment_prompt})
|
2025-11-08 10:51:30 +08:00
|
|
|
|
request = request.override(messages=messages)
|
|
|
|
|
|
|
|
|
|
|
|
return await handler(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 21:44:10 +08:00
|
|
|
|
async def _save_attachments_to_fs(attachments: Sequence[dict], thread_id: str) -> list[str]:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
"""Save attachment markdown content to MinIO using MinIOBackend.
|
|
|
|
|
|
|
|
|
|
|
|
保存路径: /attachments/{thread_id}/{original_file_name}.md (使用原始文件名)
|
|
|
|
|
|
实际存储: attachments/{thread_id}/{original_file_name}.md (MinIO key)
|
2026-02-02 21:44:10 +08:00
|
|
|
|
|
2026-02-03 02:28:51 +08:00
|
|
|
|
使用 MinIOBackend 确保 read_file 工具能够读取这些文件。
|
2026-02-02 21:44:10 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-02-03 02:28:51 +08:00
|
|
|
|
list of saved file paths (relative to /attachments/)
|
2026-02-02 21:44:10 +08:00
|
|
|
|
"""
|
2026-02-03 02:28:51 +08:00
|
|
|
|
backend = MinIOBackend(bucket_name="chat-attachments")
|
2026-02-02 21:44:10 +08:00
|
|
|
|
|
|
|
|
|
|
saved_paths: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for attachment in attachments:
|
|
|
|
|
|
if attachment.get("status") != "parsed":
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
file_id = attachment.get("file_id")
|
2026-02-03 02:28:51 +08:00
|
|
|
|
file_name = attachment.get("file_name")
|
2026-02-02 21:44:10 +08:00
|
|
|
|
markdown = attachment.get("markdown")
|
|
|
|
|
|
|
2026-02-03 02:28:51 +08:00
|
|
|
|
if not file_id or not file_name or not markdown:
|
2026-02-02 21:44:10 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-02-03 02:28:51 +08:00
|
|
|
|
# 确保文件名安全:移除路径分隔符,保留原始扩展名
|
|
|
|
|
|
safe_file_name = file_name.replace("/", "_").replace("\\", "_")
|
|
|
|
|
|
file_path = f"/attachments/{thread_id}/{safe_file_name}"
|
|
|
|
|
|
result = backend.write(file_path, markdown)
|
|
|
|
|
|
if not result.error:
|
|
|
|
|
|
saved_paths.append(file_path)
|
|
|
|
|
|
logger.info(f"Saved attachment to MinIO: {file_path}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Failed to save attachment: {result.error}")
|
2026-02-02 21:44:10 +08:00
|
|
|
|
|
|
|
|
|
|
return saved_paths
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-08 10:51:30 +08:00
|
|
|
|
# 创建中间件实例,供其他模块使用
|
2026-02-02 21:44:10 +08:00
|
|
|
|
# 新的文件系统模式中间件:保存附件到文件系统,提示模型自主读取
|
|
|
|
|
|
save_attachments_to_fs = AttachmentMiddleware()
|
|
|
|
|
|
|
|
|
|
|
|
# 保留旧名称以保持向后兼容(已废弃)
|
|
|
|
|
|
inject_attachment_context = save_attachments_to_fs
|