Skip to content

Commit 8b4b7c2

Browse files
committed
feat: 添加附件上传中间件和管理功能
- 实现对话线程的附件上传、列出和删除功能 - 引入附件中间件以支持附件内容的上下文注入 - 更新智能体和对话管理器以处理附件元数据 - 增强前端组件以支持文件上传和显示附件状态 - 优化文档以反映新功能和使用示例
1 parent 6361602 commit 8b4b7c2

File tree

15 files changed

+825
-32
lines changed

15 files changed

+825
-32
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Yuxi-Know 是一个基于知识图谱和向量数据库的智能知识库系统
99

1010
本项目完全通过 Docker Compose 进行管理。所有开发和调试都应在运行的容器环境中进行。使用 `docker compose up -d` 命令进行构建和启动。
1111

12-
核心原则: 由于 api-dev 和 web-dev 服务均配置了热重载 (hot-reloading),本地修改代码后无需重启容器,服务会自动更新。应该先检查项目是否已经在后台启动(`docker ps`),具体的可以阅读 [docker-compose.yml](docker-compose.yml).
12+
核心原则: 由于 api-dev 和 web-dev 服务均配置了热重载 (hot-reloading),本地修改代码后无需重启容器,服务会自动更新。应该先检查项目是否已经在后台启动(`docker ps`),查看日志(`docker logs api-dev --tail 100`具体的可以阅读 [docker-compose.yml](docker-compose.yml).
1313

1414
前端开发规范:
1515

@@ -22,7 +22,7 @@ Yuxi-Know 是一个基于知识图谱和向量数据库的智能知识库系统
2222
后端开发规范:
2323

2424
- 项目使用 uv 来管理依赖,所以需要使用 uv run 来调试。
25-
- Python 代码要符合 Python 的规范,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 make lint 检查 lint。使用 make format 来格式化代码。
25+
- Python 代码要符合 Python 的规范,符合 pythonic 风格,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 make lint 检查 lint。使用 make format 来格式化代码。
2626

2727
其他:
2828

docs/advanced/agents-config.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,55 @@
5252

5353
子智能体集中放在 `src/agents/common/subagents` 目录,典型例子是 `calc_agent`,它通过 LangChain 的 `create_agent` 构建计算器能力并以工具暴露给主图。新增子智能体时沿用这一结构:在目录内编写封装函数与 `@tool` 装饰器,导出后即可被任意智能体调用。
5454

55-
中间件位于 `src/agents/common/middlewares`,包含上下文感知提示词、模型选择以及动态工具加载等实现。如果需要编写新的中间件,请遵循 LangChain 官方文档中对 `AgentMiddleware``ModelRequest``ModelResponse` 等接口的定义,完成后在该目录的 `__init__.py` 暴露入口,主智能体即可在 `middleware` 列表中引用。
55+
中间件位于 `src/agents/common/middlewares`,包含上下文感知提示词、模型选择、动态工具加载以及附件注入等实现。如果需要编写新的中间件,请遵循 LangChain 官方文档中对 `AgentMiddleware``ModelRequest``ModelResponse` 等接口的定义,完成后在该目录的 `__init__.py` 暴露入口,主智能体即可在 `middleware` 列表中引用。
56+
57+
#### 文件上传中间件
58+
59+
文件上传功能通过 `inject_attachment_context` 中间件实现(位于 `src/agents/common/middlewares/attachment_middleware.py`)。该中间件基于 LangChain 1.0 的 `AgentMiddleware` 标准实现,具有以下特点:
60+
61+
1. **状态扩展**:定义 `AttachmentState` 扩展 `AgentState`,添加可选的 `attachments` 字段
62+
2. **自动注入**:在模型调用前,从 `request.state` 中读取附件并转换为 `SystemMessage`
63+
3. **向后兼容**:不使用文件上传的智能体不受影响
64+
65+
##### 为智能体启用文件上传
66+
67+
只需两步:
68+
69+
**步骤 1:声明能力**(让前端显示上传按钮)
70+
71+
```python
72+
class MyAgent(BaseAgent):
73+
capabilities = ["file_upload"]
74+
```
75+
76+
**步骤 2:添加中间件**(让智能体能够处理附件内容)
77+
78+
```python
79+
from src.agents.common.middlewares import inject_attachment_context
80+
81+
async def get_graph(self):
82+
graph = create_agent(
83+
model=load_chat_model("..."),
84+
tools=get_tools(),
85+
middleware=[
86+
inject_attachment_context, # 添加附件中间件
87+
context_aware_prompt, # 其他中间件...
88+
# ...
89+
],
90+
checkpointer=await self._get_checkpointer(),
91+
)
92+
return graph
93+
```
94+
95+
##### 工作流程
96+
97+
1. **前端上传**:用户在聊天界面上传文档(txt、md、docx、html)
98+
2. **API 解析**:后端将文档转换为 Markdown 格式并存储到数据库(超过 32k 会被截断)
99+
3. **自动加载**:API 层在调用 agent 前从数据库加载附件数据
100+
4. **中间件注入**`inject_attachment_context` 自动将附件内容注入为系统消息
101+
5. **模型处理**:LLM 接收到附件内容和用户问题,进行综合回答
102+
103+
这种设计确保了附件功能的可选性和可扩展性,任何智能体都可以通过添加中间件快速启用文件上传能力。
56104

57105
## 内置工具与 MCP 集成
58106

docs/changelog/roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
### Bugs
2020
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
21+
- 消息中断没有达到预期效果,看不到截断的消息
2122

2223
### 新增
2324
- 优化知识库详情页面,更加简洁清晰

server/routers/chat_router.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
import json
33
import traceback
44
import uuid
5-
from pathlib import Path
65

7-
from fastapi import APIRouter, Body, Depends, HTTPException
6+
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
87
from fastapi.responses import StreamingResponse
98
from langchain.messages import AIMessageChunk, HumanMessage
109
from langgraph.types import Command
@@ -22,6 +21,12 @@
2221
from src.agents.common.tools import gen_tool_info, get_buildin_tools
2322
from src.models import select_model
2423
from src.plugins.guard import content_guard
24+
from src.services.doc_converter import (
25+
ATTACHMENT_ALLOWED_EXTENSIONS,
26+
MAX_ATTACHMENT_SIZE_BYTES,
27+
convert_upload_to_markdown,
28+
)
29+
from src.utils.datetime_utils import utc_isoformat
2530
from src.utils.logging_config import logger
2631

2732
chat = APIRouter(prefix="/chat", tags=["chat"])
@@ -156,6 +161,25 @@ def _save_tool_message(conv_mgr, msg_dict):
156161
logger.warning(f"Tool call {tool_call_id} not found for update")
157162

158163

164+
def _require_user_conversation(conv_mgr: ConversationManager, thread_id: str, user_id: str) -> Conversation:
165+
conversation = conv_mgr.get_conversation_by_thread_id(thread_id)
166+
if not conversation or conversation.user_id != str(user_id) or conversation.status == "deleted":
167+
raise HTTPException(status_code=404, detail="对话线程不存在")
168+
return conversation
169+
170+
171+
def _serialize_attachment(record: dict) -> dict:
172+
return {
173+
"file_id": record.get("file_id"),
174+
"file_name": record.get("file_name"),
175+
"file_type": record.get("file_type"),
176+
"file_size": record.get("file_size", 0),
177+
"status": record.get("status", "parsed"),
178+
"uploaded_at": record.get("uploaded_at"),
179+
"truncated": record.get("truncated", False),
180+
}
181+
182+
159183
async def save_messages_from_langgraph_state(
160184
agent_instance,
161185
thread_id,
@@ -313,7 +337,8 @@ async def get_agent(current_user: User = Depends(get_required_user)):
313337
"description": agent_info.get("description", ""),
314338
"examples": agent_info.get("examples", []),
315339
"configurable_items": agent_info.get("configurable_items", []),
316-
"has_checkpointer": agent_info.get("has_checkpointer", False)
340+
"has_checkpointer": agent_info.get("has_checkpointer", False),
341+
"capabilities": agent_info.get("capabilities", []) # 智能体能力列表
317342
}
318343
for agent_info in agents_info
319344
]
@@ -401,6 +426,15 @@ async def stream_messages():
401426
except Exception as e:
402427
logger.error(f"Error saving user message: {e}")
403428

429+
try:
430+
assert thread_id, "thread_id is required"
431+
attachments = conv_manager.get_attachments_by_thread_id(thread_id)
432+
input_context["attachments"] = attachments
433+
logger.debug(f"Loaded {len(attachments)} attachments for thread_id={thread_id}")
434+
except Exception as e:
435+
logger.error(f"Error loading attachments for thread_id={thread_id}: {e}")
436+
input_context["attachments"] = []
437+
404438
try:
405439
full_msg = None
406440
async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
@@ -739,6 +773,26 @@ class ThreadResponse(BaseModel):
739773
updated_at: str
740774

741775

776+
class AttachmentResponse(BaseModel):
777+
file_id: str
778+
file_name: str
779+
file_type: str | None = None
780+
file_size: int
781+
status: str
782+
uploaded_at: str
783+
truncated: bool | None = False
784+
785+
786+
class AttachmentLimits(BaseModel):
787+
allowed_extensions: list[str]
788+
max_size_bytes: int
789+
790+
791+
class AttachmentListResponse(BaseModel):
792+
attachments: list[AttachmentResponse]
793+
limits: AttachmentLimits
794+
795+
742796
# =============================================================================
743797
# > === 会话管理分组 ===
744798
# =============================================================================
@@ -859,6 +913,75 @@ async def update_thread(
859913
}
860914

861915

916+
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
917+
async def upload_thread_attachment(
918+
thread_id: str,
919+
file: UploadFile = File(...),
920+
db: Session = Depends(get_db),
921+
current_user: User = Depends(get_required_user),
922+
):
923+
"""上传并解析附件为 Markdown,附加到指定对话线程。"""
924+
conv_manager = ConversationManager(db)
925+
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
926+
927+
try:
928+
conversion = await convert_upload_to_markdown(file)
929+
except ValueError as exc:
930+
raise HTTPException(status_code=400, detail=str(exc)) from exc
931+
except Exception as exc: # noqa: BLE001
932+
logger.error(f"附件解析失败: {exc}")
933+
raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from exc
934+
935+
attachment_record = {
936+
"file_id": conversion.file_id,
937+
"file_name": conversion.file_name,
938+
"file_type": conversion.file_type,
939+
"file_size": conversion.file_size,
940+
"status": "parsed",
941+
"markdown": conversion.markdown,
942+
"uploaded_at": utc_isoformat(),
943+
"truncated": conversion.truncated,
944+
}
945+
conv_manager.add_attachment(conversation.id, attachment_record)
946+
947+
return _serialize_attachment(attachment_record)
948+
949+
950+
@chat.get("/thread/{thread_id}/attachments", response_model=AttachmentListResponse)
951+
async def list_thread_attachments(
952+
thread_id: str,
953+
db: Session = Depends(get_db),
954+
current_user: User = Depends(get_required_user),
955+
):
956+
"""列出当前对话线程的所有附件元信息。"""
957+
conv_manager = ConversationManager(db)
958+
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
959+
attachments = conv_manager.get_attachments(conversation.id)
960+
return {
961+
"attachments": [_serialize_attachment(item) for item in attachments],
962+
"limits": {
963+
"allowed_extensions": sorted(ATTACHMENT_ALLOWED_EXTENSIONS),
964+
"max_size_bytes": MAX_ATTACHMENT_SIZE_BYTES,
965+
},
966+
}
967+
968+
969+
@chat.delete("/thread/{thread_id}/attachments/{file_id}")
970+
async def delete_thread_attachment(
971+
thread_id: str,
972+
file_id: str,
973+
db: Session = Depends(get_db),
974+
current_user: User = Depends(get_required_user),
975+
):
976+
"""移除指定附件。"""
977+
conv_manager = ConversationManager(db)
978+
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
979+
removed = conv_manager.remove_attachment(conversation.id, file_id)
980+
if not removed:
981+
raise HTTPException(status_code=404, detail="附件不存在或已被删除")
982+
return {"message": "附件已删除"}
983+
984+
862985
# =============================================================================
863986
# > === 消息反馈分组 ===
864987
# =============================================================================

src/agents/chatbot/graph.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from src.agents.common import BaseAgent, load_chat_model
44
from src.agents.common.mcp import MCP_SERVERS
5-
from src.agents.common.middlewares import DynamicToolMiddleware, context_aware_prompt, context_based_model
5+
from src.agents.common.middlewares import (
6+
DynamicToolMiddleware,
7+
context_aware_prompt,
8+
context_based_model,
9+
inject_attachment_context,
10+
)
611
from src.agents.common.subagents import calc_agent_tool
712

813
from .context import Context
@@ -12,6 +17,7 @@
1217
class ChatbotAgent(BaseAgent):
1318
name = "智能体助手"
1419
description = "基础的对话机器人,可以回答问题,默认不使用任何工具,可在配置中启用需要的工具。"
20+
capabilities = ["file_upload"] # 支持文件上传功能
1521

1622
def __init__(self, **kwargs):
1723
super().__init__(**kwargs)
@@ -44,6 +50,7 @@ async def get_graph(self, **kwargs):
4450
tools=get_tools(), # 注册基础工具
4551
middleware=[
4652
context_aware_prompt, # 动态系统提示词
53+
inject_attachment_context, # 附件上下文注入(LangChain 标准中间件)
4754
context_based_model, # 动态模型选择
4855
dynamic_tool_middleware, # 动态工具选择(支持 MCP 工具注册)
4956
],

src/agents/common/base.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class BaseAgent:
2323

2424
name = "base_agent"
2525
description = "base_agent"
26+
capabilities: list[str] = [] # 智能体能力列表,如 ["file_upload", "web_search"] 等
2627

2728
def __init__(self, **kwargs):
2829
self.graph = None # will be covered by get_graph
@@ -54,6 +55,7 @@ async def get_info(self):
5455
"examples": metadata.get("examples", []),
5556
"configurable_items": self.context_schema.get_configurable_items(),
5657
"has_checkpointer": await self.check_checkpointer(),
58+
"capabilities": getattr(self, "capabilities", []), # 智能体能力列表
5759
}
5860

5961
async def get_config(self):
@@ -70,18 +72,31 @@ async def stream_messages(self, messages: list[str], input_context=None, **kwarg
7072
context = self.context_schema.from_file(module_name=self.module_name, input_context=input_context)
7173
logger.debug(f"stream_messages: {context}")
7274
# TODO Checkpointer 似乎还没有适配最新的 1.0 Context API
75+
76+
# 从 input_context 中提取 attachments(如果有)
77+
attachments = (input_context or {}).get("attachments", [])
7378
input_config = {"configurable": input_context, "recursion_limit": 100}
79+
7480
async for msg, metadata in graph.astream(
75-
{"messages": messages}, stream_mode="messages", context=context, config=input_config
81+
{"messages": messages, "attachments": attachments},
82+
stream_mode="messages",
83+
context=context,
84+
config=input_config,
7685
):
7786
yield msg, metadata
7887

7988
async def invoke_messages(self, messages: list[str], input_context=None, **kwargs):
8089
graph = await self.get_graph()
8190
context = self.context_schema.from_file(module_name=self.module_name, input_context=input_context)
8291
logger.debug(f"invoke_messages: {context}")
92+
93+
# 从 input_context 中提取 attachments(如果有)
94+
attachments = (input_context or {}).get("attachments", [])
8395
input_config = {"configurable": input_context, "recursion_limit": 100}
84-
msg = await graph.ainvoke({"messages": messages}, context=context, config=input_config)
96+
97+
msg = await graph.ainvoke(
98+
{"messages": messages, "attachments": attachments}, context=context, config=input_config
99+
)
85100
return msg
86101

87102
async def check_checkpointer(self):
@@ -186,4 +201,4 @@ def load_metadata(self) -> dict:
186201
except Exception as e:
187202
logger.error(f"Error loading metadata for {self.module_name}: {e}")
188203
self._metadata_cache = {}
189-
return {}
204+
return {}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from .context_middlewares import context_aware_prompt, context_based_model
22
from .dynamic_tool_middleware import DynamicToolMiddleware
3+
from .attachment_middleware import inject_attachment_context
34

45
__all__ = [
56
"DynamicToolMiddleware",
67
"context_aware_prompt",
78
"context_based_model",
9+
"inject_attachment_context",
810
]

0 commit comments

Comments
 (0)