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" - /> + > + +
@@ -139,7 +151,19 @@ placeholder="输入问题..." @send="handleSendOrStop" @keydown="handleKeyDown" - /> + > + +

请注意辨别内容的可靠性

@@ -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; diff --git a/web/src/components/AttachmentInputPanel.vue b/web/src/components/AttachmentInputPanel.vue new file mode 100644 index 00000000..57d87176 --- /dev/null +++ b/web/src/components/AttachmentInputPanel.vue @@ -0,0 +1,168 @@ + + + + +