feat(knowledge): 添加文档基本信息和内容信息的获取接口,支持文件下载功能

- 在知识库路由中新增获取文档基本信息和内容信息的接口
- 实现文档下载功能,支持中文文件名处理
- 更新相关文档和API调用,确保前端能够正确下载文件
This commit is contained in:
Wenjie Zhang 2025-09-21 23:48:56 +08:00
parent c934ccb10d
commit 6d7e3ea2ef
12 changed files with 409 additions and 66 deletions

View File

@ -23,10 +23,10 @@
### ✨ 核心特性
- 🤖 **多模型支持** - 适配主流大模型平台及离线方案vLLM、Ollama,支持自定义智能体开发,兼容 LangGraph 部署
- 🤖 **模型与智能体** - 支持主流大模型及 vLLM、Ollama 等,支持自定义智能体开发,兼容 LangGraph 部署
- 📚 **灵活知识库** - 支持 LightRAG、Milvus、Chroma 等存储形式,配置 MinerU、PP-Structure-V3 文档解析引擎
- 🕸️ **知识图谱** - 支持 LightRAG 自动图谱构建,以及自定义图谱问答,可接入现有知识图谱
- 👥 **权限安全** - 支持超级管理员、管理员、普通用户三级权限体系,并配置守卫模型
- 👥 **权限安全** - 支持超级管理员、管理员、普通用户三级权限体系,并配置内容审查以及守卫模型
<div align="center">
<!-- 视频缩略图 -->

View File

@ -14,6 +14,7 @@
- [ ] 添加统计信息
- [x] 添加内容审查功能
- [x] 补充 embedding 模型和 reranker 模型的配置说明
- [ ] 知识库文件应该上传到 minio 中,然后就可以支持 从 minio 中下载文件
📝 **Base**

View File

@ -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)

View File

@ -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}

View File

@ -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)

View File

@ -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

View File

@ -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:
"""

View File

@ -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中的集合"""

View File

@ -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)

View File

@ -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')
}
}

View File

@ -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 {}

View File

@ -102,6 +102,12 @@
</a-tooltip>
<div v-else-if="column.key === 'action'" style="display: flex; gap: 4px;">
<a-button class="download-btn" type="text"
@click="handleDownloadFile(record)"
:disabled="lock || record.status !== 'done'"
:icon="h(DownloadOutlined)"
title="下载"
/>
<a-button class="del-btn" type="text"
@click="handleDeleteFile(record.file_id)"
:disabled="lock || record.status === 'processing' || record.status === 'waiting'"
@ -118,7 +124,9 @@
<script setup>
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';
</script>
@ -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);
}