feat(backend): 新增 MinIO 虚拟文件系统后端
实现 MinIOBackend 类,实现 BackendProtocol 接口: - 将 /attachments/* 路径映射到 MinIO 对象存储 - 支持 ls_info、read、write、edit、glob_info、grep_raw 等操作 - 提供 init_attachment_bucket() 初始化函数 后续提交将使用此后端替代本地文件系统存储附件。
This commit is contained in:
parent
796c804514
commit
fae17c3013
@ -1,8 +1,10 @@
|
|||||||
|
from deepagents.backends import CompositeBackend, StateBackend
|
||||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import ModelRetryMiddleware
|
from langchain.agents.middleware import ModelRetryMiddleware
|
||||||
|
|
||||||
from src.agents.common import BaseAgent, load_chat_model
|
from src.agents.common import BaseAgent, load_chat_model
|
||||||
|
from src.agents.common.backends.minio_backend import MinIOBackend
|
||||||
from src.agents.common.middlewares import (
|
from src.agents.common.middlewares import (
|
||||||
RuntimeConfigMiddleware,
|
RuntimeConfigMiddleware,
|
||||||
save_attachments_to_fs,
|
save_attachments_to_fs,
|
||||||
@ -10,6 +12,22 @@ from src.agents.common.middlewares import (
|
|||||||
from src.services.mcp_service import get_tools_from_all_servers
|
from src.services.mcp_service import get_tools_from_all_servers
|
||||||
|
|
||||||
|
|
||||||
|
def _create_fs_backend_factory(rt) -> CompositeBackend:
|
||||||
|
"""创建混合文件存储后端工厂函数(供 FilesystemMiddleware 使用)。
|
||||||
|
|
||||||
|
/attachments/* 路由到 MinIO(供附件中间件使用)
|
||||||
|
其他路径使用 StateBackend(内存存储,用于临时文件和大结果卸载)
|
||||||
|
|
||||||
|
注意:rt (runtime) 由 FilesystemMiddleware 在初始化时自动传入。
|
||||||
|
"""
|
||||||
|
return CompositeBackend(
|
||||||
|
default=StateBackend(rt), # 传入 runtime
|
||||||
|
routes={
|
||||||
|
"/attachments/": MinIOBackend(bucket_name="chat-attachments"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatbotAgent(BaseAgent):
|
class ChatbotAgent(BaseAgent):
|
||||||
name = "智能体助手"
|
name = "智能体助手"
|
||||||
description = "基础的对话机器人,可以回答问题,可在配置中启用需要的工具。"
|
description = "基础的对话机器人,可以回答问题,可在配置中启用需要的工具。"
|
||||||
@ -32,7 +50,7 @@ class ChatbotAgent(BaseAgent):
|
|||||||
system_prompt=context.system_prompt,
|
system_prompt=context.system_prompt,
|
||||||
middleware=[
|
middleware=[
|
||||||
save_attachments_to_fs, # 附件保存到文件系统
|
save_attachments_to_fs, # 附件保存到文件系统
|
||||||
FilesystemMiddleware(tool_token_limit_before_evict=5000),
|
FilesystemMiddleware(backend=_create_fs_backend_factory, tool_token_limit_before_evict=5000),
|
||||||
RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/知识库/MCP/提示词)
|
RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/知识库/MCP/提示词)
|
||||||
ModelRetryMiddleware(), # 模型重试中间件
|
ModelRetryMiddleware(), # 模型重试中间件
|
||||||
],
|
],
|
||||||
|
|||||||
5
src/agents/common/backends/__init__.py
Normal file
5
src/agents/common/backends/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""自定义文件系统后端模块"""
|
||||||
|
|
||||||
|
from src.agents.common.backends.minio_backend import MinIOBackend, init_attachment_bucket
|
||||||
|
|
||||||
|
__all__ = ["MinIOBackend", "init_attachment_bucket"]
|
||||||
300
src/agents/common/backends/minio_backend.py
Normal file
300
src/agents/common/backends/minio_backend.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"""MinIO 虚拟文件系统后端 - 实现 BackendProtocol 接口"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import BackendProtocol, EditResult, WriteResult
|
||||||
|
from deepagents.backends.utils import FileInfo, GrepMatch
|
||||||
|
|
||||||
|
from src.utils import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.storage.minio.client import MinIOClient
|
||||||
|
|
||||||
|
|
||||||
|
class MinIOBackend(BackendProtocol):
|
||||||
|
"""基于 MinIO 的虚拟文件系统后端,用于存储对话附件。
|
||||||
|
|
||||||
|
将 /attachments/{thread_id}/{file_id}.md 路径映射到 MinIO 的
|
||||||
|
attachments/{thread_id}/{file_id}.md 对象存储。
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 附件内容持久化存储在 MinIO
|
||||||
|
- 无需本地物理存储空间
|
||||||
|
- 支持 Agent 的 read_file 工具读取
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bucket_name: str = "chat-attachments",
|
||||||
|
minio_client: Optional[MinIOClient] = None,
|
||||||
|
):
|
||||||
|
self.bucket_name = bucket_name
|
||||||
|
self._client = minio_client
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> MinIOClient:
|
||||||
|
"""获取 MinIO 客户端实例"""
|
||||||
|
if self._client is None:
|
||||||
|
from src.storage.minio.client import MinIOClient
|
||||||
|
|
||||||
|
self._client = MinIOClient()
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _key(self, path: str) -> str:
|
||||||
|
"""将虚拟路径转换为 MinIO object key。
|
||||||
|
|
||||||
|
虚拟路径: /attachments/{thread_id}/{file_id}.md
|
||||||
|
MinIO key: attachments/{thread_id}/{file_id}.md
|
||||||
|
"""
|
||||||
|
return path.lstrip("/")
|
||||||
|
|
||||||
|
def _parse_path(self, path: str) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""解析路径,提取 thread_id 和 filename。
|
||||||
|
|
||||||
|
支持两种格式:
|
||||||
|
- 目录路径: /attachments/{thread_id}/
|
||||||
|
- 文件路径: /attachments/{thread_id}/{file_id}.md
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[thread_id, filename] 或 (thread_id, None) 对于目录
|
||||||
|
"""
|
||||||
|
# /attachments/{thread_id}/{file_id}.md -> parts = ['attachments', '{thread_id}', '{file_id}.md']
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
thread_id = parts[1]
|
||||||
|
filename = parts[2] if len(parts) >= 3 else None
|
||||||
|
return thread_id, filename
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _ensure_bucket_exists(self) -> None:
|
||||||
|
"""确保存储桶存在"""
|
||||||
|
self.client.ensure_bucket_exists(self.bucket_name)
|
||||||
|
|
||||||
|
def _ensure_attachments_prefix(self, path: str) -> str:
|
||||||
|
"""确保路径以 /attachments/ 前缀开头。
|
||||||
|
|
||||||
|
当此 backend 被 CompositeBackend 用于 /attachments/ 路由时,
|
||||||
|
CompositeBackend 会剥离前缀,需要在此处补全。
|
||||||
|
"""
|
||||||
|
if not path.startswith("/attachments/"):
|
||||||
|
return f"/attachments/{path.lstrip('/')}"
|
||||||
|
return path
|
||||||
|
|
||||||
|
# ========== BackendProtocol 接口实现 ==========
|
||||||
|
|
||||||
|
def ls_info(self, path: str) -> list[FileInfo]:
|
||||||
|
"""列出目录内容。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 虚拟路径,支持:
|
||||||
|
- / - 返回 /attachments/ 目录(当此 backend 用于 /attachments/ 路由时)
|
||||||
|
- /attachments/ - 列出所有 thread_id 目录
|
||||||
|
- /attachments/{thread_id}/ - 列出某个 thread 的附件
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileInfo 列表
|
||||||
|
"""
|
||||||
|
# 确保路径有 /attachments/ 前缀(CompositeBackend 可能会剥离)
|
||||||
|
path = self._ensure_attachments_prefix(path)
|
||||||
|
|
||||||
|
# 处理根目录 - 当 CompositeBackend 将 /attachments/ 路由转换为 / 时
|
||||||
|
if path == "/":
|
||||||
|
# 对于根目录,返回 /attachments/ 作为入口点
|
||||||
|
return [
|
||||||
|
FileInfo(
|
||||||
|
path="/attachments/",
|
||||||
|
is_dir=True,
|
||||||
|
size=0,
|
||||||
|
modified_at=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not path.startswith("/attachments/"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 解析路径
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if len(parts) == 1:
|
||||||
|
# /attachments/ - 列出所有 thread_id 目录
|
||||||
|
prefix = "attachments/"
|
||||||
|
try:
|
||||||
|
self._ensure_bucket_exists()
|
||||||
|
mc = self.client.client
|
||||||
|
result: list[FileInfo] = []
|
||||||
|
# 列出所有对象,按 thread_id 分组
|
||||||
|
all_objects = list(mc.list_objects(self.bucket_name, prefix=prefix, recursive=False))
|
||||||
|
seen_threads: set[str] = set()
|
||||||
|
for obj in all_objects:
|
||||||
|
# 从 attachments/{thread_id}/{file_id}.md 中提取 thread_id
|
||||||
|
obj_parts = obj.object_name.strip("/").split("/")
|
||||||
|
if len(obj_parts) >= 2:
|
||||||
|
thread_id = obj_parts[1]
|
||||||
|
if thread_id not in seen_threads:
|
||||||
|
seen_threads.add(thread_id)
|
||||||
|
# 创建目录条目(虚拟的,不是真实目录)
|
||||||
|
result.append(
|
||||||
|
FileInfo(
|
||||||
|
path=f"/attachments/{thread_id}/",
|
||||||
|
is_dir=True,
|
||||||
|
size=0,
|
||||||
|
modified_at=obj.last_modified.isoformat() if obj.last_modified else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MinIOBackend.ls_info failed for {path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# /attachments/{thread_id}/ - 列出某个 thread 的附件
|
||||||
|
thread_id, _ = self._parse_path(path)
|
||||||
|
if not thread_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prefix = f"attachments/{thread_id}/"
|
||||||
|
try:
|
||||||
|
self._ensure_bucket_exists()
|
||||||
|
mc = self.client.client
|
||||||
|
result: list[FileInfo] = []
|
||||||
|
objects = list(mc.list_objects(self.bucket_name, prefix=prefix, recursive=False))
|
||||||
|
for obj in objects:
|
||||||
|
file_path = f"/{obj.object_name}"
|
||||||
|
modified_at = obj.last_modified.isoformat() if obj.last_modified else None
|
||||||
|
result.append(
|
||||||
|
FileInfo(
|
||||||
|
path=file_path,
|
||||||
|
is_dir=False,
|
||||||
|
size=obj.size,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MinIOBackend.ls_info failed for {path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
||||||
|
"""读取文件内容。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 虚拟路径,如 /attachments/{thread_id}/{file_id}.md
|
||||||
|
offset: 起始行偏移
|
||||||
|
limit: 最大行数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
带行号的内容字符串
|
||||||
|
"""
|
||||||
|
# 确保路径有 /attachments/ 前缀(CompositeBackend 可能会剥离)
|
||||||
|
file_path = self._ensure_attachments_prefix(file_path)
|
||||||
|
|
||||||
|
if not file_path.startswith("/attachments/"):
|
||||||
|
return f"Error: Access denied to {file_path}"
|
||||||
|
|
||||||
|
_, filename = self._parse_path(file_path)
|
||||||
|
if not filename:
|
||||||
|
return f"Error: File not found: {file_path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = self.client.download_file(self.bucket_name, self._key(file_path))
|
||||||
|
content = data.decode("utf-8")
|
||||||
|
lines = content.split("\n")
|
||||||
|
# 添加行号
|
||||||
|
start = max(0, offset)
|
||||||
|
end = start + limit
|
||||||
|
numbered_lines = [f"{i+1}:{line}" for i, line in enumerate(lines[start:end], start + 1)]
|
||||||
|
return "\n".join(numbered_lines)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MinIOBackend.read failed for {file_path}: {e}")
|
||||||
|
return f"Error reading {file_path}: {e}"
|
||||||
|
|
||||||
|
def write(self, file_path: str, content: str) -> WriteResult:
|
||||||
|
"""写入文件。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 虚拟路径
|
||||||
|
content: 文件内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WriteResult
|
||||||
|
"""
|
||||||
|
# 确保路径有 /attachments/ 前缀
|
||||||
|
file_path = self._ensure_attachments_prefix(file_path)
|
||||||
|
|
||||||
|
if not file_path.startswith("/attachments/"):
|
||||||
|
return WriteResult(error=f"Access denied: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ensure_bucket_exists()
|
||||||
|
data = content.encode("utf-8")
|
||||||
|
self.client.upload_file(
|
||||||
|
self.bucket_name,
|
||||||
|
self._key(file_path),
|
||||||
|
data,
|
||||||
|
content_type="text/markdown",
|
||||||
|
)
|
||||||
|
logger.info(f"MinIOBackend: wrote {file_path}")
|
||||||
|
return WriteResult(path=file_path, files_update=None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MinIOBackend.write failed for {file_path}: {e}")
|
||||||
|
return WriteResult(error=str(e))
|
||||||
|
|
||||||
|
def edit(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
old_string: str,
|
||||||
|
new_string: str,
|
||||||
|
replace_all: bool = False,
|
||||||
|
) -> EditResult:
|
||||||
|
"""编辑文件。
|
||||||
|
|
||||||
|
MinIO 后端不支持原地编辑,返回错误提示。
|
||||||
|
"""
|
||||||
|
return EditResult(error="Edit not supported for MinIO backend")
|
||||||
|
|
||||||
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
||||||
|
"""glob 模式匹配。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: glob 模式,如 *.md
|
||||||
|
path: 搜索路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
匹配的 FileInfo 列表
|
||||||
|
"""
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
files = self.ls_info(path)
|
||||||
|
result: list[FileInfo] = []
|
||||||
|
for f in files:
|
||||||
|
if fnmatch.fnmatch(f.path, pattern):
|
||||||
|
result.append(f)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def grep_raw(
|
||||||
|
self,
|
||||||
|
pattern: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
glob: Optional[str] = None,
|
||||||
|
) -> list[GrepMatch] | str:
|
||||||
|
"""搜索文件内容。
|
||||||
|
|
||||||
|
当前实现为简化版本,仅列出文件不进行实际搜索。
|
||||||
|
"""
|
||||||
|
# 简化实现:暂不支持内容搜索
|
||||||
|
logger.debug(f"MinIOBackend.grep_raw called with pattern={pattern}, path={path}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def init_attachment_bucket() -> None:
|
||||||
|
"""初始化附件存储桶。
|
||||||
|
|
||||||
|
在应用启动时调用,确保 chat-attachments 存储桶存在。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = MinIOBackend()
|
||||||
|
client._ensure_bucket_exists()
|
||||||
|
logger.info("chat-attachments bucket initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize attachment bucket: {e}")
|
||||||
@ -1,24 +1,21 @@
|
|||||||
"""附件注入中间件 - 使用 LangChain 标准中间件实现
|
"""附件注入中间件 - 使用 LangChain 标准中间件实现
|
||||||
|
|
||||||
支持两种模式:
|
支持两种模式:
|
||||||
1. 文件系统模式(默认):将附件保存到 /attachments/{thread_id}/,提示模型自主读取
|
1. MinIO 模式(默认):将附件保存到 MinIO 存储,提示模型自主读取
|
||||||
2. 内嵌模式(已废弃):将附件内容直接拼接到消息中
|
2. 文件系统模式(已废弃):将附件保存到本地文件系统
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from pathlib import Path
|
|
||||||
from typing import NotRequired
|
from typing import NotRequired
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
||||||
|
|
||||||
|
from src.agents.common.backends.minio_backend import MinIOBackend
|
||||||
from src.utils import logger
|
from src.utils import logger
|
||||||
|
|
||||||
# 附件存储根目录
|
|
||||||
ATTACHMENTS_ROOT = Path("attachments")
|
|
||||||
|
|
||||||
|
|
||||||
class AttachmentState(AgentState):
|
class AttachmentState(AgentState):
|
||||||
"""扩展 AgentState 以支持附件"""
|
"""扩展 AgentState 以支持附件"""
|
||||||
@ -77,12 +74,89 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
|||||||
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
|
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
|
||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
# Read from State: get uploaded files metadata
|
# Read from State: get uploaded files metadata
|
||||||
|
# 首先尝试从 state 获取,如果为空则从 input_context 获取
|
||||||
attachments = request.state.get("attachments", [])
|
attachments = request.state.get("attachments", [])
|
||||||
|
|
||||||
if attachments:
|
# 如果 state 中没有,尝试从 input_context 获取
|
||||||
# Get thread_id from input_context
|
if not attachments:
|
||||||
input_context = request.state.get("input_context", {})
|
input_context = request.state.get("input_context", {})
|
||||||
thread_id = input_context.get("thread_id")
|
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:
|
||||||
|
# 检查是否有其他方式传递的附件
|
||||||
|
logger.info(f"AttachmentMiddleware: checking for attachments in other locations...")
|
||||||
|
# 输入可能直接在 state 中
|
||||||
|
logger.info(f"AttachmentMiddleware: state type = {type(request.state)}")
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
# Get thread_id - 尝试从多个来源获取
|
||||||
|
thread_id = None
|
||||||
|
|
||||||
|
# 0. 尝试从 request.runtime 获取(LangChain runtime)
|
||||||
|
if hasattr(request, 'runtime') and request.runtime:
|
||||||
|
runtime = request.runtime
|
||||||
|
logger.info(f"AttachmentMiddleware: runtime type = {type(runtime)}")
|
||||||
|
logger.info(f"AttachmentMiddleware: runtime attrs = {[a for a in dir(runtime) if not a.startswith('_')]}")
|
||||||
|
|
||||||
|
# 检查 runtime.context
|
||||||
|
if hasattr(runtime, 'context') and runtime.context:
|
||||||
|
ctx = runtime.context
|
||||||
|
logger.info(f"AttachmentMiddleware: runtime.context type = {type(ctx)}")
|
||||||
|
# 如果是 Pydantic 模型,使用 model_dump()
|
||||||
|
if hasattr(ctx, 'model_dump'):
|
||||||
|
ctx_dict = ctx.model_dump()
|
||||||
|
logger.info(f"AttachmentMiddleware: runtime.context keys = {list(ctx_dict.keys())}")
|
||||||
|
thread_id = ctx_dict.get("thread_id")
|
||||||
|
elif hasattr(ctx, '__dict__'):
|
||||||
|
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:
|
||||||
|
for attr in ['state', 'config', 'configurable']:
|
||||||
|
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")
|
||||||
|
elif hasattr(val, 'get'):
|
||||||
|
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")
|
||||||
|
elif hasattr(input_context, 'thread_id'):
|
||||||
|
thread_id = getattr(input_context, 'thread_id', None)
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -97,7 +171,7 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
|||||||
attachment_prompt = _build_attachment_prompt(attachments, thread_id)
|
attachment_prompt = _build_attachment_prompt(attachments, thread_id)
|
||||||
|
|
||||||
if attachment_prompt:
|
if attachment_prompt:
|
||||||
logger.debug(f"Saved {len(attachment_paths)} attachments to /attachments/{thread_id}/")
|
logger.info(f"Saved {len(attachment_paths)} attachments to /attachments/{thread_id}/")
|
||||||
|
|
||||||
messages = list(request.messages)
|
messages = list(request.messages)
|
||||||
insert_idx = 0
|
insert_idx = 0
|
||||||
@ -120,16 +194,17 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
|||||||
|
|
||||||
|
|
||||||
async def _save_attachments_to_fs(attachments: Sequence[dict], thread_id: str) -> list[str]:
|
async def _save_attachments_to_fs(attachments: Sequence[dict], thread_id: str) -> list[str]:
|
||||||
"""Save attachment markdown content to filesystem.
|
"""Save attachment markdown content to MinIO using MinIOBackend.
|
||||||
|
|
||||||
保存路径: /attachments/{thread_id}/{file_id}.md
|
保存路径: /attachments/{thread_id}/{original_file_name}.md (使用原始文件名)
|
||||||
|
实际存储: attachments/{thread_id}/{original_file_name}.md (MinIO key)
|
||||||
|
|
||||||
|
使用 MinIOBackend 确保 read_file 工具能够读取这些文件。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list of saved file paths
|
list of saved file paths (relative to /attachments/)
|
||||||
"""
|
"""
|
||||||
# Ensure directory exists
|
backend = MinIOBackend(bucket_name="chat-attachments")
|
||||||
thread_dir = ATTACHMENTS_ROOT / thread_id
|
|
||||||
thread_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
saved_paths: list[str] = []
|
saved_paths: list[str] = []
|
||||||
|
|
||||||
@ -138,15 +213,21 @@ async def _save_attachments_to_fs(attachments: Sequence[dict], thread_id: str) -
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
file_id = attachment.get("file_id")
|
file_id = attachment.get("file_id")
|
||||||
|
file_name = attachment.get("file_name")
|
||||||
markdown = attachment.get("markdown")
|
markdown = attachment.get("markdown")
|
||||||
|
|
||||||
if not file_id or not markdown:
|
if not file_id or not file_name or not markdown:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_path = thread_dir / f"{file_id}.md"
|
# 确保文件名安全:移除路径分隔符,保留原始扩展名
|
||||||
file_path.write_text(markdown, encoding="utf-8")
|
safe_file_name = file_name.replace("/", "_").replace("\\", "_")
|
||||||
saved_paths.append(str(file_path))
|
file_path = f"/attachments/{thread_id}/{safe_file_name}"
|
||||||
logger.debug(f"Saved attachment to {file_path}")
|
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}")
|
||||||
|
|
||||||
return saved_paths
|
return saved_paths
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Deep Agent - 基于create_deep_agent的深度分析智能体"""
|
"""Deep Agent - 基于create_deep_agent的深度分析智能体"""
|
||||||
|
|
||||||
|
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
|
||||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||||
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
||||||
from deepagents.middleware.subagents import SubAgentMiddleware
|
from deepagents.middleware.subagents import SubAgentMiddleware
|
||||||
@ -20,6 +21,23 @@ from src.services.mcp_service import get_tools_from_all_servers
|
|||||||
from .context import DeepContext
|
from .context import DeepContext
|
||||||
|
|
||||||
|
|
||||||
|
def _create_filesystem_backend_factory() -> CompositeBackend:
|
||||||
|
"""创建混合文件存储后端工厂函数。
|
||||||
|
|
||||||
|
/attachments/* 路由到真实文件系统(供附件中间件使用)
|
||||||
|
其他路径使用 StateBackend(内存存储,用于临时文件和大结果卸载)
|
||||||
|
|
||||||
|
返回 CompositeBackend 实例,供 FilesystemMiddleware 使用。
|
||||||
|
注意:StateBackend 会在 middleware 初始化时由 runtime 注入。
|
||||||
|
"""
|
||||||
|
return CompositeBackend(
|
||||||
|
default=StateBackend, # 使用类而非实例,FilesystemMiddleware 会自动注入 runtime
|
||||||
|
routes={
|
||||||
|
"/attachments/": FilesystemBackend(root_dir=".", virtual_mode=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_research_sub_agent(search_tools: list) -> dict:
|
def _get_research_sub_agent(search_tools: list) -> dict:
|
||||||
"""Get research sub-agent config with search tools."""
|
"""Get research sub-agent config with search tools."""
|
||||||
return {
|
return {
|
||||||
@ -101,6 +119,7 @@ class DeepAgent(BaseAgent):
|
|||||||
research_sub_agent = _get_research_sub_agent(search_tools)
|
research_sub_agent = _get_research_sub_agent(search_tools)
|
||||||
|
|
||||||
# 使用 create_deep_agent 创建深度智能体
|
# 使用 create_deep_agent 创建深度智能体
|
||||||
|
fs_backend_factory = _create_filesystem_backend_factory()
|
||||||
graph = create_agent(
|
graph = create_agent(
|
||||||
model=model,
|
model=model,
|
||||||
system_prompt=context.system_prompt,
|
system_prompt=context.system_prompt,
|
||||||
@ -108,13 +127,13 @@ class DeepAgent(BaseAgent):
|
|||||||
save_attachments_to_fs, # 附件保存到文件系统
|
save_attachments_to_fs, # 附件保存到文件系统
|
||||||
RuntimeConfigMiddleware(extra_tools=all_mcp_tools),
|
RuntimeConfigMiddleware(extra_tools=all_mcp_tools),
|
||||||
TodoListMiddleware(),
|
TodoListMiddleware(),
|
||||||
FilesystemMiddleware(tool_token_limit_before_evict=5000),
|
FilesystemMiddleware(backend=fs_backend_factory, tool_token_limit_before_evict=5000),
|
||||||
SubAgentMiddleware(
|
SubAgentMiddleware(
|
||||||
default_model=sub_model,
|
default_model=sub_model,
|
||||||
default_tools=search_tools,
|
default_tools=search_tools,
|
||||||
subagents=[critique_sub_agent, research_sub_agent],
|
subagents=[critique_sub_agent, research_sub_agent],
|
||||||
default_middleware=[
|
default_middleware=[
|
||||||
FilesystemMiddleware(),
|
FilesystemMiddleware(backend=fs_backend_factory),
|
||||||
RuntimeConfigMiddleware(
|
RuntimeConfigMiddleware(
|
||||||
model_context_name="subagents_model",
|
model_context_name="subagents_model",
|
||||||
enable_model_override=True,
|
enable_model_override=True,
|
||||||
|
|||||||
@ -1208,6 +1208,8 @@ watch(
|
|||||||
flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
min-width: 0; /* Prevent flex item from overflowing */
|
min-width: 0; /* Prevent flex item from overflowing */
|
||||||
|
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-panel-wrapper {
|
.agent-panel-wrapper {
|
||||||
|
|||||||
@ -91,7 +91,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #title="{ data }">
|
<template #title="{ data }">
|
||||||
<div class="tree-node-wrapper">
|
<div class="tree-node-wrapper" @click="toggleFolder(data)">
|
||||||
<div class="tree-node-name" :title="data.title">
|
<div class="tree-node-name" :title="data.title">
|
||||||
<span class="name-start">{{ data.nameStart || data.title }}</span>
|
<span class="name-start">{{ data.nameStart || data.title }}</span>
|
||||||
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
|
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
|
||||||
@ -159,7 +159,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #title="{ data }">
|
<template #title="{ data }">
|
||||||
<div class="tree-node-wrapper">
|
<div class="tree-node-wrapper" @click="toggleFolder(data)">
|
||||||
<div class="tree-node-name" :title="data.title">
|
<div class="tree-node-name" :title="data.title">
|
||||||
<span class="name-start">{{ data.nameStart || data.title }}</span>
|
<span class="name-start">{{ data.nameStart || data.title }}</span>
|
||||||
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
|
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
|
||||||
@ -484,19 +484,22 @@ const truncateFilename = (name) => {
|
|||||||
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
|
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
|
||||||
const attachmentTreeData = computed(() => buildTreeData(normalizedAttachments.value))
|
const attachmentTreeData = computed(() => buildTreeData(normalizedAttachments.value))
|
||||||
|
|
||||||
|
const toggleFolder = (data) => {
|
||||||
|
if (data.isLeaf) return
|
||||||
|
const key = data.key
|
||||||
|
const index = expandedKeys.value.indexOf(key)
|
||||||
|
if (index > -1) {
|
||||||
|
expandedKeys.value = expandedKeys.value.filter((k) => k !== key)
|
||||||
|
} else {
|
||||||
|
expandedKeys.value = [...expandedKeys.value, key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onFileSelect = (selectedKeys, { node }) => {
|
const onFileSelect = (selectedKeys, { node }) => {
|
||||||
if (node.isLeaf) {
|
if (node.isLeaf) {
|
||||||
if (node.fileData) {
|
if (node.fileData) {
|
||||||
showFileContent(node.key, node.fileData)
|
showFileContent(node.key, node.fileData)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 切换文件夹展开状态
|
|
||||||
const index = expandedKeys.value.indexOf(node.key)
|
|
||||||
if (index > -1) {
|
|
||||||
expandedKeys.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
expandedKeys.value.push(node.key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1044,7 +1047,7 @@ const stopResize = () => {
|
|||||||
/* File Tree Styles - VS Code Style Refined */
|
/* File Tree Styles - VS Code Style Refined */
|
||||||
.file-tree-container {
|
.file-tree-container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 -12px; /* Slight negative margin */
|
margin: 0 -6px; /* Slight negative margin */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node-wrapper {
|
.tree-node-wrapper {
|
||||||
@ -1062,7 +1065,7 @@ const stopResize = () => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0; /* Important for flex child truncation */
|
min-width: 0; /* Important for flex child truncation */
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--gray-800);
|
color: var(--gray-800);
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
@ -1116,7 +1119,7 @@ const stopResize = () => {
|
|||||||
.file-tree-container :deep(.ant-tree) {
|
.file-tree-container :deep(.ant-tree) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
|
||||||
.ant-tree-treenode {
|
.ant-tree-treenode {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -1129,13 +1132,12 @@ const stopResize = () => {
|
|||||||
transition: none;
|
transition: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0 8px 0 0;
|
padding: 0 8px 0 0;
|
||||||
min-height: 28px;
|
|
||||||
height: 28px;
|
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 4px;
|
padding: 0 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--gray-50);
|
background-color: var(--gray-50);
|
||||||
@ -1152,8 +1154,8 @@ const stopResize = () => {
|
|||||||
|
|
||||||
/* Icon Vertical Alignment */
|
/* Icon Vertical Alignment */
|
||||||
.ant-tree-iconEle {
|
.ant-tree-iconEle {
|
||||||
line-height: 28px;
|
line-height: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -1170,8 +1172,8 @@ const stopResize = () => {
|
|||||||
/* Switcher (Arrow) */
|
/* Switcher (Arrow) */
|
||||||
.ant-tree-switcher {
|
.ant-tree-switcher {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
line-height: 28px;
|
line-height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user