ForcePilot/src/agents/common/backends/minio_backend.py
2026-02-03 14:55:21 +08:00

313 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MinIO 虚拟文件系统后端 - 实现 BackendProtocol 接口"""
from __future__ import annotations
from typing import TYPE_CHECKING
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: MinIOClient | None = 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[str | None, str | None]:
"""解析路径,提取 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))
async def awrite(self, file_path: str, content: str) -> WriteResult:
"""异步写入文件。
Args:
file_path: 虚拟路径
content: 文件内容
Returns:
WriteResult
"""
# 委托给同步 write 方法
return self.write(file_path, content)
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: str | None = None,
glob: str | None = 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}")