refactor: 简化附件处理架构,使用 StateBackend 替代 MinIO
- 移除 MinIOBackend 和复合存储后端,统一使用 StateBackend - 依赖 LangGraph checkpointer 自动恢复 state(attachments, files) - 前端适配 StateBackend 字典格式文件结构 - 优化 @提及 文件搜索逻辑,支持路径匹配
This commit is contained in:
parent
34b06b297e
commit
44e41bbb5a
@ -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(), # 模型重试中间件
|
||||
],
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
"""自定义文件系统后端模块"""
|
||||
|
||||
from src.agents.common.backends.minio_backend import MinIOBackend, init_attachment_bucket
|
||||
|
||||
__all__ = ["MinIOBackend", "init_attachment_bucket"]
|
||||
@ -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}")
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
# 保留旧名称以保持向后兼容(已废弃)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
288
test/api/test_attachment_and_agent_state.py
Normal file
288
test/api/test_attachment_and_agent_state.py
Normal 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())
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -25,13 +25,6 @@
|
||||
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">
|
||||
文件 ({{ fileCount }})
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'attachments' }"
|
||||
@click="activeTab = 'attachments'"
|
||||
>
|
||||
附件 ({{ attachmentCount }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<!-- Todo Display -->
|
||||
@ -102,84 +95,8 @@
|
||||
</a-tree>
|
||||
</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>
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
style="display: none"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
<!-- 文件内容 Modal -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
@ -237,13 +154,10 @@ import { computed, ref, onMounted, onUpdated, nextTick } from 'vue'
|
||||
import {
|
||||
Download,
|
||||
X,
|
||||
Plus,
|
||||
Info,
|
||||
FolderCode,
|
||||
RefreshCw,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Trash2
|
||||
FolderOpen
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
@ -256,8 +170,6 @@ import { MdPreview } from 'md-editor-v3'
|
||||
import 'md-editor-v3/lib/preview.css'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { getFileIcon, getFileIconColor, formatFileSize } from '@/utils/file_utils'
|
||||
import { threadApi } from '@/apis'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const props = defineProps({
|
||||
agentState: {
|
||||
@ -280,8 +192,6 @@ const activeTab = ref('todos')
|
||||
const modalVisible = ref(false)
|
||||
const currentFile = ref(null)
|
||||
const currentFilePath = ref('')
|
||||
const isUploading = ref(false)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
@ -298,10 +208,6 @@ const files = computed(() => {
|
||||
return props.agentState?.files || []
|
||||
})
|
||||
|
||||
const attachments = computed(() => {
|
||||
return props.agentState?.attachments || []
|
||||
})
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return todos.value.filter((t) => t.status === 'completed').length
|
||||
})
|
||||
@ -346,34 +252,35 @@ onUpdated(() => {
|
||||
|
||||
// 适配实际数据格式
|
||||
const normalizedFiles = computed(() => {
|
||||
if (!Array.isArray(files.value)) return []
|
||||
|
||||
const rawFiles = files.value
|
||||
const result = []
|
||||
files.value.forEach((item) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
Object.entries(item).forEach(([filePath, fileData]) => {
|
||||
result.push({
|
||||
path: filePath,
|
||||
...fileData
|
||||
})
|
||||
|
||||
// 兼容字典格式 {"/path/file": {content: [...]}} 和旧数组格式
|
||||
if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) {
|
||||
// 新格式:字典格式
|
||||
Object.entries(rawFiles).forEach(([filePath, 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
|
||||
})
|
||||
|
||||
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 buildTreeData = (filesList) => {
|
||||
@ -483,7 +390,6 @@ const truncateFilename = (name) => {
|
||||
}
|
||||
|
||||
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
|
||||
const attachmentTreeData = computed(() => buildTreeData(normalizedAttachments.value))
|
||||
|
||||
const toggleFolder = (data) => {
|
||||
if (data.isLeaf) return
|
||||
@ -508,10 +414,6 @@ const fileCount = computed(() => {
|
||||
return normalizedFiles.value.length
|
||||
})
|
||||
|
||||
const attachmentCount = computed(() => {
|
||||
return normalizedAttachments.value.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const getFileName = (fileItem) => {
|
||||
if (fileItem.path) {
|
||||
@ -555,11 +457,38 @@ const closeModal = () => {
|
||||
|
||||
const downloadFile = (fileItem) => {
|
||||
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 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)
|
||||
@ -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 = () => {
|
||||
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 startX = ref(0)
|
||||
@ -1137,9 +1027,10 @@ const stopResize = () => {
|
||||
|
||||
/* Specific Ant Design Tree Overrides */
|
||||
.file-tree-container :deep(.ant-tree) {
|
||||
background: transparent;
|
||||
background: var(--gray-25);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-tree-treenode {
|
||||
width: 100%;
|
||||
|
||||
@ -121,7 +121,7 @@ const loadCallStats = async () => {
|
||||
await nextTick()
|
||||
renderCallStatsChart()
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.error('加载调用统计数据失败:', error)
|
||||
} finally {
|
||||
callStatsLoading.value = false
|
||||
|
||||
@ -137,7 +137,11 @@ export function useMention() {
|
||||
const categorized = getCategorizedItems()
|
||||
|
||||
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 {
|
||||
files: filterItems(categorized.files),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user