diff --git a/src/agents/chatbot/graph.py b/src/agents/chatbot/graph.py index 68c45982..0e4ee619 100644 --- a/src/agents/chatbot/graph.py +++ b/src/agents/chatbot/graph.py @@ -1,10 +1,10 @@ -from deepagents.backends import CompositeBackend, StateBackend -from deepagents.middleware.filesystem import FilesystemMiddleware from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware +from deepagents.backends import StateBackend +from deepagents.middleware.filesystem import FilesystemMiddleware + from src.agents.common import BaseAgent, load_chat_model -from src.agents.common.backends.minio_backend import MinIOBackend from src.agents.common.middlewares import ( RuntimeConfigMiddleware, save_attachments_to_fs, @@ -12,20 +12,9 @@ from src.agents.common.middlewares import ( 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"), - }, - ) +def _create_fs_backend(rt): + """创建文件存储后端""" + return StateBackend(rt) class ChatbotAgent(BaseAgent): @@ -49,8 +38,8 @@ class ChatbotAgent(BaseAgent): model=load_chat_model(context.model), system_prompt=context.system_prompt, middleware=[ - save_attachments_to_fs, # 附件保存到文件系统 - FilesystemMiddleware(backend=_create_fs_backend_factory, tool_token_limit_before_evict=5000), + save_attachments_to_fs, # 附件注入提示词 + FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端 RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/知识库/MCP/提示词) ModelRetryMiddleware(), # 模型重试中间件 ], diff --git a/src/agents/common/backends/__init__.py b/src/agents/common/backends/__init__.py deleted file mode 100644 index d2fdd50b..00000000 --- a/src/agents/common/backends/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""自定义文件系统后端模块""" - -from src.agents.common.backends.minio_backend import MinIOBackend, init_attachment_bucket - -__all__ = ["MinIOBackend", "init_attachment_bucket"] diff --git a/src/agents/common/backends/minio_backend.py b/src/agents/common/backends/minio_backend.py deleted file mode 100644 index f08e6f02..00000000 --- a/src/agents/common/backends/minio_backend.py +++ /dev/null @@ -1,312 +0,0 @@ -"""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}") diff --git a/src/agents/common/base.py b/src/agents/common/base.py index 90e86475..fe341801 100644 --- a/src/agents/common/base.py +++ b/src/agents/common/base.py @@ -79,17 +79,15 @@ class BaseAgent: context.update(agent_config) context.update(input_context or {}) logger.debug(f"stream_messages: {context}") - # TODO Checkpointer 似乎还没有适配最新的 1.0 Context API - # 从 input_context 中提取 attachments(如果有) - attachments = (input_context or {}).get("attachments", []) + # 构建配置:LangGraph 会自动从 checkpointer 恢复 state input_config = { "configurable": {"thread_id": context.thread_id, "user_id": context.user_id}, "recursion_limit": 300, } async for msg, metadata in graph.astream( - {"messages": messages, "attachments": attachments}, + {"messages": messages}, stream_mode="messages", context=context, config=input_config, @@ -105,15 +103,16 @@ class BaseAgent: context.update(input_context or {}) logger.debug(f"invoke_messages: {context}") - # 从 input_context 中提取 attachments(如果有) - attachments = (input_context or {}).get("attachments", []) + # 构建配置 input_config = { "configurable": {"thread_id": context.thread_id, "user_id": context.user_id}, "recursion_limit": 100, } msg = await graph.ainvoke( - {"messages": messages, "attachments": attachments}, context=context, config=input_config + {"messages": messages}, + context=context, + config=input_config, ) return msg diff --git a/src/agents/common/middlewares/attachment_middleware.py b/src/agents/common/middlewares/attachment_middleware.py index 6f6f1c2e..5c6136e8 100644 --- a/src/agents/common/middlewares/attachment_middleware.py +++ b/src/agents/common/middlewares/attachment_middleware.py @@ -1,8 +1,6 @@ """附件注入中间件 - 使用 LangChain 标准中间件实现 -支持两种模式: -1. MinIO 模式(默认):将附件保存到 MinIO 存储,提示模型自主读取 -2. 文件系统模式(已废弃):将附件保存到本地文件系统 +从 State 中读取附件信息,注入提示词让模型使用 read_file 工具读取附件内容。 """ from __future__ import annotations @@ -13,7 +11,6 @@ from typing import NotRequired from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse -from src.agents.common.backends.minio_backend import MinIOBackend from src.utils import logger @@ -21,9 +18,10 @@ class AttachmentState(AgentState): """扩展 AgentState 以支持附件""" attachments: NotRequired[list[dict]] + files: NotRequired[dict[str, str]] # {"/attachments/xxx/file.md": content} -def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str | None: +def _build_attachment_prompt(attachments: Sequence[dict]) -> str | None: """Render attachments into a system prompt block with file paths. 提示模型使用 read_file 工具读取附件内容。 @@ -31,26 +29,28 @@ def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str if not attachments: return None - valid_attachments = [a for a in attachments if a.get("status") == "parsed" and a.get("markdown")] + valid_attachments = [a for a in attachments if a.get("status") == "parsed"] if not valid_attachments: return None attachment_infos: list[str] = [] - for idx, attachment in enumerate(valid_attachments, 1): - file_id = attachment.get("file_id", f"file_{idx}") - file_name = attachment.get("file_name") or f"附件 {idx}" + for attachment in valid_attachments: + file_name = attachment.get("file_name", "未知文件") + file_path = attachment.get("file_path", "") truncated = "(已截断)" if attachment.get("truncated") else "" - file_path = f"{thread_id}/{file_id}.md" - attachment_infos.append(f"- {file_name}{truncated}: /attachments/{file_path}") + if file_path: + attachment_infos.append(f"- {file_name}{truncated}: {file_path}") + else: + attachment_infos.append(f"- {file_name}{truncated}") lines = [ - "用户上传了以下附件,已保存到文件系统中:", + "用户上传了以下附件:", "", *attachment_infos, "", - "请使用 read_file 工具读取附件内容后,再回答用户的问题。如果附件与问题无关,可以忽略附件内容。", + "请使用 read_file 工具读取附件内容后,再回答用户的问题。", ] return "\n".join(lines) @@ -58,14 +58,10 @@ def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str class AttachmentMiddleware(AgentMiddleware[AttachmentState]): """ - LangChain 标准中间件:从 State 中读取附件并注入到消息中。 + LangChain 标准中间件:从 State 中读取附件并注入提示词。 - 根据官方文档示例: - https://docs.langchain.com/oss/python/langchain/middleware - - 从 request.state 中读取 attachments,将其转换为 SystemMessage 并注入到消息列表开头。 - - NOTE: 缺点是无法命中缓存了 + LangGraph 会自动从 checkpointer 恢复 state,包括 attachments。 + 从 request.state 中读取附件,将其转换为 SystemMessage 并注入到消息列表开头。 """ state_schema = AttachmentState @@ -73,107 +69,16 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]): async def awrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse] ) -> ModelResponse: - # Read from State: get uploaded files metadata - # 首先尝试从 state 获取,如果为空则从 input_context 获取 + # 从 state 获取附件(LangGraph 自动从 checkpointer 恢复) attachments = request.state.get("attachments", []) - - # 如果 state 中没有,尝试从 input_context 获取 - if not attachments: - input_context = request.state.get("input_context", {}) - 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("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: - 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) + # 构建附件提示 + attachment_prompt = _build_attachment_prompt(attachments) if attachment_prompt: - logger.info(f"Saved {len(attachment_paths)} attachments to /attachments/{thread_id}/") + logger.info("AttachmentMiddleware: injecting attachment prompt") messages = list(request.messages) insert_idx = 0 @@ -195,47 +100,7 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]): return await handler(request) -async def _save_attachments_to_fs(attachments: Sequence[dict], thread_id: str) -> list[str]: - """Save attachment markdown content to MinIO using MinIOBackend. - - 保存路径: /attachments/{thread_id}/{original_file_name}.md (使用原始文件名) - 实际存储: attachments/{thread_id}/{original_file_name}.md (MinIO key) - - 使用 MinIOBackend 确保 read_file 工具能够读取这些文件。 - - Returns: - list of saved file paths (relative to /attachments/) - """ - backend = MinIOBackend(bucket_name="chat-attachments") - - saved_paths: list[str] = [] - - for attachment in attachments: - if attachment.get("status") != "parsed": - continue - - file_id = attachment.get("file_id") - file_name = attachment.get("file_name") - markdown = attachment.get("markdown") - - if not file_id or not file_name or not markdown: - continue - - # 确保文件名安全:移除路径分隔符,保留原始扩展名 - 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}") - - return saved_paths - - # 创建中间件实例,供其他模块使用 -# 新的文件系统模式中间件:保存附件到文件系统,提示模型自主读取 save_attachments_to_fs = AttachmentMiddleware() # 保留旧名称以保持向后兼容(已废弃) diff --git a/src/agents/deep_agent/graph.py b/src/agents/deep_agent/graph.py index 305140aa..b3b6af01 100644 --- a/src/agents/deep_agent/graph.py +++ b/src/agents/deep_agent/graph.py @@ -1,6 +1,6 @@ """Deep Agent - 基于create_deep_agent的深度分析智能体""" -from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend +from deepagents.backends import StateBackend from deepagents.middleware.filesystem import FilesystemMiddleware from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from deepagents.middleware.subagents import SubAgentMiddleware @@ -10,31 +10,16 @@ from langchain.agents.middleware import ( ) from src.agents.common import BaseAgent, load_chat_model -from src.agents.common.middlewares import ( - RuntimeConfigMiddleware, - SummaryOffloadMiddleware, - save_attachments_to_fs, -) +from src.agents.common.middlewares import RuntimeConfigMiddleware, SummaryOffloadMiddleware, save_attachments_to_fs from src.agents.common.tools import get_tavily_search from src.services.mcp_service import get_tools_from_all_servers from .context import DeepContext -def _create_filesystem_backend_factory(rt) -> CompositeBackend: - """创建混合文件存储后端工厂函数(供 FilesystemMiddleware 使用)。 - - /attachments/* 路由到真实文件系统(供附件中间件使用) - 其他路径使用 StateBackend(内存存储,用于临时文件和大结果卸载) - - 注意:rt (runtime) 由 FilesystemMiddleware 在调用时自动传入。 - """ - return CompositeBackend( - default=StateBackend(rt), # 传入 runtime 创建实例 - routes={ - "/attachments/": FilesystemBackend(root_dir=".", virtual_mode=False), - }, - ) +def _create_fs_backend(rt): + """创建文件存储后端""" + return StateBackend(rt) def _get_research_sub_agent(search_tools: list) -> dict: @@ -130,7 +115,6 @@ class DeepAgent(BaseAgent): default_tools=search_tools, subagents=[critique_sub_agent, research_sub_agent], default_middleware=[ - FilesystemMiddleware(backend=_create_filesystem_backend_factory), RuntimeConfigMiddleware( model_context_name="subagents_model", enable_model_override=True, @@ -148,9 +132,9 @@ class DeepAgent(BaseAgent): model=model, system_prompt=context.system_prompt, middleware=[ - FilesystemMiddleware(backend=_create_filesystem_backend_factory), + FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端 RuntimeConfigMiddleware(extra_tools=all_mcp_tools), - save_attachments_to_fs, # 附件保存到文件系统 + save_attachments_to_fs, # 附件注入提示词 TodoListMiddleware(), PatchToolCallsMiddleware(), subagents_middleware, diff --git a/src/services/chat_stream_service.py b/src/services/chat_stream_service.py index 420fdac1..49e4be61 100644 --- a/src/services/chat_stream_service.py +++ b/src/services/chat_stream_service.py @@ -3,6 +3,7 @@ import json import traceback import uuid from collections.abc import AsyncIterator +from datetime import datetime from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langgraph.types import Command @@ -17,6 +18,41 @@ from src.storage.postgres.manager import pg_manager from src.utils.logging_config import logger +def _build_state_files(attachments: list[dict]) -> dict: + """将附件列表转换为 StateBackend 格式的 files 字典 + + StateBackend 期望的格式: + { + "/attachments/file.md": { + "content": ["line1", "line2", ...], + "created_at": "...", + "modified_at": "...", + } + } + """ + files = {} + for attachment in attachments: + if attachment.get("status") != "parsed": + continue + + file_path = attachment.get("file_path") + markdown = attachment.get("markdown") + + if not file_path or not markdown: + continue + + now = datetime.utcnow().isoformat() + "+00:00" + # 将 markdown 内容按行拆分 + content_lines = markdown.split("\n") + files[file_path] = { + "content": content_lines, + "created_at": attachment.get("uploaded_at", now), + "modified_at": attachment.get("uploaded_at", now), + } + + return files + + async def _get_langgraph_messages(agent_instance, config_dict): graph = await agent_instance.get_graph() state = await graph.aget_state(config_dict) @@ -29,20 +65,16 @@ async def _get_langgraph_messages(agent_instance, config_dict): def extract_agent_state(values: dict) -> dict: + """从 LangGraph state 中提取 agent 状态""" if not isinstance(values, dict): return {} - def _norm_list(v): - if v is None: - return [] - if isinstance(v, (list, tuple)): - return list(v) - return [v] - - result = {} - print(f"values.keys(): {values.keys()}") - result["todos"] = _norm_list(values.get("todos"))[:20] - result["files"] = _norm_list(values.get("files"))[:50] + # 直接获取,信任 state 的数据结构 + todos = values.get("todos") + result = { + "todos": list(todos)[:20] if todos else [], + "files": values.get("files") or {}, + } return result @@ -324,13 +356,11 @@ async def stream_agent_chat( except Exception as e: logger.error(f"Error saving user message: {e}") - try: - assert thread_id, "thread_id is required" - attachments = await conv_repo.get_attachments_by_thread_id(thread_id) - input_context["attachments"] = attachments - except Exception as e: - logger.error(f"Error loading attachments for thread_id={thread_id}: {e}") - input_context["attachments"] = [] + # 先构建 langgraph_config + langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}} + + # 注意:LangGraph 会自动从 checkpointer 恢复 state(包括 attachments 和 files) + # 无需手动加载或传递 # 根据用户权限过滤知识库 requested_knowledge_names = input_context["agent_config"].get("knowledges") @@ -353,7 +383,6 @@ async def stream_agent_chat( full_msg = None accumulated_content = [] - langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}} async for msg, metadata in agent.stream_messages(messages, input_context=input_context): if isinstance(msg, AIMessageChunk): accumulated_content.append(msg.content) @@ -618,12 +647,23 @@ async def get_agent_state_view( state = await graph.aget_state(langgraph_config) agent_state = extract_agent_state(getattr(state, "values", {})) if state else {} - # 获取附件 - try: - attachments = await conv_repo.get_attachments_by_thread_id(thread_id) - agent_state["attachments"] = attachments - except Exception as e: - logger.warning(f"Failed to fetch attachments for thread {thread_id}: {e}") - agent_state["attachments"] = [] + # 如果 state 中没有 files,从附件构建 + # 这确保了上传附件后立即可以在文件列表中看到文件 + if not agent_state.get("files") or agent_state["files"] == {}: + try: + attachments = await conv_repo.get_attachments_by_thread_id(thread_id) + logger.info(f"[get_agent_state_view] found {len(attachments)} attachments in DB") + if attachments: + first_status = attachments[0].get("status") + first_has_markdown = bool(attachments[0].get("markdown")) + logger.info( + f"[get_agent_state_view] first attachment status: {first_status}, " + f"has markdown: {first_has_markdown}" + ) + files = _build_state_files(attachments) + agent_state["files"] = files + logger.info(f"[get_agent_state_view] Built files from attachments: {len(files)} files") + except Exception as e: + logger.warning(f"Failed to fetch attachments for thread {thread_id}: {e}") return {"agent_state": agent_state} diff --git a/src/services/conversation_service.py b/src/services/conversation_service.py index af23a364..5e7cabdb 100644 --- a/src/services/conversation_service.py +++ b/src/services/conversation_service.py @@ -9,9 +9,13 @@ from src.services.doc_converter import ( MAX_ATTACHMENT_SIZE_BYTES, convert_upload_to_markdown, ) +from src.storage.minio.client import get_minio_client from src.utils.datetime_utils import utc_isoformat from src.utils.logging_config import logger +# 附件存储桶名称 +ATTACHMENTS_BUCKET = "chat-attachments" + async def require_user_conversation(conv_repo: ConversationRepository, thread_id: str, user_id: str): conversation = await conv_repo.get_conversation_by_thread_id(thread_id) @@ -20,7 +24,25 @@ async def require_user_conversation(conv_repo: ConversationRepository, thread_id return conversation +def _make_attachment_path(file_name: str) -> str: + """生成附件在文件系统中的路径(无需 thread_id,state 已隔离) + + 统一使用 .md 扩展名,因为文件内容已经是 Markdown 格式 + """ + # 提取不带扩展名的部分 + base_name = file_name + for ext in ['.docx', '.txt', '.html', '.htm', '.pdf', '.md']: + if file_name.lower().endswith(ext): + base_name = file_name[:-len(ext)] + break + + # 替换路径分隔符 + safe_name = base_name.replace("/", "_").replace("\\", "_") + return f"/attachments/{safe_name}.md" + + def serialize_attachment(record: dict) -> dict: + """序列化附件记录,返回给前端""" return { "file_id": record.get("file_id"), "file_name": record.get("file_name"), @@ -29,6 +51,7 @@ def serialize_attachment(record: dict) -> dict: "status": record.get("status", "parsed"), "uploaded_at": record.get("uploaded_at"), "truncated": record.get("truncated", False), + "minio_url": record.get("minio_url"), # 仅用于前端下载 } @@ -143,6 +166,28 @@ async def upload_thread_attachment_view( logger.error(f"附件解析失败: {exc}") raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from exc + # 生成文件路径 + file_path = _make_attachment_path(conversion.file_name) + + # 上传源文件到 MinIO(用于前端下载) + minio_url = None + try: + file_content = await file.read() + await file.seek(0) + client = get_minio_client() + object_name = f"attachments/{thread_id}/{conversion.file_name}" + result = client.upload_file( + bucket_name=ATTACHMENTS_BUCKET, + object_name=object_name, + data=file_content, + content_type=conversion.file_type or "application/octet-stream", + ) + minio_url = result.public_url + logger.info(f"Uploaded attachment to MinIO: {object_name}") + except Exception as e: + logger.error(f"Failed to upload attachment to MinIO: {e}") + # 继续处理,不因为上传失败而中断 + attachment_record = { "file_id": conversion.file_id, "file_name": conversion.file_name, @@ -152,6 +197,8 @@ async def upload_thread_attachment_view( "markdown": conversion.markdown, "uploaded_at": utc_isoformat(), "truncated": conversion.truncated, + "file_path": file_path, # 用于 StateBackend,前端不返回此字段 + "minio_url": minio_url, } await conv_repo.add_attachment(conversation.id, attachment_record) diff --git a/src/services/doc_converter.py b/src/services/doc_converter.py index 00c4637a..4b0c60f3 100644 --- a/src/services/doc_converter.py +++ b/src/services/doc_converter.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import uuid from dataclasses import dataclass from pathlib import Path @@ -94,7 +93,3 @@ async def convert_upload_to_markdown(upload: UploadFile) -> ConversionResult: except Exception as exc: # noqa: BLE001 logger.error("Attachment conversion failed: %s", exc) raise - finally: - # Remove the temp file in a thread to avoid blocking the event loop - if temp_path.exists(): - await asyncio.to_thread(temp_path.unlink) diff --git a/test/api/test_attachment_and_agent_state.py b/test/api/test_attachment_and_agent_state.py new file mode 100644 index 00000000..72f2bc88 --- /dev/null +++ b/test/api/test_attachment_and_agent_state.py @@ -0,0 +1,288 @@ +""" +测试附件上传和 agent state 获取的 API 脚本 + +使用方式: + cd /home/zwj/workspace/Yuxi-Know + docker compose exec api uv run python test/api/test_attachment_and_agent_state.py + +或者本地运行: + python test/api/test_attachment_and_agent_state.py +""" + +import asyncio +import contextlib +import os +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# 加载 .env 文件 +from dotenv import load_dotenv +load_dotenv(PROJECT_ROOT / ".env") + +import httpx +import uuid + + +# API 配置 +API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050") + +# 测试账户配置 +USERNAME = os.getenv("YUXI_SUPER_ADMIN_NAME", "zwj") +PASSWORD = os.getenv("YUXI_SUPER_ADMIN_PASSWORD", "zwj12138") + +# 默认 Agent ID (需要根据实际情况修改,使用类名) +DEFAULT_AGENT_ID = "ChatbotAgent" + + +class APITester: + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url + self.username = username + self.password = password + self.token: str | None = None + self.user_id: str | None = None + self.headers: dict | None = None + + @contextlib.asynccontextmanager + async def _client(self, timeout: float = 30.0): + """获取 HTTP 客户端""" + client = httpx.AsyncClient(timeout=timeout) + try: + yield client + finally: + await client.aclose() + + async def login(self) -> bool: + """登录获取 token""" + print(f"\n{'='*60}") + print(f"1. 正在登录: {self.username}") + print(f"{'='*60}") + + async with self._client() as client: + response = await client.post( + f"{self.base_url}/api/auth/token", + data={"username": self.username, "password": self.password}, + ) + + if response.status_code == 200: + data = response.json() + self.token = data.get("access_token") + self.user_id = str(data.get("user_id")) + self.headers = {"Authorization": f"Bearer {self.token}"} + print(f" ✓ 登录成功! user_id: {self.user_id}") + print(f" ✓ Token: {self.token[:50]}...") + return True + else: + print(f" ✗ 登录失败: {response.status_code} - {response.text}") + return False + + async def create_thread(self, agent_id: str) -> str: + """创建对话线程""" + print(f"\n{'='*60}") + print(f"2. 创建对话线程 (agent_id: {agent_id})") + print(f"{'='*60}") + + async with self._client() as client: + response = await client.post( + f"{self.base_url}/api/chat/thread", + json={"agent_id": agent_id, "title": "API 测试对话"}, + headers=self.headers, + ) + + if response.status_code == 200: + thread = response.json() + thread_id = thread.get("id") + print(f" ✓ 创建成功! thread_id: {thread_id}") + return thread_id + else: + print(f" ✗ 创建失败: {response.status_code} - {response.text}") + return "" + + async def upload_attachment(self, thread_id: str, file_path: str) -> dict | None: + """上传附件""" + print(f"\n{'='*60}") + print(f"3. 上传附件: {file_path}") + print(f"{'='*60}") + + if not os.path.exists(file_path): + print(f" ✗ 文件不存在: {file_path}") + return None + + async with self._client(timeout=60.0) as client: + with open(file_path, "rb") as f: + files = {"file": (os.path.basename(file_path), f)} + response = await client.post( + f"{self.base_url}/api/chat/thread/{thread_id}/attachments", + files=files, + headers=self.headers, + ) + + if response.status_code == 200: + attachment = response.json() + print(f" ✓ 上传成功!") + print(f" file_id: {attachment.get('file_id')}") + print(f" file_name: {attachment.get('file_name')}") + print(f" status: {attachment.get('status')}") + return attachment + else: + print(f" ✗ 上传失败: {response.status_code} - {response.text}") + return None + + async def list_attachments(self, thread_id: str) -> list[dict]: + """列出附件""" + print(f"\n{'='*60}") + print(f"4. 列出附件 (thread_id: {thread_id})") + print(f"{'='*60}") + + async with self._client() as client: + response = await client.get( + f"{self.base_url}/api/chat/thread/{thread_id}/attachments", + headers=self.headers, + ) + + if response.status_code == 200: + data = response.json() + attachments = data.get("attachments", []) + print(f" ✓ 获取到 {len(attachments)} 个附件:") + for att in attachments: + print(f" - {att.get('file_name')}: {att.get('status')}") + return attachments + else: + print(f" ✗ 获取失败: {response.status_code} - {response.text}") + return [] + + async def get_agent_state(self, agent_id: str, thread_id: str) -> dict | None: + """获取 agent state""" + print(f"\n{'='*60}") + print(f"5. 获取 Agent State (agent_id: {agent_id}, thread_id: {thread_id})") + print(f"{'='*60}") + + async with self._client() as client: + response = await client.get( + f"{self.base_url}/api/chat/agent/{agent_id}/state", + params={"thread_id": thread_id}, + headers=self.headers, + ) + + if response.status_code == 200: + state = response.json() + agent_state = state.get("agent_state", {}) + print(f" ✓ 获取成功!") + print(f" files: {len(agent_state.get('files', {}))} 个") + print(f" todos: {len(agent_state.get('todos', []))} 个") + if agent_state.get("files"): + print(f" 文件列表:") + for path in agent_state["files"]: + file_info = agent_state["files"][path] + print(f" - {path}: {len(file_info.get('content', []))} 行") + return state + else: + print(f" ✗ 获取失败: {response.status_code} - {response.text}") + return None + + async def send_chat_message(self, agent_id: str, thread_id: str, query: str) -> bool: + """发送聊天消息(流式)""" + print(f"\n{'='*60}") + print(f"6. 发送聊天消息") + print(f"{'='*60}") + print(f" Query: {query}") + print(f" Thread ID: {thread_id}") + + async with self._client(timeout=120.0) as client: + async with client.stream( + "POST", + f"{self.base_url}/api/chat/agent/{agent_id}", + json={"query": query, "config": {"thread_id": thread_id}}, + headers=self.headers, + ) as response: + print(f"\n 响应状态: {response.status_code}") + print(f" 响应内容:") + async for chunk in response.aiter_lines(): + if chunk: + print(f" {chunk[:150]}...") + + return response.status_code == 200 + + +async def main(): + """主测试流程""" + print("\n" + "=" * 60) + print(" 附件上传与 Agent State API 测试") + print("=" * 60) + print(f"\nAPI 地址: {API_BASE_URL}") + print(f"测试账户: {USERNAME}") + + tester = APITester(API_BASE_URL, USERNAME, PASSWORD) + + # 1. 登录 + if not await tester.login(): + print("\n!!! 登录失败,测试终止 !!!") + return + + # 2. 创建线程(使用默认 agent_id) + agent_id = DEFAULT_AGENT_ID + thread_id = await tester.create_thread(agent_id) + if not thread_id: + print("\n!!! 创建线程失败,测试终止 !!!") + return + + # 3. 创建测试文件 + test_content = """# 测试文档 + +这是一个用于 API 测试的 Markdown 文件。 + +## 主要内容 + +- 第一点 +- 第二点 +- 第三点 + +```python +def hello(): + print("Hello, World!") +``` +""" + test_file_path = f"/tmp/test_attachment_{uuid.uuid4().hex[:8]}.md" + with open(test_file_path, "w", encoding="utf-8") as f: + f.write(test_content) + print(f"\n 测试文件已创建: {test_file_path}") + + # 4. 上传附件 + attachment = await tester.upload_attachment(thread_id, test_file_path) + + # 5. 列出附件 + await tester.list_attachments(thread_id) + + # 6. 获取 agent state (验证附件是否在 state 中) + if attachment: + print("\n 等待后端处理...") + await asyncio.sleep(2) + await tester.get_agent_state(agent_id, thread_id) + + # 7. 发送聊天消息测试 + await tester.send_chat_message( + agent_id, + thread_id, + "你好,请简单介绍一下你自己。" + ) + + # 8. 再次获取 agent state (验证 todos 等状态) + await asyncio.sleep(1) + await tester.get_agent_state(agent_id, thread_id) + + # 清理测试文件 + if os.path.exists(test_file_path): + os.remove(test_file_path) + print(f"\n 测试文件已清理: {test_file_path}") + + print(f"\n{'='*60}") + print(" 测试完成!") + print(f"{'='*60}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 98c63593..fcf20cc1 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -338,12 +338,21 @@ const currentAgentState = computed(() => { }) const countFiles = (files) => { - if (!Array.isArray(files)) return 0 - let c = 0 - for (const item of files) { - if (item && typeof item === 'object') c += Object.keys(item).length + // 支持 dict 格式(StateBackend 格式)和 array 格式 + if (!files) return 0 + if (typeof files === 'object' && !Array.isArray(files)) { + // dict 格式: {"/attachments/file.md": {...}, ...} + return Object.keys(files).length } - return c + if (Array.isArray(files)) { + // array 格式 + let c = 0 + for (const item of files) { + if (item && typeof item === 'object') c += Object.keys(item).length + } + return c + } + return 0 } const hasAgentStateContent = computed(() => { @@ -351,16 +360,24 @@ const hasAgentStateContent = computed(() => { if (!s) return false const todoCount = Array.isArray(s.todos) ? s.todos.length : 0 const fileCount = countFiles(s.files) - const attachmentCount = Array.isArray(s.attachments) ? s.attachments.length : 0 - return todoCount > 0 || fileCount > 0 || attachmentCount > 0 + return todoCount > 0 || fileCount > 0 }) const mentionConfig = computed(() => { - const rawFiles = currentAgentState.value?.files || [] - const rawAttachments = currentAgentState.value?.attachments || [] + const rawFiles = currentAgentState.value?.files || {} const files = [] - if (Array.isArray(rawFiles)) { + // 处理 files - 兼容字典格式 {"/path/file": {content: [...]}} 和旧数组格式 + if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) { + // 新格式:字典格式 {"/attachments/xxx/file.md": {...}} + Object.entries(rawFiles).forEach(([filePath, fileData]) => { + files.push({ + path: filePath, + ...fileData + }) + }) + } else if (Array.isArray(rawFiles)) { + // 旧格式:数组格式 rawFiles.forEach((item) => { if (typeof item === 'object' && item !== null) { Object.entries(item).forEach(([filePath, fileData]) => { @@ -373,17 +390,6 @@ const mentionConfig = computed(() => { }) } - if (Array.isArray(rawAttachments)) { - rawAttachments.forEach((item) => { - if (item && item.file_name) { - files.push({ - path: item.file_name, - ...item - }) - } - }) - } - // Filter KBs and MCPs based on agent config const configItems = configurableItems.value || {} const currentConfig = agentConfig.value || {} @@ -662,8 +668,18 @@ const fetchAgentState = async (agentId, threadId) => { if (!agentId || !threadId) return try { const res = await agentApi.getAgentState(agentId, threadId) - const ts = getThreadState(threadId) - if (ts) ts.agentState = res.agent_state || null + // 确保更新 currentChatId 对应的 state,因为 currentAgentState 依赖它 + // 如果 currentChatId 为 null,使用传入的 threadId + const targetChatId = currentChatId.value || threadId + console.log('[fetchAgentState] agentId:', agentId, 'threadId:', threadId, 'targetChatId:', targetChatId, 'agent_state:', JSON.stringify(res.agent_state || {})?.slice(0, 200)) + const ts = getThreadState(targetChatId) + if (ts) { + ts.agentState = res.agent_state || null + } else { + // 如果 targetChatId 对应的 state 不存在,创建一个 + const newTs = getThreadState(threadId) + if (newTs) newTs.agentState = res.agent_state || null + } } catch (error) {} } @@ -1068,9 +1084,13 @@ const toggleSidebar = () => { } const openAgentModal = () => emit('open-agent-modal') -const handleAgentStateRefresh = async () => { - if (!currentAgentId.value || !currentChatId.value) return - await fetchAgentState(currentAgentId.value, currentChatId.value) +const handleAgentStateRefresh = async (threadId = null) => { + if (!currentAgentId.value) return + // 优先使用传入的 threadId,否则使用当前的 currentChatId + let chatId = threadId || currentChatId.value + console.log('[handleAgentStateRefresh] input threadId:', threadId, 'currentChatId:', currentChatId.value, 'final chatId:', chatId) + if (!chatId) return + await fetchAgentState(currentAgentId.value, chatId) } const toggleAgentPanel = () => { diff --git a/web/src/components/AgentInputArea.vue b/web/src/components/AgentInputArea.vue index f8b790b5..a7e91b56 100644 --- a/web/src/components/AgentInputArea.vue +++ b/web/src/components/AgentInputArea.vue @@ -114,7 +114,7 @@ const handleAttachmentUpload = async (files) => { await threadApi.uploadThreadAttachment(threadId, file) message.success(`${file.name} 上传成功`) } - emit('attachment-changed') + emit('attachment-changed', threadId) } catch (error) { handleChatError(error, 'upload') } diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index 4283873a..a78e4ee3 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -25,13 +25,6 @@ -
暂无附件,支持 txt/md/docx/html 格式 ≤ 5 MB
-