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 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(), # 模型重试中间件
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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(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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
# 尝试从输入中获取 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:
|
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()
|
||||||
|
|
||||||
# 保留旧名称以保持向后兼容(已废弃)
|
# 保留旧名称以保持向后兼容(已废弃)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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_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:
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
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) => {
|
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 = () => {
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user