refactor: 简化附件处理架构,使用 StateBackend 替代 MinIO

- 移除 MinIOBackend 和复合存储后端,统一使用 StateBackend
- 依赖 LangGraph checkpointer 自动恢复 state(attachments, files)
- 前端适配 StateBackend 字典格式文件结构
- 优化 @提及 文件搜索逻辑,支持路径匹配
This commit is contained in:
Wenjie Zhang 2026-02-13 22:16:11 +08:00
parent 34b06b297e
commit 44e41bbb5a
15 changed files with 549 additions and 744 deletions

View File

@ -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 import create_agent
from langchain.agents.middleware import ModelRetryMiddleware 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 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,
@ -12,20 +12,9 @@ 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: def _create_fs_backend(rt):
"""创建混合文件存储后端工厂函数(供 FilesystemMiddleware 使用)。 """创建文件存储后端"""
return StateBackend(rt)
/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):
@ -49,8 +38,8 @@ class ChatbotAgent(BaseAgent):
model=load_chat_model(context.model), model=load_chat_model(context.model),
system_prompt=context.system_prompt, system_prompt=context.system_prompt,
middleware=[ middleware=[
save_attachments_to_fs, # 附件保存到文件系统 save_attachments_to_fs, # 附件注入提示词
FilesystemMiddleware(backend=_create_fs_backend_factory, tool_token_limit_before_evict=5000), FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端
RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/知识库/MCP/提示词) RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/知识库/MCP/提示词)
ModelRetryMiddleware(), # 模型重试中间件 ModelRetryMiddleware(), # 模型重试中间件
], ],

View File

@ -1,5 +0,0 @@
"""自定义文件系统后端模块"""
from src.agents.common.backends.minio_backend import MinIOBackend, init_attachment_bucket
__all__ = ["MinIOBackend", "init_attachment_bucket"]

View File

@ -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}")

View File

@ -79,17 +79,15 @@ class BaseAgent:
context.update(agent_config) context.update(agent_config)
context.update(input_context or {}) context.update(input_context or {})
logger.debug(f"stream_messages: {context}") logger.debug(f"stream_messages: {context}")
# TODO Checkpointer 似乎还没有适配最新的 1.0 Context API
# 从 input_context 中提取 attachments如果有 # 构建配置LangGraph 会自动从 checkpointer 恢复 state
attachments = (input_context or {}).get("attachments", [])
input_config = { input_config = {
"configurable": {"thread_id": context.thread_id, "user_id": context.user_id}, "configurable": {"thread_id": context.thread_id, "user_id": context.user_id},
"recursion_limit": 300, "recursion_limit": 300,
} }
async for msg, metadata in graph.astream( async for msg, metadata in graph.astream(
{"messages": messages, "attachments": attachments}, {"messages": messages},
stream_mode="messages", stream_mode="messages",
context=context, context=context,
config=input_config, config=input_config,
@ -105,15 +103,16 @@ class BaseAgent:
context.update(input_context or {}) context.update(input_context or {})
logger.debug(f"invoke_messages: {context}") logger.debug(f"invoke_messages: {context}")
# 从 input_context 中提取 attachments如果有 # 构建配置
attachments = (input_context or {}).get("attachments", [])
input_config = { input_config = {
"configurable": {"thread_id": context.thread_id, "user_id": context.user_id}, "configurable": {"thread_id": context.thread_id, "user_id": context.user_id},
"recursion_limit": 100, "recursion_limit": 100,
} }
msg = await graph.ainvoke( msg = await graph.ainvoke(
{"messages": messages, "attachments": attachments}, context=context, config=input_config {"messages": messages},
context=context,
config=input_config,
) )
return msg return msg

View File

@ -1,8 +1,6 @@
"""附件注入中间件 - 使用 LangChain 标准中间件实现 """附件注入中间件 - 使用 LangChain 标准中间件实现
支持两种模式 State 中读取附件信息注入提示词让模型使用 read_file 工具读取附件内容
1. MinIO 模式默认将附件保存到 MinIO 存储提示模型自主读取
2. 文件系统模式已废弃将附件保存到本地文件系统
""" """
from __future__ import annotations from __future__ import annotations
@ -13,7 +11,6 @@ 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
@ -21,9 +18,10 @@ class AttachmentState(AgentState):
"""扩展 AgentState 以支持附件""" """扩展 AgentState 以支持附件"""
attachments: NotRequired[list[dict]] 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. """Render attachments into a system prompt block with file paths.
提示模型使用 read_file 工具读取附件内容 提示模型使用 read_file 工具读取附件内容
@ -31,26 +29,28 @@ def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str
if not attachments: if not attachments:
return None 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: if not valid_attachments:
return None return None
attachment_infos: list[str] = [] attachment_infos: list[str] = []
for idx, attachment in enumerate(valid_attachments, 1): for attachment in valid_attachments:
file_id = attachment.get("file_id", f"file_{idx}") file_name = attachment.get("file_name", "未知文件")
file_name = attachment.get("file_name") or f"附件 {idx}" file_path = attachment.get("file_path", "")
truncated = "(已截断)" if attachment.get("truncated") else "" truncated = "(已截断)" if attachment.get("truncated") else ""
file_path = f"{thread_id}/{file_id}.md" if file_path:
attachment_infos.append(f"- {file_name}{truncated}: /attachments/{file_path}") attachment_infos.append(f"- {file_name}{truncated}: {file_path}")
else:
attachment_infos.append(f"- {file_name}{truncated}")
lines = [ lines = [
"用户上传了以下附件,已保存到文件系统中", "用户上传了以下附件",
"", "",
*attachment_infos, *attachment_infos,
"", "",
"请使用 read_file 工具读取附件内容后,再回答用户的问题。如果附件与问题无关,可以忽略附件内容。", "请使用 read_file 工具读取附件内容后,再回答用户的问题。",
] ]
return "\n".join(lines) return "\n".join(lines)
@ -58,14 +58,10 @@ def _build_attachment_prompt(attachments: Sequence[dict], thread_id: str) -> str
class AttachmentMiddleware(AgentMiddleware[AttachmentState]): class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
""" """
LangChain 标准中间件 State 中读取附件并注入到消息中 LangChain 标准中间件 State 中读取附件并注入提示词
根据官方文档示例 LangGraph 会自动从 checkpointer 恢复 state包括 attachments
https://docs.langchain.com/oss/python/langchain/middleware request.state 中读取附件将其转换为 SystemMessage 并注入到消息列表开头
request.state 中读取 attachments将其转换为 SystemMessage 并注入到消息列表开头
NOTE: 缺点是无法命中缓存了
""" """
state_schema = AttachmentState state_schema = AttachmentState
@ -73,107 +69,16 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
async def awrap_model_call( async def awrap_model_call(
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse] self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse: ) -> ModelResponse:
# Read from State: get uploaded files metadata # 从 state 获取附件LangGraph 自动从 checkpointer 恢复)
# 首先尝试从 state 获取,如果为空则从 input_context 获取
attachments = request.state.get("attachments", []) 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") logger.info(f"AttachmentMiddleware: found {len(attachments)} attachments in state")
# 尝试从输入中获取 attachmentsLangGraph 会将输入 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: if attachments:
# Get thread_id - 尝试从多个来源获取 # 构建附件提示
thread_id = None attachment_prompt = _build_attachment_prompt(attachments)
# 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)
if attachment_prompt: 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) messages = list(request.messages)
insert_idx = 0 insert_idx = 0
@ -195,47 +100,7 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
return await handler(request) 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() save_attachments_to_fs = AttachmentMiddleware()
# 保留旧名称以保持向后兼容(已废弃) # 保留旧名称以保持向后兼容(已废弃)

View File

@ -1,6 +1,6 @@
"""Deep Agent - 基于create_deep_agent的深度分析智能体""" """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.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
@ -10,31 +10,16 @@ from langchain.agents.middleware import (
) )
from src.agents.common import BaseAgent, load_chat_model from src.agents.common import BaseAgent, load_chat_model
from src.agents.common.middlewares import ( from src.agents.common.middlewares import RuntimeConfigMiddleware, SummaryOffloadMiddleware, save_attachments_to_fs
RuntimeConfigMiddleware,
SummaryOffloadMiddleware,
save_attachments_to_fs,
)
from src.agents.common.tools import get_tavily_search from src.agents.common.tools import get_tavily_search
from src.services.mcp_service import get_tools_from_all_servers from src.services.mcp_service import get_tools_from_all_servers
from .context import DeepContext from .context import DeepContext
def _create_filesystem_backend_factory(rt) -> CompositeBackend: def _create_fs_backend(rt):
"""创建混合文件存储后端工厂函数(供 FilesystemMiddleware 使用)。 """创建文件存储后端"""
return StateBackend(rt)
/attachments/* 路由到真实文件系统供附件中间件使用
其他路径使用 StateBackend内存存储用于临时文件和大结果卸载
注意rt (runtime) FilesystemMiddleware 在调用时自动传入
"""
return CompositeBackend(
default=StateBackend(rt), # 传入 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:
@ -130,7 +115,6 @@ class DeepAgent(BaseAgent):
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(backend=_create_filesystem_backend_factory),
RuntimeConfigMiddleware( RuntimeConfigMiddleware(
model_context_name="subagents_model", model_context_name="subagents_model",
enable_model_override=True, enable_model_override=True,
@ -148,9 +132,9 @@ class DeepAgent(BaseAgent):
model=model, model=model,
system_prompt=context.system_prompt, system_prompt=context.system_prompt,
middleware=[ middleware=[
FilesystemMiddleware(backend=_create_filesystem_backend_factory), FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端
RuntimeConfigMiddleware(extra_tools=all_mcp_tools), RuntimeConfigMiddleware(extra_tools=all_mcp_tools),
save_attachments_to_fs, # 附件保存到文件系统 save_attachments_to_fs, # 附件注入提示词
TodoListMiddleware(), TodoListMiddleware(),
PatchToolCallsMiddleware(), PatchToolCallsMiddleware(),
subagents_middleware, subagents_middleware,

View File

@ -3,6 +3,7 @@ import json
import traceback import traceback
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from langgraph.types import Command from langgraph.types import Command
@ -17,6 +18,41 @@ from src.storage.postgres.manager import pg_manager
from src.utils.logging_config import logger 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): async def _get_langgraph_messages(agent_instance, config_dict):
graph = await agent_instance.get_graph() graph = await agent_instance.get_graph()
state = await graph.aget_state(config_dict) 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: def extract_agent_state(values: dict) -> dict:
"""从 LangGraph state 中提取 agent 状态"""
if not isinstance(values, dict): if not isinstance(values, dict):
return {} return {}
def _norm_list(v): # 直接获取,信任 state 的数据结构
if v is None: todos = values.get("todos")
return [] result = {
if isinstance(v, (list, tuple)): "todos": list(todos)[:20] if todos else [],
return list(v) "files": values.get("files") or {},
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]
return result return result
@ -324,13 +356,11 @@ async def stream_agent_chat(
except Exception as e: except Exception as e:
logger.error(f"Error saving user message: {e}") logger.error(f"Error saving user message: {e}")
try: # 先构建 langgraph_config
assert thread_id, "thread_id is required" langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
attachments = await conv_repo.get_attachments_by_thread_id(thread_id)
input_context["attachments"] = attachments # 注意LangGraph 会自动从 checkpointer 恢复 state包括 attachments 和 files
except Exception as e: # 无需手动加载或传递
logger.error(f"Error loading attachments for thread_id={thread_id}: {e}")
input_context["attachments"] = []
# 根据用户权限过滤知识库 # 根据用户权限过滤知识库
requested_knowledge_names = input_context["agent_config"].get("knowledges") requested_knowledge_names = input_context["agent_config"].get("knowledges")
@ -353,7 +383,6 @@ async def stream_agent_chat(
full_msg = None full_msg = None
accumulated_content = [] 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): async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
if isinstance(msg, AIMessageChunk): if isinstance(msg, AIMessageChunk):
accumulated_content.append(msg.content) accumulated_content.append(msg.content)
@ -618,12 +647,23 @@ async def get_agent_state_view(
state = await graph.aget_state(langgraph_config) state = await graph.aget_state(langgraph_config)
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {} agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
# 获取附件 # 如果 state 中没有 files从附件构建
try: # 这确保了上传附件后立即可以在文件列表中看到文件
attachments = await conv_repo.get_attachments_by_thread_id(thread_id) if not agent_state.get("files") or agent_state["files"] == {}:
agent_state["attachments"] = attachments try:
except Exception as e: attachments = await conv_repo.get_attachments_by_thread_id(thread_id)
logger.warning(f"Failed to fetch attachments for thread {thread_id}: {e}") logger.info(f"[get_agent_state_view] found {len(attachments)} attachments in DB")
agent_state["attachments"] = [] 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} return {"agent_state": agent_state}

View File

@ -9,9 +9,13 @@ from src.services.doc_converter import (
MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENT_SIZE_BYTES,
convert_upload_to_markdown, convert_upload_to_markdown,
) )
from src.storage.minio.client import get_minio_client
from src.utils.datetime_utils import utc_isoformat from src.utils.datetime_utils import utc_isoformat
from src.utils.logging_config import logger 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): 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) 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 return conversation
def _make_attachment_path(file_name: str) -> str:
"""生成附件在文件系统中的路径(无需 thread_idstate 已隔离)
统一使用 .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: def serialize_attachment(record: dict) -> dict:
"""序列化附件记录,返回给前端"""
return { return {
"file_id": record.get("file_id"), "file_id": record.get("file_id"),
"file_name": record.get("file_name"), "file_name": record.get("file_name"),
@ -29,6 +51,7 @@ def serialize_attachment(record: dict) -> dict:
"status": record.get("status", "parsed"), "status": record.get("status", "parsed"),
"uploaded_at": record.get("uploaded_at"), "uploaded_at": record.get("uploaded_at"),
"truncated": record.get("truncated", False), "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}") logger.error(f"附件解析失败: {exc}")
raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from 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 = { attachment_record = {
"file_id": conversion.file_id, "file_id": conversion.file_id,
"file_name": conversion.file_name, "file_name": conversion.file_name,
@ -152,6 +197,8 @@ async def upload_thread_attachment_view(
"markdown": conversion.markdown, "markdown": conversion.markdown,
"uploaded_at": utc_isoformat(), "uploaded_at": utc_isoformat(),
"truncated": conversion.truncated, "truncated": conversion.truncated,
"file_path": file_path, # 用于 StateBackend前端不返回此字段
"minio_url": minio_url,
} }
await conv_repo.add_attachment(conversation.id, attachment_record) await conv_repo.add_attachment(conversation.id, attachment_record)

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -94,7 +93,3 @@ async def convert_upload_to_markdown(upload: UploadFile) -> ConversionResult:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("Attachment conversion failed: %s", exc) logger.error("Attachment conversion failed: %s", exc)
raise 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)

View File

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

View File

@ -338,12 +338,21 @@ const currentAgentState = computed(() => {
}) })
const countFiles = (files) => { const countFiles = (files) => {
if (!Array.isArray(files)) return 0 // dict StateBackend array
let c = 0 if (!files) return 0
for (const item of files) { if (typeof files === 'object' && !Array.isArray(files)) {
if (item && typeof item === 'object') c += Object.keys(item).length // 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(() => { const hasAgentStateContent = computed(() => {
@ -351,16 +360,24 @@ const hasAgentStateContent = computed(() => {
if (!s) return false if (!s) return false
const todoCount = Array.isArray(s.todos) ? s.todos.length : 0 const todoCount = Array.isArray(s.todos) ? s.todos.length : 0
const fileCount = countFiles(s.files) const fileCount = countFiles(s.files)
const attachmentCount = Array.isArray(s.attachments) ? s.attachments.length : 0 return todoCount > 0 || fileCount > 0
return todoCount > 0 || fileCount > 0 || attachmentCount > 0
}) })
const mentionConfig = computed(() => { const mentionConfig = computed(() => {
const rawFiles = currentAgentState.value?.files || [] const rawFiles = currentAgentState.value?.files || {}
const rawAttachments = currentAgentState.value?.attachments || []
const 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) => { rawFiles.forEach((item) => {
if (typeof item === 'object' && item !== null) { if (typeof item === 'object' && item !== null) {
Object.entries(item).forEach(([filePath, fileData]) => { 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 // Filter KBs and MCPs based on agent config
const configItems = configurableItems.value || {} const configItems = configurableItems.value || {}
const currentConfig = agentConfig.value || {} const currentConfig = agentConfig.value || {}
@ -662,8 +668,18 @@ const fetchAgentState = async (agentId, threadId) => {
if (!agentId || !threadId) return if (!agentId || !threadId) return
try { try {
const res = await agentApi.getAgentState(agentId, threadId) const res = await agentApi.getAgentState(agentId, threadId)
const ts = getThreadState(threadId) // currentChatId state currentAgentState
if (ts) ts.agentState = res.agent_state || null // 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) {} } catch (error) {}
} }
@ -1068,9 +1084,13 @@ const toggleSidebar = () => {
} }
const openAgentModal = () => emit('open-agent-modal') const openAgentModal = () => emit('open-agent-modal')
const handleAgentStateRefresh = async () => { const handleAgentStateRefresh = async (threadId = null) => {
if (!currentAgentId.value || !currentChatId.value) return if (!currentAgentId.value) return
await fetchAgentState(currentAgentId.value, currentChatId.value) // 使 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 = () => { const toggleAgentPanel = () => {

View File

@ -114,7 +114,7 @@ const handleAttachmentUpload = async (files) => {
await threadApi.uploadThreadAttachment(threadId, file) await threadApi.uploadThreadAttachment(threadId, file)
message.success(`${file.name} 上传成功`) message.success(`${file.name} 上传成功`)
} }
emit('attachment-changed') emit('attachment-changed', threadId)
} catch (error) { } catch (error) {
handleChatError(error, 'upload') handleChatError(error, 'upload')
} }

View File

@ -25,13 +25,6 @@
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'"> <button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">
文件 ({{ fileCount }}) 文件 ({{ fileCount }})
</button> </button>
<button
class="tab"
:class="{ active: activeTab === 'attachments' }"
@click="activeTab = 'attachments'"
>
附件 ({{ attachmentCount }})
</button>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<!-- Todo Display --> <!-- Todo Display -->
@ -102,84 +95,8 @@
</a-tree> </a-tree>
</div> </div>
</div> </div>
<!-- Attachments Display -->
<div v-if="activeTab === 'attachments'" class="files-display">
<div class="list-header" v-if="attachmentCount">
<div class="list-header-left">
<span class="count">{{ attachmentCount }} 个附件</span>
<a-tooltip title="支持 txt/md/docx/html 格式 ≤ 5 MB">
<Info :size="14" class="info-icon" />
</a-tooltip>
</div>
<button class="add-btn" @click="triggerUpload" :disabled="isUploading">
<Plus :size="16" />
<span>添加</span>
</button>
</div>
<div v-if="!attachmentCount" class="empty">
<p>暂无附件支持 txt/md/docx/html 格式 5 MB</p>
<a-button type="primary" @click="triggerUpload" :loading="isUploading">上传附件</a-button>
</div>
<div v-else class="file-tree-container attachment-tree">
<a-tree
v-model:expandedKeys="expandedKeys"
:tree-data="attachmentTreeData"
:show-icon="true"
block-node
:show-line="false"
@select="onFileSelect"
>
<template #icon="{ data, expanded }">
<template v-if="data.isLeaf">
<component
:is="getFileIcon(data.key)"
:style="{ color: getFileIconColor(data.key), fontSize: '16px' }"
/>
</template>
<template v-else>
<FolderOpen v-if="expanded" :size="18" class="folder-icon open" />
<Folder v-else :size="18" class="folder-icon" />
</template>
</template>
<template #title="{ data }">
<div class="tree-node-wrapper" @click="toggleFolder(data)">
<div class="tree-node-name" :title="data.title">
<span class="name-start">{{ data.nameStart || data.title }}</span>
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
</div>
<div v-if="data.isLeaf" class="node-actions" @click.stop>
<button
class="tree-action-btn tree-download-btn"
@click.stop="downloadFile(data.fileData)"
title="下载文件"
>
<Download :size="14" />
</button>
<button
class="tree-action-btn tree-delete-btn"
@click.stop="deleteAttachment(data.fileData)"
title="删除附件"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</template>
</a-tree>
</div>
</div>
</div> </div>
<!-- Hidden File Input -->
<input
type="file"
ref="fileInputRef"
style="display: none"
multiple
@change="handleFileChange"
/>
<!-- 文件内容 Modal --> <!-- 文件内容 Modal -->
<a-modal <a-modal
v-model:open="modalVisible" v-model:open="modalVisible"
@ -237,13 +154,10 @@ import { computed, ref, onMounted, onUpdated, nextTick } from 'vue'
import { import {
Download, Download,
X, X,
Plus,
Info,
FolderCode, FolderCode,
RefreshCw, RefreshCw,
Folder, Folder,
FolderOpen, FolderOpen
Trash2
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
CheckCircleOutlined, CheckCircleOutlined,
@ -256,8 +170,6 @@ import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css' import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor, formatFileSize } from '@/utils/file_utils' import { getFileIcon, getFileIconColor, formatFileSize } from '@/utils/file_utils'
import { threadApi } from '@/apis'
import { message } from 'ant-design-vue'
const props = defineProps({ const props = defineProps({
agentState: { agentState: {
@ -280,8 +192,6 @@ const activeTab = ref('todos')
const modalVisible = ref(false) const modalVisible = ref(false)
const currentFile = ref(null) const currentFile = ref(null)
const currentFilePath = ref('') const currentFilePath = ref('')
const isUploading = ref(false)
const fileInputRef = ref(null)
const themeStore = useThemeStore() const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light')) const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
@ -298,10 +208,6 @@ const files = computed(() => {
return props.agentState?.files || [] return props.agentState?.files || []
}) })
const attachments = computed(() => {
return props.agentState?.attachments || []
})
const completedCount = computed(() => { const completedCount = computed(() => {
return todos.value.filter((t) => t.status === 'completed').length return todos.value.filter((t) => t.status === 'completed').length
}) })
@ -346,34 +252,35 @@ onUpdated(() => {
// //
const normalizedFiles = computed(() => { const normalizedFiles = computed(() => {
if (!Array.isArray(files.value)) return [] const rawFiles = files.value
const result = [] const result = []
files.value.forEach((item) => {
if (typeof item === 'object' && item !== null) { // {"/path/file": {content: [...]}}
Object.entries(item).forEach(([filePath, fileData]) => { if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) {
result.push({ //
path: filePath, Object.entries(rawFiles).forEach(([filePath, fileData]) => {
...fileData result.push({
}) path: filePath,
...fileData
}) })
} })
}) } else if (Array.isArray(rawFiles)) {
//
rawFiles.forEach((item) => {
if (typeof item === 'object' && item !== null) {
Object.entries(item).forEach(([filePath, fileData]) => {
result.push({
path: filePath,
...fileData
})
})
}
})
}
return result return result
}) })
const normalizedAttachments = computed(() => {
if (!Array.isArray(attachments.value)) return []
return attachments.value.map((item) => ({
...item,
path: item.file_name,
content: item.markdown,
modified_at: item.uploaded_at,
size: item.file_size
}))
})
const expandedKeys = ref([]) const expandedKeys = ref([])
const buildTreeData = (filesList) => { const buildTreeData = (filesList) => {
@ -483,7 +390,6 @@ const truncateFilename = (name) => {
} }
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value)) const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
const attachmentTreeData = computed(() => buildTreeData(normalizedAttachments.value))
const toggleFolder = (data) => { const toggleFolder = (data) => {
if (data.isLeaf) return if (data.isLeaf) return
@ -508,10 +414,6 @@ const fileCount = computed(() => {
return normalizedFiles.value.length return normalizedFiles.value.length
}) })
const attachmentCount = computed(() => {
return normalizedAttachments.value.length
})
// //
const getFileName = (fileItem) => { const getFileName = (fileItem) => {
if (fileItem.path) { if (fileItem.path) {
@ -555,11 +457,38 @@ const closeModal = () => {
const downloadFile = (fileItem) => { const downloadFile = (fileItem) => {
try { try {
// /attachments/ Markdown
if (fileItem.path?.startsWith('/attachments/') && fileItem.content) {
const content = formatContent(fileItem.content)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = getFileName(fileItem)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return
}
// 使 minio_url
if (fileItem.minio_url) {
const link = document.createElement('a')
link.href = fileItem.minio_url
link.download = getFileName(fileItem)
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
// content
const content = formatContent(fileItem.content) const content = formatContent(fileItem.content)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = getFileName(fileItem) link.download = getFileName(fileItem)
document.body.appendChild(link) document.body.appendChild(link)
@ -571,49 +500,10 @@ const downloadFile = (fileItem) => {
} }
} }
const triggerUpload = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
const handleFileChange = async (event) => {
const files = event.target.files
if (!files?.length || !props.threadId) return
isUploading.value = true
try {
for (const file of Array.from(files)) {
await threadApi.uploadThreadAttachment(props.threadId, file)
message.success(`${file.name} 上传成功`)
}
emitRefresh()
} catch (error) {
console.error('上传附件失败:', error)
message.error('上传附件失败')
} finally {
isUploading.value = false
event.target.value = ''
}
}
const emitRefresh = () => { const emitRefresh = () => {
emit('refresh') emit('refresh')
} }
const deleteAttachment = async (fileItem) => {
if (!props.threadId || !fileItem?.file_id) return
try {
await threadApi.deleteThreadAttachment(props.threadId, fileItem.file_id)
message.success('附件已删除')
emitRefresh()
} catch (error) {
console.error('删除附件失败:', error)
message.error('删除附件失败')
}
}
// //
const isResizing = ref(false) const isResizing = ref(false)
const startX = ref(0) const startX = ref(0)
@ -1137,9 +1027,10 @@ const stopResize = () => {
/* Specific Ant Design Tree Overrides */ /* Specific Ant Design Tree Overrides */
.file-tree-container :deep(.ant-tree) { .file-tree-container :deep(.ant-tree) {
background: transparent; background: var(--gray-25);
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
overflow: hidden;
.ant-tree-treenode { .ant-tree-treenode {
width: 100%; width: 100%;

View File

@ -121,7 +121,7 @@ const loadCallStats = async () => {
await nextTick() await nextTick()
renderCallStatsChart() renderCallStatsChart()
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error('加载调用统计数据失败:', error) console.error('加载调用统计数据失败:', error)
} finally { } finally {
callStatsLoading.value = false callStatsLoading.value = false

View File

@ -137,7 +137,11 @@ export function useMention() {
const categorized = getCategorizedItems() const categorized = getCategorizedItems()
const filterItems = (items) => const filterItems = (items) =>
items.filter((item) => item.label.toLowerCase().includes(lowerQuery)) items.filter(
(item) =>
item.label.toLowerCase().includes(lowerQuery) ||
item.value.toLowerCase().includes(lowerQuery)
)
return { return {
files: filterItems(categorized.files), files: filterItems(categorized.files),