feat: 添加附件上传中间件和管理功能
- 实现对话线程的附件上传、列出和删除功能 - 引入附件中间件以支持附件内容的上下文注入 - 更新智能体和对话管理器以处理附件元数据 - 增强前端组件以支持文件上传和显示附件状态 - 优化文档以反映新功能和使用示例
This commit is contained in:
parent
6361602851
commit
8b4b7c230c
@ -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 来格式化代码。
|
||||
|
||||
其他:
|
||||
|
||||
|
||||
@ -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 集成
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
### Bugs
|
||||
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
|
||||
- 消息中断没有达到预期效果,看不到截断的消息
|
||||
|
||||
### 新增
|
||||
- 优化知识库详情页面,更加简洁清晰
|
||||
|
||||
@ -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": "附件已删除"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 消息反馈分组 ===
|
||||
# =============================================================================
|
||||
|
||||
@ -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 工具注册)
|
||||
],
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
89
src/agents/common/middlewares/attachment_middleware.py
Normal file
89
src/agents/common/middlewares/attachment_middleware.py
Normal 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()
|
||||
100
src/services/doc_converter.py
Normal file
100
src/services/doc_converter.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}`)
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
168
web/src/components/AttachmentInputPanel.vue
Normal file
168
web/src/components/AttachmentInputPanel.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user