diff --git a/AGENTS.md b/AGENTS.md
index cbc2064b..5f43de26 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 来格式化代码。
其他:
diff --git a/docs/advanced/agents-config.md b/docs/advanced/agents-config.md
index 73c34583..01111f00 100644
--- a/docs/advanced/agents-config.md
+++ b/docs/advanced/agents-config.md
@@ -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 集成
diff --git a/docs/changelog/roadmap.md b/docs/changelog/roadmap.md
index 951cce09..7cad8d58 100644
--- a/docs/changelog/roadmap.md
+++ b/docs/changelog/roadmap.md
@@ -18,6 +18,7 @@
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
+- 消息中断没有达到预期效果,看不到截断的消息
### 新增
- 优化知识库详情页面,更加简洁清晰
diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py
index 106bdc29..939ae6b1 100644
--- a/server/routers/chat_router.py
+++ b/server/routers/chat_router.py
@@ -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": "附件已删除"}
+
+
# =============================================================================
# > === 消息反馈分组 ===
# =============================================================================
diff --git a/src/agents/chatbot/graph.py b/src/agents/chatbot/graph.py
index 28ffa0ef..fa9296ca 100644
--- a/src/agents/chatbot/graph.py
+++ b/src/agents/chatbot/graph.py
@@ -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 工具注册)
],
diff --git a/src/agents/common/base.py b/src/agents/common/base.py
index e0247ed2..1e889b3a 100644
--- a/src/agents/common/base.py
+++ b/src/agents/common/base.py
@@ -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 {}
\ No newline at end of file
+ return {}
diff --git a/src/agents/common/middlewares/__init__.py b/src/agents/common/middlewares/__init__.py
index fc571db1..5436dbcc 100644
--- a/src/agents/common/middlewares/__init__.py
+++ b/src/agents/common/middlewares/__init__.py
@@ -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",
]
diff --git a/src/agents/common/middlewares/attachment_middleware.py b/src/agents/common/middlewares/attachment_middleware.py
new file mode 100644
index 00000000..910f345e
--- /dev/null
+++ b/src/agents/common/middlewares/attachment_middleware.py
@@ -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()
diff --git a/src/services/doc_converter.py b/src/services/doc_converter.py
new file mode 100644
index 00000000..00c4637a
--- /dev/null
+++ b/src/services/doc_converter.py
@@ -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)
diff --git a/src/storage/conversation/manager.py b/src/storage/conversation/manager.py
index 2d590619..5948775d 100644
--- a/src/storage/conversation/manager.py
+++ b/src/storage/conversation/manager.py
@@ -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
diff --git a/src/storage/db/manager.py b/src/storage/db/manager.py
index b301540c..68105f72 100644
--- a/src/storage/db/manager.py
+++ b/src/storage/db/manager.py
@@ -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)
diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js
index 32643400..837a3e0c 100644
--- a/web/src/apis/agent_api.js
+++ b/web/src/apis/agent_api.js
@@ -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}`)
};
diff --git a/web/src/apis/base.js b/web/src/apis/base.js
index 4e019fb6..382023ae 100644
--- a/web/src/apis/base.js
+++ b/web/src/apis/base.js
@@ -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,
},
}
diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue
index 2acdca44..118a4500 100644
--- a/web/src/components/AgentChatComponent.vue
+++ b/web/src/components/AgentChatComponent.vue
@@ -70,7 +70,19 @@
placeholder="输入问题..."
@send="handleSendOrStop"
@keydown="handleKeyDown"
- />
+ >
+
+
请注意辨别内容的可靠性