feat: 添加附件上传中间件和管理功能

- 实现对话线程的附件上传、列出和删除功能
- 引入附件中间件以支持附件内容的上下文注入
- 更新智能体和对话管理器以处理附件元数据
- 增强前端组件以支持文件上传和显示附件状态
- 优化文档以反映新功能和使用示例
This commit is contained in:
Wenjie Zhang 2025-11-08 10:51:30 +08:00
parent 6361602851
commit 8b4b7c230c
15 changed files with 825 additions and 32 deletions

View File

@ -9,7 +9,7 @@ Yuxi-Know 是一个基于知识图谱和向量数据库的智能知识库系统
本项目完全通过 Docker Compose 进行管理。所有开发和调试都应在运行的容器环境中进行。使用 `docker compose up -d` 命令进行构建和启动。
核心原则: 由于 api-dev 和 web-dev 服务均配置了热重载 (hot-reloading),本地修改代码后无需重启容器,服务会自动更新。应该先检查项目是否已经在后台启动(`docker ps`),具体的可以阅读 [docker-compose.yml](docker-compose.yml).
核心原则: 由于 api-dev 和 web-dev 服务均配置了热重载 (hot-reloading),本地修改代码后无需重启容器,服务会自动更新。应该先检查项目是否已经在后台启动(`docker ps`查看日志(`docker logs api-dev --tail 100`具体的可以阅读 [docker-compose.yml](docker-compose.yml).
前端开发规范:
@ -22,7 +22,7 @@ Yuxi-Know 是一个基于知识图谱和向量数据库的智能知识库系统
后端开发规范:
- 项目使用 uv 来管理依赖,所以需要使用 uv run 来调试。
- Python 代码要符合 Python 的规范,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 make lint 检查 lint。使用 make format 来格式化代码。
- Python 代码要符合 Python 的规范,符合 pythonic 风格,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 make lint 检查 lint。使用 make format 来格式化代码。
其他:

View File

@ -52,7 +52,55 @@
子智能体集中放在 `src/agents/common/subagents` 目录,典型例子是 `calc_agent`,它通过 LangChain 的 `create_agent` 构建计算器能力并以工具暴露给主图。新增子智能体时沿用这一结构:在目录内编写封装函数与 `@tool` 装饰器,导出后即可被任意智能体调用。
中间件位于 `src/agents/common/middlewares`,包含上下文感知提示词、模型选择以及动态工具加载等实现。如果需要编写新的中间件,请遵循 LangChain 官方文档中对 `AgentMiddleware`、`ModelRequest`、`ModelResponse` 等接口的定义,完成后在该目录的 `__init__.py` 暴露入口,主智能体即可在 `middleware` 列表中引用。
中间件位于 `src/agents/common/middlewares`,包含上下文感知提示词、模型选择、动态工具加载以及附件注入等实现。如果需要编写新的中间件,请遵循 LangChain 官方文档中对 `AgentMiddleware`、`ModelRequest`、`ModelResponse` 等接口的定义,完成后在该目录的 `__init__.py` 暴露入口,主智能体即可在 `middleware` 列表中引用。
#### 文件上传中间件
文件上传功能通过 `inject_attachment_context` 中间件实现(位于 `src/agents/common/middlewares/attachment_middleware.py`)。该中间件基于 LangChain 1.0 的 `AgentMiddleware` 标准实现,具有以下特点:
1. **状态扩展**:定义 `AttachmentState` 扩展 `AgentState`,添加可选的 `attachments` 字段
2. **自动注入**:在模型调用前,从 `request.state` 中读取附件并转换为 `SystemMessage`
3. **向后兼容**:不使用文件上传的智能体不受影响
##### 为智能体启用文件上传
只需两步:
**步骤 1声明能力**(让前端显示上传按钮)
```python
class MyAgent(BaseAgent):
capabilities = ["file_upload"]
```
**步骤 2添加中间件**(让智能体能够处理附件内容)
```python
from src.agents.common.middlewares import inject_attachment_context
async def get_graph(self):
graph = create_agent(
model=load_chat_model("..."),
tools=get_tools(),
middleware=[
inject_attachment_context, # 添加附件中间件
context_aware_prompt, # 其他中间件...
# ...
],
checkpointer=await self._get_checkpointer(),
)
return graph
```
##### 工作流程
1. **前端上传**用户在聊天界面上传文档txt、md、docx、html
2. **API 解析**:后端将文档转换为 Markdown 格式并存储到数据库(超过 32k 会被截断)
3. **自动加载**API 层在调用 agent 前从数据库加载附件数据
4. **中间件注入**`inject_attachment_context` 自动将附件内容注入为系统消息
5. **模型处理**LLM 接收到附件内容和用户问题,进行综合回答
这种设计确保了附件功能的可选性和可扩展性,任何智能体都可以通过添加中间件快速启用文件上传能力。
## 内置工具与 MCP 集成

View File

@ -18,6 +18,7 @@
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
- 消息中断没有达到预期效果,看不到截断的消息
### 新增
- 优化知识库详情页面,更加简洁清晰

View File

@ -2,9 +2,8 @@ import asyncio
import json
import traceback
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from langchain.messages import AIMessageChunk, HumanMessage
from langgraph.types import Command
@ -22,6 +21,12 @@ from src.agents import agent_manager
from src.agents.common.tools import gen_tool_info, get_buildin_tools
from src.models import select_model
from src.plugins.guard import content_guard
from src.services.doc_converter import (
ATTACHMENT_ALLOWED_EXTENSIONS,
MAX_ATTACHMENT_SIZE_BYTES,
convert_upload_to_markdown,
)
from src.utils.datetime_utils import utc_isoformat
from src.utils.logging_config import logger
chat = APIRouter(prefix="/chat", tags=["chat"])
@ -156,6 +161,25 @@ def _save_tool_message(conv_mgr, msg_dict):
logger.warning(f"Tool call {tool_call_id} not found for update")
def _require_user_conversation(conv_mgr: ConversationManager, thread_id: str, user_id: str) -> Conversation:
conversation = conv_mgr.get_conversation_by_thread_id(thread_id)
if not conversation or conversation.user_id != str(user_id) or conversation.status == "deleted":
raise HTTPException(status_code=404, detail="对话线程不存在")
return conversation
def _serialize_attachment(record: dict) -> dict:
return {
"file_id": record.get("file_id"),
"file_name": record.get("file_name"),
"file_type": record.get("file_type"),
"file_size": record.get("file_size", 0),
"status": record.get("status", "parsed"),
"uploaded_at": record.get("uploaded_at"),
"truncated": record.get("truncated", False),
}
async def save_messages_from_langgraph_state(
agent_instance,
thread_id,
@ -313,7 +337,8 @@ async def get_agent(current_user: User = Depends(get_required_user)):
"description": agent_info.get("description", ""),
"examples": agent_info.get("examples", []),
"configurable_items": agent_info.get("configurable_items", []),
"has_checkpointer": agent_info.get("has_checkpointer", False)
"has_checkpointer": agent_info.get("has_checkpointer", False),
"capabilities": agent_info.get("capabilities", []) # 智能体能力列表
}
for agent_info in agents_info
]
@ -401,6 +426,15 @@ async def chat_agent(
except Exception as e:
logger.error(f"Error saving user message: {e}")
try:
assert thread_id, "thread_id is required"
attachments = conv_manager.get_attachments_by_thread_id(thread_id)
input_context["attachments"] = attachments
logger.debug(f"Loaded {len(attachments)} attachments for thread_id={thread_id}")
except Exception as e:
logger.error(f"Error loading attachments for thread_id={thread_id}: {e}")
input_context["attachments"] = []
try:
full_msg = None
async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
@ -739,6 +773,26 @@ class ThreadResponse(BaseModel):
updated_at: str
class AttachmentResponse(BaseModel):
file_id: str
file_name: str
file_type: str | None = None
file_size: int
status: str
uploaded_at: str
truncated: bool | None = False
class AttachmentLimits(BaseModel):
allowed_extensions: list[str]
max_size_bytes: int
class AttachmentListResponse(BaseModel):
attachments: list[AttachmentResponse]
limits: AttachmentLimits
# =============================================================================
# > === 会话管理分组 ===
# =============================================================================
@ -859,6 +913,75 @@ async def update_thread(
}
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
async def upload_thread_attachment(
thread_id: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""上传并解析附件为 Markdown附加到指定对话线程。"""
conv_manager = ConversationManager(db)
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
try:
conversion = await convert_upload_to_markdown(file)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
logger.error(f"附件解析失败: {exc}")
raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from exc
attachment_record = {
"file_id": conversion.file_id,
"file_name": conversion.file_name,
"file_type": conversion.file_type,
"file_size": conversion.file_size,
"status": "parsed",
"markdown": conversion.markdown,
"uploaded_at": utc_isoformat(),
"truncated": conversion.truncated,
}
conv_manager.add_attachment(conversation.id, attachment_record)
return _serialize_attachment(attachment_record)
@chat.get("/thread/{thread_id}/attachments", response_model=AttachmentListResponse)
async def list_thread_attachments(
thread_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""列出当前对话线程的所有附件元信息。"""
conv_manager = ConversationManager(db)
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
attachments = conv_manager.get_attachments(conversation.id)
return {
"attachments": [_serialize_attachment(item) for item in attachments],
"limits": {
"allowed_extensions": sorted(ATTACHMENT_ALLOWED_EXTENSIONS),
"max_size_bytes": MAX_ATTACHMENT_SIZE_BYTES,
},
}
@chat.delete("/thread/{thread_id}/attachments/{file_id}")
async def delete_thread_attachment(
thread_id: str,
file_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""移除指定附件。"""
conv_manager = ConversationManager(db)
conversation = _require_user_conversation(conv_manager, thread_id, str(current_user.id))
removed = conv_manager.remove_attachment(conversation.id, file_id)
if not removed:
raise HTTPException(status_code=404, detail="附件不存在或已被删除")
return {"message": "附件已删除"}
# =============================================================================
# > === 消息反馈分组 ===
# =============================================================================

View File

@ -2,7 +2,12 @@ from langchain.agents import create_agent
from src.agents.common import BaseAgent, load_chat_model
from src.agents.common.mcp import MCP_SERVERS
from src.agents.common.middlewares import DynamicToolMiddleware, context_aware_prompt, context_based_model
from src.agents.common.middlewares import (
DynamicToolMiddleware,
context_aware_prompt,
context_based_model,
inject_attachment_context,
)
from src.agents.common.subagents import calc_agent_tool
from .context import Context
@ -12,6 +17,7 @@ from .tools import get_tools
class ChatbotAgent(BaseAgent):
name = "智能体助手"
description = "基础的对话机器人,可以回答问题,默认不使用任何工具,可在配置中启用需要的工具。"
capabilities = ["file_upload"] # 支持文件上传功能
def __init__(self, **kwargs):
super().__init__(**kwargs)
@ -44,6 +50,7 @@ class ChatbotAgent(BaseAgent):
tools=get_tools(), # 注册基础工具
middleware=[
context_aware_prompt, # 动态系统提示词
inject_attachment_context, # 附件上下文注入LangChain 标准中间件)
context_based_model, # 动态模型选择
dynamic_tool_middleware, # 动态工具选择(支持 MCP 工具注册)
],

View File

@ -23,6 +23,7 @@ class BaseAgent:
name = "base_agent"
description = "base_agent"
capabilities: list[str] = [] # 智能体能力列表,如 ["file_upload", "web_search"] 等
def __init__(self, **kwargs):
self.graph = None # will be covered by get_graph
@ -54,6 +55,7 @@ class BaseAgent:
"examples": metadata.get("examples", []),
"configurable_items": self.context_schema.get_configurable_items(),
"has_checkpointer": await self.check_checkpointer(),
"capabilities": getattr(self, "capabilities", []), # 智能体能力列表
}
async def get_config(self):
@ -70,9 +72,16 @@ class BaseAgent:
context = self.context_schema.from_file(module_name=self.module_name, input_context=input_context)
logger.debug(f"stream_messages: {context}")
# TODO Checkpointer 似乎还没有适配最新的 1.0 Context API
# 从 input_context 中提取 attachments如果有
attachments = (input_context or {}).get("attachments", [])
input_config = {"configurable": input_context, "recursion_limit": 100}
async for msg, metadata in graph.astream(
{"messages": messages}, stream_mode="messages", context=context, config=input_config
{"messages": messages, "attachments": attachments},
stream_mode="messages",
context=context,
config=input_config,
):
yield msg, metadata
@ -80,8 +89,14 @@ class BaseAgent:
graph = await self.get_graph()
context = self.context_schema.from_file(module_name=self.module_name, input_context=input_context)
logger.debug(f"invoke_messages: {context}")
# 从 input_context 中提取 attachments如果有
attachments = (input_context or {}).get("attachments", [])
input_config = {"configurable": input_context, "recursion_limit": 100}
msg = await graph.ainvoke({"messages": messages}, context=context, config=input_config)
msg = await graph.ainvoke(
{"messages": messages, "attachments": attachments}, context=context, config=input_config
)
return msg
async def check_checkpointer(self):
@ -186,4 +201,4 @@ class BaseAgent:
except Exception as e:
logger.error(f"Error loading metadata for {self.module_name}: {e}")
self._metadata_cache = {}
return {}
return {}

View File

@ -1,8 +1,10 @@
from .context_middlewares import context_aware_prompt, context_based_model
from .dynamic_tool_middleware import DynamicToolMiddleware
from .attachment_middleware import inject_attachment_context
__all__ = [
"DynamicToolMiddleware",
"context_aware_prompt",
"context_based_model",
"inject_attachment_context",
]

View File

@ -0,0 +1,89 @@
"""附件注入中间件 - 使用 LangChain 标准中间件实现"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import NotRequired
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from src.utils import logger
class AttachmentState(AgentState):
"""扩展 AgentState 以支持附件"""
attachments: NotRequired[list[dict]]
def _build_attachment_prompt(attachments: Sequence[dict]) -> str | None:
"""Render attachments into a single system prompt block."""
if not attachments:
return None
chunks: list[str] = []
for idx, attachment in enumerate(attachments, 1):
if attachment.get("status") != "parsed":
continue
markdown = attachment.get("markdown")
if not markdown:
continue
file_name = attachment.get("file_name") or f"附件 {idx}"
truncated = "(已截断)" if attachment.get("truncated") else ""
header = f"### 附件 {idx}: {file_name}{truncated}"
chunks.append(f"{header}\n\n{markdown}".strip())
if not chunks:
return None
instructions = (
"以下为用户提供的附件内容,请综合这些文件与用户的新问题进行回答。"
"如附件与问题无关,可忽略附件内容:\n\n"
)
return instructions + "\n\n".join(chunks)
class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
"""
LangChain 标准中间件 State 中读取附件并注入到消息中
根据官方文档示例
https://docs.langchain.com/oss/python/langchain/middleware
request.state 中读取 attachments将其转换为 SystemMessage 并注入到消息列表开头
NOTE: 缺点是无法命中缓存了
"""
state_schema = AttachmentState
async def awrap_model_call(
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
# Read from State: get uploaded files metadata
logger.debug(f"inject_attachment_context: request.state = {request.state}")
attachments = request.state.get("attachments", [])
if attachments:
# Build attachment context
attachment_prompt = _build_attachment_prompt(attachments)
if attachment_prompt:
logger.debug(f"Injecting {len(attachments)} attachments into model request")
# Inject attachment context at the beginning (as SystemMessage)
# 注意:这是 transient update不会修改 state只影响本次模型调用
messages = [
{"role": "system", "content": attachment_prompt},
*request.messages,
]
request = request.override(messages=messages)
return await handler(request)
# 创建中间件实例,供其他模块使用
inject_attachment_context = AttachmentMiddleware()

View File

@ -0,0 +1,100 @@
"""Helpers for converting uploaded documents into markdown snippets."""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from src.config import config as app_config
from src.knowledge.indexing import process_file_to_markdown
from src.utils import logger
ATTACHMENT_ALLOWED_EXTENSIONS: tuple[str, ...] = (".txt", ".md", ".docx", ".html", ".htm")
MAX_ATTACHMENT_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_ATTACHMENT_MARKDOWN_CHARS = 32_000
@dataclass(slots=True)
class ConversionResult:
"""Represents the normalized output of an uploaded attachment."""
file_id: str
file_name: str
file_type: str | None
file_size: int
markdown: str
truncated: bool
def _ensure_workdir() -> Path:
workdir = Path(app_config.save_dir) / "uploads" / "chat_attachments"
workdir.mkdir(parents=True, exist_ok=True)
return workdir
async def _write_upload_to_disk(upload: UploadFile, dest: Path) -> int:
await upload.seek(0)
written = 0
chunk_size = 1024 * 1024
async with aiofiles.open(dest, "wb") as buffer:
while True:
chunk = await upload.read(chunk_size)
if not chunk:
break
written += len(chunk)
if written > MAX_ATTACHMENT_SIZE_BYTES:
raise ValueError("附件过大,当前仅支持 5 MB 以内的文件")
await buffer.write(chunk)
return written
def _truncate_markdown(markdown: str) -> tuple[str, bool]:
if len(markdown) <= MAX_ATTACHMENT_MARKDOWN_CHARS:
return markdown, False
truncated_content = markdown[: MAX_ATTACHMENT_MARKDOWN_CHARS - 100].rstrip()
truncated_content = f"{truncated_content}\n\n[内容已截断,超出 {MAX_ATTACHMENT_MARKDOWN_CHARS} 字符限制]"
return truncated_content, True
async def convert_upload_to_markdown(upload: UploadFile) -> ConversionResult:
"""Persist an UploadFile temporarily, convert it to markdown, and clean up."""
if not upload.filename:
raise ValueError("无法识别的文件名")
file_name = Path(upload.filename).name
suffix = Path(file_name).suffix.lower()
if suffix not in ATTACHMENT_ALLOWED_EXTENSIONS:
allowed = ", ".join(ATTACHMENT_ALLOWED_EXTENSIONS)
raise ValueError(f"不支持的文件类型: {suffix or '未知'},当前仅支持 {allowed}")
temp_dir = _ensure_workdir()
temp_path = temp_dir / f"{uuid.uuid4().hex}{suffix}"
try:
file_size = await _write_upload_to_disk(upload, temp_path)
markdown = await process_file_to_markdown(str(temp_path))
markdown, truncated = _truncate_markdown(markdown)
return ConversionResult(
file_id=uuid.uuid4().hex,
file_name=file_name,
file_type=upload.content_type,
file_size=file_size,
markdown=markdown,
truncated=truncated,
)
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

@ -45,13 +45,16 @@ class ConversationManager:
if not thread_id:
thread_id = str(uuid.uuid4())
metadata = (metadata or {}).copy()
metadata.setdefault("attachments", [])
conversation = Conversation(
thread_id=thread_id,
user_id=str(user_id),
agent_id=agent_id,
title=title or "New Conversation",
status="active",
extra_metadata=metadata if metadata else None,
extra_metadata=metadata,
)
self.db.add(conversation)
@ -79,6 +82,26 @@ class ConversationManager:
"""
return self.db.query(Conversation).filter(Conversation.thread_id == thread_id).first()
def _get_conversation_by_id(self, conversation_id: int) -> Conversation | None:
return self.db.query(Conversation).filter(Conversation.id == conversation_id).first()
def _ensure_metadata(self, conversation: Conversation) -> dict:
"""
Return a shallow copy of conversation metadata with a standalone attachments list.
We copy here because SQLAlchemy's JSON type does not automatically detect in-place
mutations. By assigning a fresh dict/list back we ensure the ORM marks the row dirty.
"""
metadata = dict(conversation.extra_metadata or {})
metadata["attachments"] = list(metadata.get("attachments", []))
return metadata
def _save_metadata(self, conversation: Conversation, metadata: dict) -> None:
conversation.extra_metadata = metadata
conversation.updated_at = utc_now()
self.db.commit()
self.db.refresh(conversation)
def add_message(
self,
conversation_id: int,
@ -110,7 +133,7 @@ class ConversationManager:
self.db.add(message)
# Mark the parent conversation as active for sorting/analytics
conversation = self.db.query(Conversation).filter(Conversation.id == conversation_id).first()
conversation = self._get_conversation_by_id(conversation_id)
if conversation:
conversation.updated_at = utc_now()
@ -441,3 +464,72 @@ class ConversationManager:
message_count = self.db.query(Message).filter(Message.conversation_id == conversation_id).count()
stats.message_count = message_count
self.db.commit()
# -------------------------------------------------------------------------
# Attachment helpers
# -------------------------------------------------------------------------
def get_attachments(self, conversation_id: int) -> list[dict]:
conversation = self._get_conversation_by_id(conversation_id)
if not conversation:
return []
metadata = self._ensure_metadata(conversation)
return list(metadata.get("attachments", []))
def get_attachments_by_thread_id(self, thread_id: str) -> list[dict]:
conversation = self.get_conversation_by_thread_id(thread_id)
if not conversation:
return []
return self.get_attachments(conversation.id)
def add_attachment(self, conversation_id: int, attachment_info: dict) -> dict | None:
conversation = self._get_conversation_by_id(conversation_id)
if not conversation:
return None
metadata = self._ensure_metadata(conversation)
attachments = metadata.get("attachments", [])
attachments = [item for item in attachments if item.get("file_id") != attachment_info.get("file_id")]
attachments.append(attachment_info)
metadata["attachments"] = attachments
self._save_metadata(conversation, metadata)
return attachment_info
def update_attachment_status(
self, conversation_id: int, file_id: str, status: str, update_fields: dict | None = None
) -> dict | None:
conversation = self._get_conversation_by_id(conversation_id)
if not conversation:
return None
metadata = self._ensure_metadata(conversation)
attachments = metadata.get("attachments", [])
target = None
for item in attachments:
if item.get("file_id") == file_id:
item["status"] = status
if update_fields:
item.update(update_fields)
target = item
break
if target is not None:
metadata["attachments"] = attachments
self._save_metadata(conversation, metadata)
return target
def remove_attachment(self, conversation_id: int, file_id: str) -> bool:
conversation = self._get_conversation_by_id(conversation_id)
if not conversation:
return False
metadata = self._ensure_metadata(conversation)
attachments = metadata.get("attachments", [])
new_attachments = [item for item in attachments if item.get("file_id") != file_id]
if len(new_attachments) == len(attachments):
return False
metadata["attachments"] = new_attachments
self._save_metadata(conversation, metadata)
return True

View File

@ -1,3 +1,4 @@
import json
import os
import pathlib
from contextlib import contextmanager
@ -30,8 +31,13 @@ class DBManager(metaclass=SingletonMeta):
self.db_path = os.path.join(config.save_dir, "database", "server.db")
self.ensure_db_dir()
# 创建SQLAlchemy引擎
self.engine = create_engine(f"sqlite:///{self.db_path}")
# 创建SQLAlchemy引擎配置JSON序列化器以支持中文
# 使用 ensure_ascii=False 确保中文字符不被转义为 Unicode 序列
self.engine = create_engine(
f"sqlite:///{self.db_path}",
json_serializer=lambda obj: json.dumps(obj, ensure_ascii=False),
json_deserializer=json.loads,
)
# 创建会话工厂
self.Session = sessionmaker(bind=self.engine)

View File

@ -1,4 +1,4 @@
import { apiGet, apiPost, apiDelete, apiPut, apiAdminGet, apiAdminPost } from './base'
import { apiGet, apiPost, apiDelete, apiPut, apiAdminGet, apiAdminPost, apiRequest } from './base'
import { useUserStore } from '@/stores/user'
/**
@ -212,5 +212,35 @@ export const threadApi = {
* @param {string} threadId - 对话线程ID
* @returns {Promise} - 删除结果
*/
deleteThread: (threadId) => apiDelete(`/api/chat/thread/${threadId}`)
deleteThread: (threadId) => apiDelete(`/api/chat/thread/${threadId}`),
/**
* 获取线程附件列表
* @param {string} threadId - 对话线程ID
* @returns {Promise}
*/
getThreadAttachments: (threadId) => apiGet(`/api/chat/thread/${threadId}/attachments`),
/**
* 上传附件
* @param {string} threadId
* @param {File} file
* @returns {Promise}
*/
uploadThreadAttachment: (threadId, file) => {
const formData = new FormData()
formData.append('file', file)
return apiRequest(`/api/chat/thread/${threadId}/attachments`, {
method: 'POST',
body: formData
})
},
/**
* 删除附件
* @param {string} threadId
* @param {string} fileId
* @returns {Promise}
*/
deleteThreadAttachment: (threadId, fileId) => apiDelete(`/api/chat/thread/${threadId}/attachments/${fileId}`)
};

View File

@ -16,11 +16,12 @@ import { message } from 'ant-design-vue'
*/
export async function apiRequest(url, options = {}, requiresAuth = true, responseType = 'json') {
try {
const isFormData = options?.body instanceof FormData
// 默认请求配置
const requestOptions = {
...options,
headers: {
'Content-Type': 'application/json',
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...options.headers,
},
}

View File

@ -70,7 +70,19 @@
placeholder="输入问题..."
@send="handleSendOrStop"
@keydown="handleKeyDown"
/>
>
<template #options-left>
<AttachmentInputPanel
v-if="supportsFileUpload"
:attachments="currentAttachments"
:limits="attachmentState.limits"
:is-uploading="attachmentState.isUploading"
:disabled="!currentAgent"
@upload="handleAttachmentUpload"
@remove="handleAttachmentRemove"
/>
</template>
</MessageInputComponent>
<!-- 示例问题 -->
<div class="example-questions" v-if="exampleQuestions.length > 0">
@ -139,7 +151,19 @@
placeholder="输入问题..."
@send="handleSendOrStop"
@keydown="handleKeyDown"
/>
>
<template #options-left>
<AttachmentInputPanel
v-if="supportsFileUpload"
:attachments="currentAttachments"
:limits="attachmentState.limits"
:is-uploading="attachmentState.isUploading"
:disabled="!currentAgent"
@upload="handleAttachmentUpload"
@remove="handleAttachmentRemove"
/>
</template>
</MessageInputComponent>
<div class="bottom-actions">
<p class="note">请注意辨别内容的可靠性</p>
</div>
@ -154,6 +178,7 @@ import { ref, reactive, onMounted, watch, nextTick, computed, onUnmounted } from
import { LoadingOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import MessageInputComponent from '@/components/MessageInputComponent.vue'
import AttachmentInputPanel from '@/components/AttachmentInputPanel.vue'
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
import ChatSidebarComponent from '@/components/ChatSidebarComponent.vue'
import RefsComponent from '@/components/RefsComponent.vue'
@ -229,6 +254,12 @@ const uiState = reactive({
containerWidth: 0,
});
const attachmentState = reactive({
itemsByThread: {},
limits: null,
isUploading: false,
})
// ==================== COMPUTED PROPERTIES ====================
const currentAgentId = computed(() => {
if (props.singleMode) {
@ -258,6 +289,18 @@ const currentThread = computed(() => {
return threads.value.find(thread => thread.id === currentChatId.value) || null;
});
const currentAttachments = computed(() => {
if (!currentChatId.value) return [];
return attachmentState.itemsByThread[currentChatId.value] || [];
});
//
const supportsFileUpload = computed(() => {
if (!currentAgent.value) return false;
const capabilities = currentAgent.value.capabilities || [];
return capabilities.includes('file_upload');
});
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || []);
// Refs
@ -506,6 +549,12 @@ const fetchThreads = async (agentId = null) => {
try {
const fetchedThreads = await threadApi.getThreads(targetAgentId);
threads.value = fetchedThreads || [];
const validIds = new Set((threads.value || []).map(thread => thread.id));
Object.keys(attachmentState.itemsByThread).forEach((id) => {
if (!validIds.has(id)) {
delete attachmentState.itemsByThread[id];
}
});
} catch (error) {
console.error('Failed to fetch threads:', error);
handleChatError(error, 'fetch');
@ -525,6 +574,7 @@ const createThread = async (agentId, title = '新的对话') => {
if (thread) {
threads.value.unshift(thread);
threadMessages.value[thread.id] = [];
attachmentState.itemsByThread[thread.id] = [];
}
return thread;
} catch (error) {
@ -545,6 +595,7 @@ const deleteThread = async (threadId) => {
await threadApi.deleteThread(threadId);
threads.value = threads.value.filter(thread => thread.id !== threadId);
delete threadMessages.value[threadId];
delete attachmentState.itemsByThread[threadId];
if (chatState.currentThreadId === threadId) {
chatState.currentThreadId = null;
@ -591,6 +642,73 @@ const fetchThreadMessages = async ({ agentId, threadId }) => {
}
};
const loadThreadAttachments = async (threadId, { silent = false } = {}) => {
if (!threadId) return;
try {
const response = await threadApi.getThreadAttachments(threadId);
attachmentState.itemsByThread[threadId] = response.attachments || [];
if (response.limits) {
attachmentState.limits = response.limits;
}
} catch (error) {
if (silent) {
console.warn('Failed to load attachments:', error);
} else {
handleChatError(error, 'load');
}
}
};
const ensureActiveThread = async (title = '新的对话') => {
if (currentChatId.value) return currentChatId.value;
try {
const newThread = await createThread(currentAgentId.value, title || '新的对话');
if (newThread) {
chatState.currentThreadId = newThread.id;
return newThread.id;
}
} catch (error) {
// createThread
}
return null;
};
const handleAttachmentUpload = async (files) => {
if (!files?.length) return;
if (!AgentValidator.validateAgentIdWithError(currentAgentId.value, '上传附件', handleValidationError)) return;
const preferredTitle = files[0]?.name || '新的对话';
const threadId = await ensureActiveThread(preferredTitle);
if (!threadId) {
message.error('创建对话失败,无法上传附件');
return;
}
attachmentState.isUploading = true;
try {
for (const file of files) {
await threadApi.uploadThreadAttachment(threadId, file);
message.success(`${file.name} 上传成功`);
}
await loadThreadAttachments(threadId, { silent: true });
} catch (error) {
handleChatError(error, 'upload');
} finally {
attachmentState.isUploading = false;
}
};
const handleAttachmentRemove = async (fileId) => {
if (!fileId || !currentChatId.value) return;
try {
await threadApi.deleteThreadAttachment(currentChatId.value, fileId);
await loadThreadAttachments(currentChatId.value, { silent: true });
message.success('附件已删除');
} catch (error) {
handleChatError(error, 'delete');
}
};
// ==================== ====================
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
getThreadState,
@ -697,6 +815,7 @@ const selectChat = async (chatId) => {
chatState.isLoadingMessages = true;
try {
await fetchThreadMessages({ agentId: currentAgentId.value, threadId: chatId });
await loadThreadAttachments(chatId, { silent: true });
} catch (error) {
handleChatError(error, 'load');
} finally {
@ -737,18 +856,11 @@ const handleSendMessage = async () => {
const text = userInput.value.trim();
if (!text || !currentAgent.value || isProcessing.value) return;
// 线线
if (!currentChatId.value) {
try {
const newThread = await createThread(currentAgentId.value, text);
if (newThread) {
chatState.currentThreadId = newThread.id;
} else {
message.error('创建对话失败,请重试');
return;
}
} catch (error) {
handleChatError(error, 'create');
let threadId = currentChatId.value;
if (!threadId) {
threadId = await ensureActiveThread(text);
if (!threadId) {
message.error('创建对话失败,请重试');
return;
}
}
@ -757,7 +869,6 @@ const handleSendMessage = async () => {
await nextTick();
scrollController.scrollToBottom(true);
const threadId = currentChatId.value;
const threadState = getThreadState(threadId);
if (!threadState) return;

View File

@ -0,0 +1,168 @@
<template>
<div class="attachment-panel">
<label class="attachment-upload" :class="{ disabled: disabled || isUploading }">
<input
type="file"
multiple
accept=".txt,.md,.docx,.html,.htm"
:disabled="disabled || isUploading"
@change="handleFileChange"
/>
<Paperclip size="14" />
<span>{{ isUploading ? '上传中…' : '添加附件' }}</span>
</label>
<p class="attachment-hint" v-if="limits">
支持 {{ extensionsText }}单文件 {{ sizeHint }}
</p>
<div class="attachment-list" v-if="attachments.length">
<div class="attachment-chip" v-for="item in attachments" :key="item.file_id">
<Paperclip size="14" class="chip-icon" />
<span class="chip-name" :title="item.file_name">{{ item.file_name }}</span>
<span class="chip-status" :class="`status-${item.status}`">
{{ statusLabel(item) }}
</span>
<a-tooltip title="移除附件">
<a-button
type="text"
size="small"
class="chip-remove"
:disabled="disabled"
@click="$emit('remove', item.file_id)"
>
<template #icon>
<Trash2 size="14" />
</template>
</a-button>
</a-tooltip>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Paperclip, Trash2 } from 'lucide-vue-next'
const props = defineProps({
attachments: { type: Array, default: () => [] },
limits: { type: Object, default: () => null },
isUploading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['upload', 'remove'])
const extensionsText = computed(() => {
if (!props.limits?.allowed_extensions?.length) return 'txt/md/docx/html'
return props.limits.allowed_extensions.map(item => item.replace('.', '')).join(' / ')
})
const sizeHint = computed(() => {
if (!props.limits?.max_size_bytes) return '5 MB'
const mb = props.limits.max_size_bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
})
const statusLabel = (item) => {
if (item.status === 'parsed') {
return item.truncated ? '已解析(截断)' : '已解析'
}
if (item.status === 'failed') return '解析失败'
return '处理中'
}
const handleFileChange = (event) => {
const files = Array.from(event.target.files || [])
event.target.value = ''
if (!files.length) return
emit('upload', files)
}
</script>
<style scoped>
.attachment-panel {
min-width: 220px;
max-width: 320px;
}
.attachment-upload {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--main-700);
cursor: pointer;
font-weight: 500;
}
.attachment-upload.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.attachment-upload input {
display: none;
}
.attachment-hint {
margin: 4px 0 8px;
font-size: 11px;
color: var(--gray-500);
line-height: 1.4;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
}
.attachment-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--gray-25);
border: 1px solid var(--gray-100);
border-radius: 6px;
}
.chip-icon {
color: var(--gray-600);
}
.chip-name {
flex: 1;
font-size: 12px;
color: var(--gray-800);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-status {
font-size: 11px;
color: var(--gray-600);
}
.chip-status.status-parsed {
color: var(--green-600, #1a7f37);
}
.chip-status.status-failed {
color: var(--red-600, #c62828);
}
.chip-remove {
margin-left: 2px;
color: var(--gray-500);
}
.chip-remove:hover {
color: var(--gray-700);
}
</style>