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

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

View File

@ -1,10 +1,10 @@
from deepagents.backends import CompositeBackend, StateBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
from langchain.agents import create_agent
from langchain.agents.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(), # 模型重试中间件
],

View File

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

View File

@ -1,312 +0,0 @@
"""MinIO 虚拟文件系统后端 - 实现 BackendProtocol 接口"""
from __future__ import annotations
from typing import TYPE_CHECKING
from deepagents.backends.protocol import BackendProtocol, EditResult, WriteResult
from deepagents.backends.utils import FileInfo, GrepMatch
from src.utils import logger
if TYPE_CHECKING:
from src.storage.minio.client import MinIOClient
class MinIOBackend(BackendProtocol):
"""基于 MinIO 的虚拟文件系统后端,用于存储对话附件。
/attachments/{thread_id}/{file_id}.md 路径映射到 MinIO
attachments/{thread_id}/{file_id}.md 对象存储
特点
- 附件内容持久化存储在 MinIO
- 无需本地物理存储空间
- 支持 Agent read_file 工具读取
"""
def __init__(
self,
bucket_name: str = "chat-attachments",
minio_client: MinIOClient | None = None,
):
self.bucket_name = bucket_name
self._client = minio_client
@property
def client(self) -> MinIOClient:
"""获取 MinIO 客户端实例"""
if self._client is None:
from src.storage.minio.client import MinIOClient
self._client = MinIOClient()
return self._client
def _key(self, path: str) -> str:
"""将虚拟路径转换为 MinIO object key。
虚拟路径: /attachments/{thread_id}/{file_id}.md
MinIO key: attachments/{thread_id}/{file_id}.md
"""
return path.lstrip("/")
def _parse_path(self, path: str) -> tuple[str | None, str | None]:
"""解析路径,提取 thread_id 和 filename。
支持两种格式
- 目录路径: /attachments/{thread_id}/
- 文件路径: /attachments/{thread_id}/{file_id}.md
Returns:
tuple[thread_id, filename] (thread_id, None) 对于目录
"""
# /attachments/{thread_id}/{file_id}.md -> parts = ['attachments', '{thread_id}', '{file_id}.md']
parts = path.strip("/").split("/")
if len(parts) >= 2:
thread_id = parts[1]
filename = parts[2] if len(parts) >= 3 else None
return thread_id, filename
return None, None
def _ensure_bucket_exists(self) -> None:
"""确保存储桶存在"""
self.client.ensure_bucket_exists(self.bucket_name)
def _ensure_attachments_prefix(self, path: str) -> str:
"""确保路径以 /attachments/ 前缀开头。
当此 backend CompositeBackend 用于 /attachments/ 路由时
CompositeBackend 会剥离前缀需要在此处补全
"""
if not path.startswith("/attachments/"):
return f"/attachments/{path.lstrip('/')}"
return path
# ========== BackendProtocol 接口实现 ==========
def ls_info(self, path: str) -> list[FileInfo]:
"""列出目录内容。
Args:
path: 虚拟路径支持
- / - 返回 /attachments/ 目录当此 backend 用于 /attachments/ 路由时
- /attachments/ - 列出所有 thread_id 目录
- /attachments/{thread_id}/ - 列出某个 thread 的附件
Returns:
FileInfo 列表
"""
# 确保路径有 /attachments/ 前缀CompositeBackend 可能会剥离)
path = self._ensure_attachments_prefix(path)
# 处理根目录 - 当 CompositeBackend 将 /attachments/ 路由转换为 / 时
if path == "/":
# 对于根目录,返回 /attachments/ 作为入口点
return [
FileInfo(
path="/attachments/",
is_dir=True,
size=0,
modified_at=None,
)
]
if not path.startswith("/attachments/"):
return []
# 解析路径
parts = path.strip("/").split("/")
if len(parts) == 1:
# /attachments/ - 列出所有 thread_id 目录
prefix = "attachments/"
try:
self._ensure_bucket_exists()
mc = self.client.client
result: list[FileInfo] = []
# 列出所有对象,按 thread_id 分组
all_objects = list(mc.list_objects(self.bucket_name, prefix=prefix, recursive=False))
seen_threads: set[str] = set()
for obj in all_objects:
# 从 attachments/{thread_id}/{file_id}.md 中提取 thread_id
obj_parts = obj.object_name.strip("/").split("/")
if len(obj_parts) >= 2:
thread_id = obj_parts[1]
if thread_id not in seen_threads:
seen_threads.add(thread_id)
# 创建目录条目(虚拟的,不是真实目录)
result.append(
FileInfo(
path=f"/attachments/{thread_id}/",
is_dir=True,
size=0,
modified_at=obj.last_modified.isoformat() if obj.last_modified else None,
)
)
return result
except Exception as e:
logger.error(f"MinIOBackend.ls_info failed for {path}: {e}")
return []
# /attachments/{thread_id}/ - 列出某个 thread 的附件
thread_id, _ = self._parse_path(path)
if not thread_id:
return []
prefix = f"attachments/{thread_id}/"
try:
self._ensure_bucket_exists()
mc = self.client.client
result: list[FileInfo] = []
objects = list(mc.list_objects(self.bucket_name, prefix=prefix, recursive=False))
for obj in objects:
file_path = f"/{obj.object_name}"
modified_at = obj.last_modified.isoformat() if obj.last_modified else None
result.append(
FileInfo(
path=file_path,
is_dir=False,
size=obj.size,
modified_at=modified_at,
)
)
return result
except Exception as e:
logger.error(f"MinIOBackend.ls_info failed for {path}: {e}")
return []
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
"""读取文件内容。
Args:
file_path: 虚拟路径 /attachments/{thread_id}/{file_id}.md
offset: 起始行偏移
limit: 最大行数
Returns:
带行号的内容字符串
"""
# 确保路径有 /attachments/ 前缀CompositeBackend 可能会剥离)
file_path = self._ensure_attachments_prefix(file_path)
if not file_path.startswith("/attachments/"):
return f"Error: Access denied to {file_path}"
_, filename = self._parse_path(file_path)
if not filename:
return f"Error: File not found: {file_path}"
try:
data = self.client.download_file(self.bucket_name, self._key(file_path))
content = data.decode("utf-8")
lines = content.split("\n")
# 添加行号
start = max(0, offset)
end = start + limit
numbered_lines = [f"{i + 1}:{line}" for i, line in enumerate(lines[start:end], start + 1)]
return "\n".join(numbered_lines)
except Exception as e:
logger.error(f"MinIOBackend.read failed for {file_path}: {e}")
return f"Error reading {file_path}: {e}"
def write(self, file_path: str, content: str) -> WriteResult:
"""写入文件(同步)。
Args:
file_path: 虚拟路径
content: 文件内容
Returns:
WriteResult
"""
# 确保路径有 /attachments/ 前缀
file_path = self._ensure_attachments_prefix(file_path)
if not file_path.startswith("/attachments/"):
return WriteResult(error=f"Access denied: {file_path}")
try:
self._ensure_bucket_exists()
data = content.encode("utf-8")
self.client.upload_file(
self.bucket_name,
self._key(file_path),
data,
content_type="text/markdown",
)
logger.info(f"MinIOBackend: wrote {file_path}")
return WriteResult(path=file_path, files_update=None)
except Exception as e:
logger.error(f"MinIOBackend.write failed for {file_path}: {e}")
return WriteResult(error=str(e))
async def awrite(self, file_path: str, content: str) -> WriteResult:
"""异步写入文件。
Args:
file_path: 虚拟路径
content: 文件内容
Returns:
WriteResult
"""
# 委托给同步 write 方法
return self.write(file_path, content)
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
"""编辑文件。
MinIO 后端不支持原地编辑返回错误提示
"""
return EditResult(error="Edit not supported for MinIO backend")
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
"""glob 模式匹配。
Args:
pattern: glob 模式 *.md
path: 搜索路径
Returns:
匹配的 FileInfo 列表
"""
import fnmatch
files = self.ls_info(path)
result: list[FileInfo] = []
for f in files:
if fnmatch.fnmatch(f.path, pattern):
result.append(f)
return result
def grep_raw(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
) -> list[GrepMatch] | str:
"""搜索文件内容。
当前实现为简化版本仅列出文件不进行实际搜索
"""
# 简化实现:暂不支持内容搜索
logger.debug(f"MinIOBackend.grep_raw called with pattern={pattern}, path={path}")
return []
def init_attachment_bucket() -> None:
"""初始化附件存储桶。
在应用启动时调用确保 chat-attachments 存储桶存在
"""
try:
client = MinIOBackend()
client._ensure_bucket_exists()
logger.info("chat-attachments bucket initialized")
except Exception as e:
logger.error(f"Failed to initialize attachment bucket: {e}")

View File

@ -79,17 +79,15 @@ class BaseAgent:
context.update(agent_config)
context.update(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

View File

@ -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")
# 尝试从输入中获取 attachmentsLangGraph 会将输入 state 合并)
if not attachments:
# 检查是否有其他方式传递的附件
logger.info("AttachmentMiddleware: checking for attachments in other locations...")
# 输入可能直接在 state 中
logger.info(f"AttachmentMiddleware: state type = {type(request.state)}")
if attachments:
# 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()
# 保留旧名称以保持向后兼容(已废弃)

View File

@ -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,

View File

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

View File

@ -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_idstate 已隔离)
统一使用 .md 扩展名因为文件内容已经是 Markdown 格式
"""
# 提取不带扩展名的部分
base_name = file_name
for ext in ['.docx', '.txt', '.html', '.htm', '.pdf', '.md']:
if file_name.lower().endswith(ext):
base_name = file_name[:-len(ext)]
break
# 替换路径分隔符
safe_name = base_name.replace("/", "_").replace("\\", "_")
return f"/attachments/{safe_name}.md"
def serialize_attachment(record: dict) -> dict:
"""序列化附件记录,返回给前端"""
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)

View File

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

View File

@ -0,0 +1,288 @@
"""
测试附件上传和 agent state 获取的 API 脚本
使用方式:
cd /home/zwj/workspace/Yuxi-Know
docker compose exec api uv run python test/api/test_attachment_and_agent_state.py
或者本地运行:
python test/api/test_attachment_and_agent_state.py
"""
import asyncio
import contextlib
import os
import sys
from pathlib import Path
# 添加项目根目录到 Python 路径
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# 加载 .env 文件
from dotenv import load_dotenv
load_dotenv(PROJECT_ROOT / ".env")
import httpx
import uuid
# API 配置
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050")
# 测试账户配置
USERNAME = os.getenv("YUXI_SUPER_ADMIN_NAME", "zwj")
PASSWORD = os.getenv("YUXI_SUPER_ADMIN_PASSWORD", "zwj12138")
# 默认 Agent ID (需要根据实际情况修改,使用类名)
DEFAULT_AGENT_ID = "ChatbotAgent"
class APITester:
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url
self.username = username
self.password = password
self.token: str | None = None
self.user_id: str | None = None
self.headers: dict | None = None
@contextlib.asynccontextmanager
async def _client(self, timeout: float = 30.0):
"""获取 HTTP 客户端"""
client = httpx.AsyncClient(timeout=timeout)
try:
yield client
finally:
await client.aclose()
async def login(self) -> bool:
"""登录获取 token"""
print(f"\n{'='*60}")
print(f"1. 正在登录: {self.username}")
print(f"{'='*60}")
async with self._client() as client:
response = await client.post(
f"{self.base_url}/api/auth/token",
data={"username": self.username, "password": self.password},
)
if response.status_code == 200:
data = response.json()
self.token = data.get("access_token")
self.user_id = str(data.get("user_id"))
self.headers = {"Authorization": f"Bearer {self.token}"}
print(f" ✓ 登录成功! user_id: {self.user_id}")
print(f" ✓ Token: {self.token[:50]}...")
return True
else:
print(f" ✗ 登录失败: {response.status_code} - {response.text}")
return False
async def create_thread(self, agent_id: str) -> str:
"""创建对话线程"""
print(f"\n{'='*60}")
print(f"2. 创建对话线程 (agent_id: {agent_id})")
print(f"{'='*60}")
async with self._client() as client:
response = await client.post(
f"{self.base_url}/api/chat/thread",
json={"agent_id": agent_id, "title": "API 测试对话"},
headers=self.headers,
)
if response.status_code == 200:
thread = response.json()
thread_id = thread.get("id")
print(f" ✓ 创建成功! thread_id: {thread_id}")
return thread_id
else:
print(f" ✗ 创建失败: {response.status_code} - {response.text}")
return ""
async def upload_attachment(self, thread_id: str, file_path: str) -> dict | None:
"""上传附件"""
print(f"\n{'='*60}")
print(f"3. 上传附件: {file_path}")
print(f"{'='*60}")
if not os.path.exists(file_path):
print(f" ✗ 文件不存在: {file_path}")
return None
async with self._client(timeout=60.0) as client:
with open(file_path, "rb") as f:
files = {"file": (os.path.basename(file_path), f)}
response = await client.post(
f"{self.base_url}/api/chat/thread/{thread_id}/attachments",
files=files,
headers=self.headers,
)
if response.status_code == 200:
attachment = response.json()
print(f" ✓ 上传成功!")
print(f" file_id: {attachment.get('file_id')}")
print(f" file_name: {attachment.get('file_name')}")
print(f" status: {attachment.get('status')}")
return attachment
else:
print(f" ✗ 上传失败: {response.status_code} - {response.text}")
return None
async def list_attachments(self, thread_id: str) -> list[dict]:
"""列出附件"""
print(f"\n{'='*60}")
print(f"4. 列出附件 (thread_id: {thread_id})")
print(f"{'='*60}")
async with self._client() as client:
response = await client.get(
f"{self.base_url}/api/chat/thread/{thread_id}/attachments",
headers=self.headers,
)
if response.status_code == 200:
data = response.json()
attachments = data.get("attachments", [])
print(f" ✓ 获取到 {len(attachments)} 个附件:")
for att in attachments:
print(f" - {att.get('file_name')}: {att.get('status')}")
return attachments
else:
print(f" ✗ 获取失败: {response.status_code} - {response.text}")
return []
async def get_agent_state(self, agent_id: str, thread_id: str) -> dict | None:
"""获取 agent state"""
print(f"\n{'='*60}")
print(f"5. 获取 Agent State (agent_id: {agent_id}, thread_id: {thread_id})")
print(f"{'='*60}")
async with self._client() as client:
response = await client.get(
f"{self.base_url}/api/chat/agent/{agent_id}/state",
params={"thread_id": thread_id},
headers=self.headers,
)
if response.status_code == 200:
state = response.json()
agent_state = state.get("agent_state", {})
print(f" ✓ 获取成功!")
print(f" files: {len(agent_state.get('files', {}))}")
print(f" todos: {len(agent_state.get('todos', []))}")
if agent_state.get("files"):
print(f" 文件列表:")
for path in agent_state["files"]:
file_info = agent_state["files"][path]
print(f" - {path}: {len(file_info.get('content', []))}")
return state
else:
print(f" ✗ 获取失败: {response.status_code} - {response.text}")
return None
async def send_chat_message(self, agent_id: str, thread_id: str, query: str) -> bool:
"""发送聊天消息(流式)"""
print(f"\n{'='*60}")
print(f"6. 发送聊天消息")
print(f"{'='*60}")
print(f" Query: {query}")
print(f" Thread ID: {thread_id}")
async with self._client(timeout=120.0) as client:
async with client.stream(
"POST",
f"{self.base_url}/api/chat/agent/{agent_id}",
json={"query": query, "config": {"thread_id": thread_id}},
headers=self.headers,
) as response:
print(f"\n 响应状态: {response.status_code}")
print(f" 响应内容:")
async for chunk in response.aiter_lines():
if chunk:
print(f" {chunk[:150]}...")
return response.status_code == 200
async def main():
"""主测试流程"""
print("\n" + "=" * 60)
print(" 附件上传与 Agent State API 测试")
print("=" * 60)
print(f"\nAPI 地址: {API_BASE_URL}")
print(f"测试账户: {USERNAME}")
tester = APITester(API_BASE_URL, USERNAME, PASSWORD)
# 1. 登录
if not await tester.login():
print("\n!!! 登录失败,测试终止 !!!")
return
# 2. 创建线程(使用默认 agent_id
agent_id = DEFAULT_AGENT_ID
thread_id = await tester.create_thread(agent_id)
if not thread_id:
print("\n!!! 创建线程失败,测试终止 !!!")
return
# 3. 创建测试文件
test_content = """# 测试文档
这是一个用于 API 测试的 Markdown 文件
## 主要内容
- 第一点
- 第二点
- 第三点
```python
def hello():
print("Hello, World!")
```
"""
test_file_path = f"/tmp/test_attachment_{uuid.uuid4().hex[:8]}.md"
with open(test_file_path, "w", encoding="utf-8") as f:
f.write(test_content)
print(f"\n 测试文件已创建: {test_file_path}")
# 4. 上传附件
attachment = await tester.upload_attachment(thread_id, test_file_path)
# 5. 列出附件
await tester.list_attachments(thread_id)
# 6. 获取 agent state (验证附件是否在 state 中)
if attachment:
print("\n 等待后端处理...")
await asyncio.sleep(2)
await tester.get_agent_state(agent_id, thread_id)
# 7. 发送聊天消息测试
await tester.send_chat_message(
agent_id,
thread_id,
"你好,请简单介绍一下你自己。"
)
# 8. 再次获取 agent state (验证 todos 等状态)
await asyncio.sleep(1)
await tester.get_agent_state(agent_id, thread_id)
# 清理测试文件
if os.path.exists(test_file_path):
os.remove(test_file_path)
print(f"\n 测试文件已清理: {test_file_path}")
print(f"\n{'='*60}")
print(" 测试完成!")
print(f"{'='*60}\n")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -338,12 +338,21 @@ const currentAgentState = computed(() => {
})
const countFiles = (files) => {
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 = () => {

View File

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

View File

@ -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%;

View File

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

View File

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