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);
}