From 6d7e3ea2efc3940d6f9946d64a63c96bfd593bcf Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 21 Sep 2025 23:48:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=9F=BA=E6=9C=AC=E4=BF=A1=E6=81=AF=E5=92=8C=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=BF=A1=E6=81=AF=E7=9A=84=E8=8E=B7=E5=8F=96=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=E6=96=87=E4=BB=B6=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在知识库路由中新增获取文档基本信息和内容信息的接口 - 实现文档下载功能,支持中文文件名处理 - 更新相关文档和API调用,确保前端能够正确下载文件 --- README.md | 4 +- docs/changelog/update.md | 1 + server/routers/knowledge_router.py | 124 +++++++++++++++++++++- src/knowledge/chroma_kb.py | 32 +++++- src/knowledge/kb_manager.py | 12 ++- src/knowledge/knowledge_base.py | 34 +++++- src/knowledge/lightrag_kb.py | 32 +++++- src/knowledge/milvus_kb.py | 32 +++++- web/src/apis/base.js | 110 ++++++++++++------- web/src/apis/knowledge_api.js | 10 ++ web/src/components/AgentChatComponent.vue | 2 +- web/src/components/FileTable.vue | 82 +++++++++++++- 12 files changed, 409 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 701e0843..38f7b56c 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ ### ✨ 核心特性 -- 🤖 **多模型支持** - 适配主流大模型平台及离线方案(vLLM、Ollama),支持自定义智能体开发,兼容 LangGraph 部署 +- 🤖 **模型与智能体** - 支持主流大模型及 vLLM、Ollama 等,支持自定义智能体开发,兼容 LangGraph 部署 - 📚 **灵活知识库** - 支持 LightRAG、Milvus、Chroma 等存储形式,配置 MinerU、PP-Structure-V3 文档解析引擎 - 🕸️ **知识图谱** - 支持 LightRAG 自动图谱构建,以及自定义图谱问答,可接入现有知识图谱 -- 👥 **权限安全** - 支持超级管理员、管理员、普通用户三级权限体系,并配置守卫模型 +- 👥 **权限安全** - 支持超级管理员、管理员、普通用户三级权限体系,并配置内容审查以及守卫模型
diff --git a/docs/changelog/update.md b/docs/changelog/update.md index ac808c9d..1a131f85 100644 --- a/docs/changelog/update.md +++ b/docs/changelog/update.md @@ -14,6 +14,7 @@ - [ ] 添加统计信息 - [x] 添加内容审查功能 - [x] 补充 embedding 模型和 reranker 模型的配置说明 +- [ ] 知识库文件应该上传到 minio 中,然后就可以支持 从 minio 中下载文件 📝 **Base** diff --git a/server/routers/knowledge_router.py b/server/routers/knowledge_router.py index cf8e16ad..0a50a49f 100644 --- a/server/routers/knowledge_router.py +++ b/server/routers/knowledge_router.py @@ -1,8 +1,10 @@ import os import traceback +from urllib.parse import quote, unquote -from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse +from starlette.responses import FileResponse as StarletteFileResponse from server.models.user_model import User from server.utils.auth_middleware import get_admin_user @@ -160,7 +162,7 @@ async def add_documents( @knowledge.get("/databases/{db_id}/documents/{doc_id}") async def get_document_info(db_id: str, doc_id: str, current_user: User = Depends(get_admin_user)): - """获取文档详细信息""" + """获取文档详细信息(包含基本信息和内容信息)""" logger.debug(f"GET document {doc_id} info in {db_id}") try: @@ -171,6 +173,32 @@ async def get_document_info(db_id: str, doc_id: str, current_user: User = Depend return {"message": "Failed to get file info", "status": "failed"} +@knowledge.get("/databases/{db_id}/documents/{doc_id}/basic") +async def get_document_basic_info(db_id: str, doc_id: str, current_user: User = Depends(get_admin_user)): + """获取文档基本信息(仅元数据)""" + logger.debug(f"GET document {doc_id} basic info in {db_id}") + + try: + info = await knowledge_base.get_file_basic_info(db_id, doc_id) + return info + except Exception as e: + logger.error(f"Failed to get file basic info, {e}, {db_id=}, {doc_id=}, {traceback.format_exc()}") + return {"message": "Failed to get file basic info", "status": "failed"} + + +@knowledge.get("/databases/{db_id}/documents/{doc_id}/content") +async def get_document_content(db_id: str, doc_id: str, current_user: User = Depends(get_admin_user)): + """获取文档内容信息(chunks和lines)""" + logger.debug(f"GET document {doc_id} content in {db_id}") + + try: + info = await knowledge_base.get_file_content(db_id, doc_id) + return info + except Exception as e: + logger.error(f"Failed to get file content, {e}, {db_id=}, {doc_id=}, {traceback.format_exc()}") + return {"message": "Failed to get file content", "status": "failed"} + + @knowledge.delete("/databases/{db_id}/documents/{doc_id}") async def delete_document(db_id: str, doc_id: str, current_user: User = Depends(get_admin_user)): """删除文档""" @@ -183,6 +211,96 @@ async def delete_document(db_id: str, doc_id: str, current_user: User = Depends( raise HTTPException(status_code=400, detail=f"删除文档失败: {e}") +@knowledge.get("/databases/{db_id}/documents/{doc_id}/download") +async def download_document(db_id: str, doc_id: str, request: Request, current_user: User = Depends(get_admin_user)): + """下载原始文件""" + logger.debug(f"Download document {doc_id} from {db_id}") + try: + file_info = await knowledge_base.get_file_basic_info(db_id, doc_id) + if not file_info: + raise HTTPException(status_code=404, detail="File not found") + + file_path = file_info.get("meta", {}).get("path") + if not file_path or not os.path.exists(file_path): + raise HTTPException(status_code=404, detail=f"File not found on disk: {file_info=}") + + # 获取文件扩展名和MIME类型,解码URL编码的文件名 + filename = file_info.get("meta", {}).get("filename", "file") + logger.debug(f"Original filename from database: {filename}") + + # 解码URL编码的文件名(如果有的话) + try: + decoded_filename = unquote(filename, encoding='utf-8') + logger.debug(f"Decoded filename: {decoded_filename}") + except Exception as e: + logger.debug(f"Failed to decode filename {filename}: {e}") + decoded_filename = filename # 如果解码失败,使用原文件名 + + _, ext = os.path.splitext(decoded_filename) + + media_types = { + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".doc": "application/msword", + ".txt": "text/plain", + ".md": "text/markdown", + ".json": "application/json", + ".csv": "text/csv", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".ppt": "application/vnd.ms-powerpoint", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".zip": "application/zip", + ".rar": "application/x-rar-compressed", + ".7z": "application/x-7z-compressed", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".html": "text/html", + ".htm": "text/html", + ".xml": "text/xml", + ".css": "text/css", + ".js": "application/javascript", + ".py": "text/x-python", + ".java": "text/x-java-source", + ".cpp": "text/x-c++src", + ".c": "text/x-csrc", + ".h": "text/x-chdr", + ".hpp": "text/x-c++hdr", + } + media_type = media_types.get(ext.lower(), "application/octet-stream") + + # 创建自定义FileResponse,避免文件名编码问题 + response = StarletteFileResponse( + path=file_path, + media_type=media_type + ) + + # 正确处理中文文件名的HTTP头部设置 + # HTTP头部只能包含ASCII字符,所以需要对中文文件名进行编码 + try: + # 尝试使用ASCII编码(适用于英文文件名) + decoded_filename.encode('ascii') + # 如果成功,直接使用简单格式 + response.headers["Content-Disposition"] = f'attachment; filename="{decoded_filename}"' + except UnicodeEncodeError: + # 如果包含非ASCII字符(如中文),使用RFC 2231格式 + encoded_filename = quote(decoded_filename.encode('utf-8')) + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + + return response + except HTTPException: + raise + except Exception as e: + logger.error(f"下载文件失败: {e}, {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"下载失败: {e}") + + # ============================================================================= # === 查询分组 === # ============================================================================= @@ -383,6 +501,8 @@ async def upload_file( if not file.filename: raise HTTPException(status_code=400, detail="No selected file") + logger.debug(f"Received upload file with filename: {file.filename}") + # 根据db_id获取上传路径,如果db_id为None则使用默认路径 if db_id: upload_dir = knowledge_base.get_db_upload_path(db_id) diff --git a/src/knowledge/chroma_kb.py b/src/knowledge/chroma_kb.py index bd852c63..71eb4cf9 100644 --- a/src/knowledge/chroma_kb.py +++ b/src/knowledge/chroma_kb.py @@ -285,12 +285,20 @@ class ChromaKB(KnowledgeBase): del self.files_meta[file_id] self._save_metadata() - async def get_file_info(self, db_id: str, file_id: str) -> dict: - """获取文件信息和chunks""" + async def get_file_basic_info(self, db_id: str, file_id: str) -> dict: + """获取文件基本信息(仅元数据)""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + return {"meta": self.files_meta[file_id]} + + async def get_file_content(self, db_id: str, file_id: str) -> dict: + """获取文件内容信息(chunks和lines)""" if file_id not in self.files_meta: raise Exception(f"File not found: {file_id}") # 使用 ChromaDB 获取chunks + content_info = {"lines": []} collection = await self._get_chroma_collection(db_id) if collection: try: @@ -313,9 +321,23 @@ class ChromaKB(KnowledgeBase): # 按 chunk_order_index 排序 doc_chunks.sort(key=lambda x: x.get("chunk_order_index", 0)) - return {"lines": doc_chunks} + content_info["lines"] = doc_chunks + return content_info except Exception as e: - logger.error(f"Error getting chunks for file {file_id}: {e}") + logger.error(f"Failed to get file content from ChromaDB: {e}") + content_info["lines"] = [] + return content_info - return {"lines": []} + return content_info + + async def get_file_info(self, db_id: str, file_id: str) -> dict: + """获取文件完整信息(基本信息+内容信息)- 保持向后兼容""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + # 合并基本信息和内容信息 + basic_info = await self.get_file_basic_info(db_id, file_id) + content_info = await self.get_file_content(db_id, file_id) + + return {**basic_info, **content_info} diff --git a/src/knowledge/kb_manager.py b/src/knowledge/kb_manager.py index c8b54fce..eb3cd06b 100644 --- a/src/knowledge/kb_manager.py +++ b/src/knowledge/kb_manager.py @@ -230,8 +230,18 @@ class KnowledgeBaseManager: kb_instance = self._get_kb_for_database(db_id) await kb_instance.delete_file(db_id, file_id) + async def get_file_basic_info(self, db_id: str, file_id: str) -> dict: + """获取文件基本信息(仅元数据)""" + kb_instance = self._get_kb_for_database(db_id) + return await kb_instance.get_file_basic_info(db_id, file_id) + + async def get_file_content(self, db_id: str, file_id: str) -> dict: + """获取文件内容信息(chunks和lines)""" + kb_instance = self._get_kb_for_database(db_id) + return await kb_instance.get_file_content(db_id, file_id) + async def get_file_info(self, db_id: str, file_id: str) -> dict: - """获取文件信息""" + """获取文件完整信息(基本信息+内容信息)- 保持向后兼容""" kb_instance = self._get_kb_for_database(db_id) return await kb_instance.get_file_info(db_id, file_id) diff --git a/src/knowledge/knowledge_base.py b/src/knowledge/knowledge_base.py index d520d42c..ab179ad4 100644 --- a/src/knowledge/knowledge_base.py +++ b/src/knowledge/knowledge_base.py @@ -375,16 +375,44 @@ class KnowledgeBase(ABC): pass @abstractmethod - async def get_file_info(self, db_id: str, file_id: str) -> dict: + async def get_file_basic_info(self, db_id: str, file_id: str) -> dict: """ - 获取文件信息和chunks + 获取文件基本信息(仅元数据) Args: db_id: 数据库ID file_id: 文件ID Returns: - 文件信息和chunks + dict: 包含文件基本信息的字典 + """ + pass + + @abstractmethod + async def get_file_content(self, db_id: str, file_id: str) -> dict: + """ + 获取文件内容信息(chunks和lines) + + Args: + db_id: 数据库ID + file_id: 文件ID + + Returns: + dict: 包含文件内容信息的字典 + """ + pass + + @abstractmethod + async def get_file_info(self, db_id: str, file_id: str) -> dict: + """ + 获取文件完整信息(基本信息+内容信息)- 保持向后兼容 + + Args: + db_id: 数据库ID + file_id: 文件ID + + Returns: + dict: 包含文件信息和chunks的字典 """ pass diff --git a/src/knowledge/lightrag_kb.py b/src/knowledge/lightrag_kb.py index c8c0ec36..d1ce5393 100644 --- a/src/knowledge/lightrag_kb.py +++ b/src/knowledge/lightrag_kb.py @@ -310,12 +310,20 @@ class LightRagKB(KnowledgeBase): del self.files_meta[file_id] self._save_metadata() - async def get_file_info(self, db_id: str, file_id: str) -> dict: - """获取文件信息和chunks""" + async def get_file_basic_info(self, db_id: str, file_id: str) -> dict: + """获取文件基本信息(仅元数据)""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + return {"meta": self.files_meta[file_id]} + + async def get_file_content(self, db_id: str, file_id: str) -> dict: + """获取文件内容信息(chunks和lines)""" if file_id not in self.files_meta: raise Exception(f"File not found: {file_id}") # 使用 LightRAG 获取 chunks + content_info = {"lines": []} rag = await self._get_lightrag_instance(db_id) if rag: try: @@ -333,12 +341,26 @@ class LightRagKB(KnowledgeBase): # 按 chunk_order_index 排序 doc_chunks.sort(key=lambda x: x.get("chunk_order_index", 0)) - return {"lines": doc_chunks} + content_info["lines"] = doc_chunks + return content_info except Exception as e: - logger.error(f"Error getting chunks for file {file_id}: {e}") + logger.error(f"Failed to get file content from LightRAG: {e}") + content_info["lines"] = [] + return content_info - return {"lines": []} + return content_info + + async def get_file_info(self, db_id: str, file_id: str) -> dict: + """获取文件完整信息(基本信息+内容信息)- 保持向后兼容""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + # 合并基本信息和内容信息 + basic_info = await self.get_file_basic_info(db_id, file_id) + content_info = await self.get_file_content(db_id, file_id) + + return {**basic_info, **content_info} async def export_data(self, db_id: str, format: str = "csv", **kwargs) -> str: """ diff --git a/src/knowledge/milvus_kb.py b/src/knowledge/milvus_kb.py index 59e432ce..0cb685d8 100644 --- a/src/knowledge/milvus_kb.py +++ b/src/knowledge/milvus_kb.py @@ -377,12 +377,20 @@ class MilvusKB(KnowledgeBase): del self.files_meta[file_id] self._save_metadata() - async def get_file_info(self, db_id: str, file_id: str) -> dict: - """获取文件信息和chunks""" + async def get_file_basic_info(self, db_id: str, file_id: str) -> dict: + """获取文件基本信息(仅元数据)""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + return {"meta": self.files_meta[file_id]} + + async def get_file_content(self, db_id: str, file_id: str) -> dict: + """获取文件内容信息(chunks和lines)""" if file_id not in self.files_meta: raise Exception(f"File not found: {file_id}") # 使用 Milvus 获取chunks + content_info = {"lines": []} collection = await self._get_milvus_collection(db_id) if collection: try: @@ -406,12 +414,26 @@ class MilvusKB(KnowledgeBase): # 按 chunk_order_index 排序 doc_chunks.sort(key=lambda x: x.get("chunk_order_index", 0)) - return {"lines": doc_chunks} + content_info["lines"] = doc_chunks + return content_info except Exception as e: - logger.error(f"Error getting chunks for file {file_id}: {e}") + logger.error(f"Failed to get file content from Milvus: {e}") + content_info["lines"] = [] + return content_info - return {"lines": []} + return content_info + + async def get_file_info(self, db_id: str, file_id: str) -> dict: + """获取文件完整信息(基本信息+内容信息)- 保持向后兼容""" + if file_id not in self.files_meta: + raise Exception(f"File not found: {file_id}") + + # 合并基本信息和内容信息 + basic_info = await self.get_file_basic_info(db_id, file_id) + content_info = await self.get_file_content(db_id, file_id) + + return {**basic_info, **content_info} def delete_database(self, db_id: str) -> dict: """删除数据库,同时清除Milvus中的集合""" diff --git a/web/src/apis/base.js b/web/src/apis/base.js index 16f3d178..4e019fb6 100644 --- a/web/src/apis/base.js +++ b/web/src/apis/base.js @@ -11,9 +11,10 @@ import { message } from 'ant-design-vue' * @param {string} url - API端点 * @param {Object} options - 请求选项 * @param {boolean} requiresAuth - 是否需要认证头 + * @param {string} responseType - 响应类型: 'json' | 'text' | 'blob' * @returns {Promise} - 请求结果 */ -export async function apiRequest(url, options = {}, requiresAuth = true) { +export async function apiRequest(url, options = {}, requiresAuth = true, responseType = 'json') { try { // 默认请求配置 const requestOptions = { @@ -43,33 +44,46 @@ export async function apiRequest(url, options = {}, requiresAuth = true) { let errorMessage = `请求失败: ${response.status}, ${response.statusText}` let errorData = null + console.log('API请求失败:', { + url, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()) + }); + try { errorData = await response.json() errorMessage = errorData.detail || errorData.message || errorMessage + console.log('API错误详情:', errorData); } catch (e) { // 如果无法解析JSON,使用默认错误信息 + console.log('无法解析错误响应JSON:', e); } // 特殊处理401和403错误 if (response.status === 401) { // 如果是认证失败,可能需要重新登录 const userStore = useUserStore() + + // 检查是否是token过期 + const isTokenExpired = errorData && + (errorData.detail?.includes('令牌已过期') || + errorData.detail?.includes('token expired') || + errorMessage?.includes('令牌已过期') || + errorMessage?.includes('token expired')) + + message.error(isTokenExpired ? '登录已过期,请重新登录' : '认证失败,请重新登录') + + // 如果用户当前认为自己已登录,则登出 if (userStore.isLoggedIn) { - // 如果用户认为自己已登录,但收到401,则可能是令牌过期 - const isTokenExpired = errorData && - (errorData.detail?.includes('令牌已过期') || - errorData.detail?.includes('token expired') || - errorMessage?.includes('令牌已过期') || - errorMessage?.includes('token expired')) - - message.error(isTokenExpired ? '登录已过期,请重新登录' : '认证失败,请重新登录') userStore.logout() - - // 使用setTimeout确保消息显示后再跳转 - setTimeout(() => { - window.location.href = '/login' - }, 1500) } + + // 使用setTimeout确保消息显示后再跳转 + setTimeout(() => { + window.location.href = '/login' + }, 1500) + throw new Error('未授权,请先登录') } else if (response.status === 403) { throw new Error('没有权限执行此操作') @@ -80,13 +94,21 @@ export async function apiRequest(url, options = {}, requiresAuth = true) { throw new Error(errorMessage) } - // 检查Content-Type以确定如何处理响应 - const contentType = response.headers.get('Content-Type') - if (contentType && contentType.includes('application/json')) { - return await response.json() + // 根据responseType处理响应 + if (responseType === 'blob') { + return response + } else if (responseType === 'json') { + // 检查Content-Type以确定如何处理响应 + const contentType = response.headers.get('Content-Type') + if (contentType && contentType.includes('application/json')) { + return await response.json() + } + return await response.text() + } else if (responseType === 'text') { + return await response.text() + } else { + return response } - - return await response.text() } catch (error) { console.error('API请求错误:', error) throw error @@ -98,20 +120,21 @@ export async function apiRequest(url, options = {}, requiresAuth = true) { * @param {string} url - API端点 * @param {Object} options - 请求选项 * @param {boolean} requiresAuth - 是否需要认证 + * @param {string} responseType - 响应类型: 'json' | 'text' | 'blob' * @returns {Promise} - 请求结果 */ -export function apiGet(url, options = {}, requiresAuth = true) { - return apiRequest(url, { method: 'GET', ...options }, requiresAuth) +export function apiGet(url, options = {}, requiresAuth = true, responseType = 'json') { + return apiRequest(url, { method: 'GET', ...options }, requiresAuth, responseType) } -export function apiAdminGet(url, options = {}) { +export function apiAdminGet(url, options = {}, responseType = 'json') { checkAdminPermission() - return apiGet(url, options, true) + return apiGet(url, options, true, responseType) } -export function apiSuperAdminGet(url, options = {}) { +export function apiSuperAdminGet(url, options = {}, responseType = 'json') { checkSuperAdminPermission() - return apiGet(url, options, true) + return apiGet(url, options, true, responseType) } /** @@ -120,9 +143,10 @@ export function apiSuperAdminGet(url, options = {}) { * @param {Object} data - 请求体数据 * @param {Object} options - 其他请求选项 * @param {boolean} requiresAuth - 是否需要认证 + * @param {string} responseType - 响应类型: 'json' | 'text' | 'blob' * @returns {Promise} - 请求结果 */ -export function apiPost(url, data = {}, options = {}, requiresAuth = true) { +export function apiPost(url, data = {}, options = {}, requiresAuth = true, responseType = 'json') { return apiRequest( url, { @@ -130,18 +154,19 @@ export function apiPost(url, data = {}, options = {}, requiresAuth = true) { body: JSON.stringify(data), ...options }, - requiresAuth + requiresAuth, + responseType ) } -export function apiAdminPost(url, data = {}, options = {}) { +export function apiAdminPost(url, data = {}, options = {}, responseType = 'json') { checkAdminPermission() - return apiPost(url, data, options, true) + return apiPost(url, data, options, true, responseType) } -export function apiSuperAdminPost(url, data = {}, options = {}) { +export function apiSuperAdminPost(url, data = {}, options = {}, responseType = 'json') { checkSuperAdminPermission() - return apiPost(url, data, options, true) + return apiPost(url, data, options, true, responseType) } /** @@ -150,9 +175,10 @@ export function apiSuperAdminPost(url, data = {}, options = {}) { * @param {Object} data - 请求体数据 * @param {Object} options - 其他请求选项 * @param {boolean} requiresAuth - 是否需要认证 + * @param {string} responseType - 响应类型: 'json' | 'text' | 'blob' * @returns {Promise} - 请求结果 */ -export function apiPut(url, data = {}, options = {}, requiresAuth = true) { +export function apiPut(url, data = {}, options = {}, requiresAuth = true, responseType = 'json') { return apiRequest( url, { @@ -160,18 +186,19 @@ export function apiPut(url, data = {}, options = {}, requiresAuth = true) { body: JSON.stringify(data), ...options }, - requiresAuth + requiresAuth, + responseType ) } -export function apiAdminPut(url, data = {}, options = {}) { +export function apiAdminPut(url, data = {}, options = {}, responseType = 'json') { checkAdminPermission() - return apiPut(url, data, options, true) + return apiPut(url, data, options, true, responseType) } -export function apiSuperAdminPut(url, data = {}, options = {}) { +export function apiSuperAdminPut(url, data = {}, options = {}, responseType = 'json') { checkSuperAdminPermission() - return apiPut(url, data, options, true) + return apiPut(url, data, options, true, responseType) } /** @@ -179,17 +206,20 @@ export function apiSuperAdminPut(url, data = {}, options = {}) { * @param {string} url - API端点 * @param {Object} options - 请求选项 * @param {boolean} requiresAuth - 是否需要认证 + * @param {string} responseType - 响应类型: 'json' | 'text' | 'blob' * @returns {Promise} - 请求结果 */ -export function apiDelete(url, options = {}, requiresAuth = true) { - return apiRequest(url, { method: 'DELETE', ...options }, requiresAuth) +export function apiDelete(url, options = {}, requiresAuth = true, responseType = 'json') { + return apiRequest(url, { method: 'DELETE', ...options }, requiresAuth, responseType) } + export function apiAdminDelete(url, options = {}) { checkAdminPermission() return apiDelete(url, options, true) } + export function apiSuperAdminDelete(url, options = {}) { checkSuperAdminPermission() return apiDelete(url, options, true) diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index e718197b..e438e3b4 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -93,6 +93,16 @@ export const documentApi = { */ deleteDocument: async (dbId, docId) => { return apiAdminDelete(`/api/knowledge/databases/${dbId}/documents/${docId}`) + }, + + /** + * 下载文档 + * @param {string} dbId - 知识库ID + * @param {string} docId - 文档ID + * @returns {Promise} - Response对象 + */ + downloadDocument: async (dbId, docId) => { + return apiAdminGet(`/api/knowledge/databases/${dbId}/documents/${docId}/download`, {}, 'blob') } } diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 45e8a1f7..e31ae1f3 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -222,7 +222,7 @@ const currentAgentId = computed(() => { }); const currentAgentMetadata = computed(() => { - if (agentStore?.metadata && currentAgentId.value in agentStore?.metadata[currentAgentId.value]) { + if (agentStore?.metadata && currentAgentId.value && currentAgentId.value in agentStore?.metadata[currentAgentId.value]) { return agentStore?.metadata[currentAgentId.value] } return {} diff --git a/web/src/components/FileTable.vue b/web/src/components/FileTable.vue index f991b3e7..6ff61f50 100644 --- a/web/src/components/FileTable.vue +++ b/web/src/components/FileTable.vue @@ -102,6 +102,12 @@
+ import { ref, computed, watch, h } from 'vue'; import { useDatabaseStore } from '@/stores/database'; -import { Modal } from 'ant-design-vue'; +import { Modal, message } from 'ant-design-vue'; +import { useUserStore } from '@/stores/user'; +import { documentApi } from '@/apis/knowledge_api'; import { CheckCircleFilled, HourglassFilled, @@ -127,10 +135,12 @@ import { DeleteOutlined, PlusOutlined, ReloadOutlined, + DownloadOutlined, } from '@ant-design/icons-vue'; import { ChevronLast } from 'lucide-vue-next'; const store = useDatabaseStore(); +const userStore = useUserStore(); const props = defineProps({ rightPanelVisible: { @@ -189,7 +199,7 @@ const columnsCompact = [ }, sortDirections: ['ascend', 'descend'] }, - { title: '', key: 'action', dataIndex: 'file_id', width: 40, align: 'center' } + { title: '', key: 'action', dataIndex: 'file_id', width: 80, align: 'center' } ]; // 过滤后的文件列表 @@ -284,9 +294,69 @@ const selectAllFailedFiles = () => { }; const openFileDetail = (record) => { + console.log('openFileDetail', record); store.openFileDetail(record); }; +const handleDownloadFile = async (record) => { + const dbId = store.databaseId; + if (!dbId) { + console.error('无法获取数据库ID,数据库ID:', store.databaseId, '记录:', record); + message.error('无法获取数据库ID,请刷新页面后重试'); + return; + } + + console.log('开始下载文件:', { dbId, fileId: record.file_id, record }); + + try { + const response = await documentApi.downloadDocument(dbId, record.file_id); + + // 获取文件名 + const contentDisposition = response.headers.get('content-disposition'); + let filename = record.filename; + if (contentDisposition) { + // 首先尝试匹配RFC 2231格式 filename*=UTF-8''... + const rfc2231Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/); + if (rfc2231Match) { + try { + filename = decodeURIComponent(rfc2231Match[1]); + } catch (error) { + console.warn('Failed to decode RFC2231 filename:', rfc2231Match[1], error); + } + } else { + // 回退到标准格式 filename="..." + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, ''); + // 解码URL编码的文件名 + try { + filename = decodeURIComponent(filename); + } catch (error) { + console.warn('Failed to decode filename:', filename, error); + // 如果解码失败,使用原文件名 + } + } + } + } + + // 创建blob并下载 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('下载文件时出错:', error); + const errorMessage = error.message || '下载失败,请稍后重试'; + message.error(errorMessage); + } +}; + // 导入工具函数 import { getFileIcon, getFileIconColor, formatRelativeTime } from '@/utils/file_utils'; @@ -388,6 +458,14 @@ import { getFileIcon, getFileIconColor, formatRelativeTime } from '@/utils/file_ color: var(--gray-500); } +.my-table-compact .download-btn { + color: var(--gray-500); +} + +.my-table-compact .download-btn:hover { + color: var(--main-color); +} + .my-table-compact .del-btn:hover { color: var(--error-color); }