From 462bb204270bb4bfa72b86e5cb2738b23c032e3f Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 26 Aug 2026 21:00:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(knowledge):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AB=AF=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E6=9C=AA=E4=BF=9D=E7=95=99=E7=9B=AE=E5=BD=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E4=B8=8E=E7=BB=9F=E8=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复后端 add_documents 接口未解包 source_paths 导致无法设置 filename_display 的问题 - 前端 FileUploadModal 收集并传递 webkitRelativePath 构造 source_paths - FileUploadModal 仅统计和提交受支持且非隐藏的文件,避免无意义报错 - DataBaseInfoView 上传菜单中增加上传文件夹直达入口 --- backend/server/routers/knowledge_router.py | 5 +- .../routers/test_knowledge_router_cleanup.py | 52 +++++++++ web/src/components/FileUploadModal.vue | 82 +++++++++++--- web/src/views/DataBaseInfoView.vue | 14 +++ web/test/unit/file_upload_folder.test.js | 100 ++++++++++++++++++ 5 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 web/test/unit/file_upload_folder.test.js diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 684aff98b..c8e28d598 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -795,7 +795,10 @@ async def run_ingest(context: TaskContext): try: file_meta = await knowledge_base.add_file_record( - kb_id, item, params=params, operator_id=current_user.uid + kb_id, + item, + params=_params_for_uploaded_document_item(item, params), + operator_id=current_user.uid, ) added_files.append( { diff --git a/backend/test/unit/routers/test_knowledge_router_cleanup.py b/backend/test/unit/routers/test_knowledge_router_cleanup.py index aa0799439..9e42d97da 100644 --- a/backend/test/unit/routers/test_knowledge_router_cleanup.py +++ b/backend/test/unit/routers/test_knowledge_router_cleanup.py @@ -469,6 +469,58 @@ async def fake_enqueue(name: str, task_type: str, payload: dict, coroutine): assert context.result["items"] == [{"file_id": "file_1", "status": "indexed", "error": None}] +async def test_add_documents_passes_source_path_to_file_record(monkeypatch): + """验证包含 source_paths 的批量上传会将单文件 source_path 传递给 add_file_record。""" + context = FakeTaskContext() + item1 = "minio://knowledgebases/kb_1/upload/doc1.txt" + item2 = "minio://knowledgebases/kb_1/upload/doc2.txt" + captured_records = [] + + async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None: + return None + + async def fake_get_database_info(kb_id: str) -> KnowledgeBaseDetail: + return _database_detail() + + async def fake_add_file_record(kb_id: str, item_path: str, params: dict, operator_id: str | None = None): + captured_records.append({"kb_id": kb_id, "item": item_path, "params": params, "operator_id": operator_id}) + return {"file_id": f"file_{len(captured_records)}", "status": "uploaded"} + + async def fake_parse_file(kb_id: str, file_id: str, operator_id: str | None = None): + return {"file_id": file_id, "status": "parsed", "error": None} + + async def fake_enqueue(name: str, task_type: str, payload: dict, coroutine): + await coroutine(context) + return SimpleNamespace(id="task_1") + + monkeypatch.setattr( + knowledge_router, + "_ensure_database_supports_documents", + fake_ensure_database_supports_documents, + ) + monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info) + monkeypatch.setattr(knowledge_router.knowledge_base, "add_file_record", fake_add_file_record) + monkeypatch.setattr(knowledge_router.knowledge_base, "parse_file", fake_parse_file) + monkeypatch.setattr(knowledge_router.tasker, "enqueue", fake_enqueue) + + await knowledge_router.add_documents( + "kb_1", + [item1, item2], + params={ + "content_type": "file", + "content_hashes": {item1: "hash_1", item2: "hash_2"}, + "source_paths": {item1: "folder_a/sub/doc1.txt", item2: "folder_a/doc2.txt"}, + }, + current_user=SimpleNamespace(uid="uid-user"), + ) + + assert len(captured_records) == 2 + assert captured_records[0]["params"]["source_path"] == "folder_a/sub/doc1.txt" + assert "source_paths" not in captured_records[0]["params"] + assert captured_records[1]["params"]["source_path"] == "folder_a/doc2.txt" + assert "source_paths" not in captured_records[1]["params"] + + @pytest.mark.parametrize( ("payload", "error_detail"), [ diff --git a/web/src/components/FileUploadModal.vue b/web/src/components/FileUploadModal.vue index e70360bbd..6870e9410 100644 --- a/web/src/components/FileUploadModal.vue +++ b/web/src/components/FileUploadModal.vue @@ -429,6 +429,25 @@ const isSupportedExtension = (fileName) => { return supportedFileTypes.value.includes(ext) || ext === '.zip' } +const isHiddenPath = (name, relativePath) => { + if (name && name.startsWith('.')) return true + if (relativePath) { + const parts = relativePath.split('/') + if (parts.some((part) => part.startsWith('.'))) return true + } + return false +} + +const isSupportedUploadFile = (file) => { + if (!file) return false + const name = file.name || '' + const relativePath = file.webkitRelativePath || file.originFileObj?.webkitRelativePath || '' + if (isHiddenPath(name, relativePath)) { + return false + } + return isSupportedExtension(name) +} + const loadSupportedFileTypes = async () => { try { const data = await fileApi.getSupportedFileTypes() @@ -457,6 +476,9 @@ const MAX_UPLOAD_CONCURRENCY = 10 // 文件列表 const fileList = ref([]) +const validFileList = computed(() => { + return fileList.value.filter((file) => isSupportedUploadFile(file)) +}) const uploadQueue = ref([]) const activeUploadCount = ref(0) @@ -464,18 +486,33 @@ const uploadTaskStatus = ref({}) const uploadTaskProgress = ref({}) const progressExpanded = ref(false) -const totalUploadCount = computed(() => fileList.value.length) +const totalUploadCount = computed(() => validFileList.value.length) +const validUidSet = computed( + () => new Set(validFileList.value.map((file) => file.uid).filter(Boolean)) +) const queuedUploadCount = computed( - () => Object.values(uploadTaskStatus.value).filter((status) => status === 'queued').length + () => + Object.entries(uploadTaskStatus.value).filter( + ([uid, status]) => validUidSet.value.has(uid) && status === 'queued' + ).length ) const uploadingUploadCount = computed( - () => Object.values(uploadTaskStatus.value).filter((status) => status === 'uploading').length + () => + Object.entries(uploadTaskStatus.value).filter( + ([uid, status]) => validUidSet.value.has(uid) && status === 'uploading' + ).length ) const successUploadCount = computed( - () => Object.values(uploadTaskStatus.value).filter((status) => status === 'done').length + () => + Object.entries(uploadTaskStatus.value).filter( + ([uid, status]) => validUidSet.value.has(uid) && status === 'done' + ).length ) const failedUploadCount = computed( - () => Object.values(uploadTaskStatus.value).filter((status) => status === 'error').length + () => + Object.entries(uploadTaskStatus.value).filter( + ([uid, status]) => validUidSet.value.has(uid) && status === 'error' + ).length ) const hasPendingUploads = computed(() => queuedUploadCount.value + uploadingUploadCount.value > 0) @@ -484,9 +521,8 @@ const overallUploadProgress = computed(() => { if (!total) { return 0 } - const validUidSet = new Set(fileList.value.map((file) => file.uid).filter(Boolean)) let sum = 0 - for (const uid of validUidSet) { + for (const uid of validUidSet.value) { sum += uploadTaskProgress.value[uid] || 0 } return Math.round(sum / total) @@ -495,14 +531,15 @@ const overallUploadProgress = computed(() => { const showAggregateProgress = computed(() => totalUploadCount.value >= MAX_UPLOAD_CONCURRENCY) const failedDetailItems = computed(() => { - return fileList.value + return validFileList.value .map((file) => { const uid = file.uid const rawStatus = uploadTaskStatus.value[uid] || file.status || 'unknown' const detail = file?.response?.detail || file?.error?.message || '' + const relativePath = file?.webkitRelativePath || file?.originFileObj?.webkitRelativePath return { uid, - name: file.name || '未命名文件', + name: relativePath || file.name || '未命名文件', status: rawStatus, errorText: detail || '上传失败' } @@ -717,7 +754,7 @@ const isOcrEnabled = computed(() => { // 计算属性:是否有PDF或图片文件 const hasPdfOrImageFiles = computed(() => { - if (fileList.value.length === 0) { + if (validFileList.value.length === 0) { return false } @@ -725,7 +762,7 @@ const hasPdfOrImageFiles = computed(() => { const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.gif', '.webp'] const ocrExtensions = [...pdfExtensions, ...imageExtensions] - return fileList.value.some((file) => { + return validFileList.value.some((file) => { if (file.status !== 'done') { return false } @@ -742,11 +779,11 @@ const hasPdfOrImageFiles = computed(() => { // 计算属性:是否有ZIP文件 const hasZipFiles = computed(() => { - if (fileList.value.length === 0) { + if (validFileList.value.length === 0) { return false } - return fileList.value.some((file) => { + return validFileList.value.some((file) => { if (file.status !== 'done') { return false } @@ -814,8 +851,14 @@ const handleCancel = () => { } const beforeUpload = (file) => { + const relativePath = file?.webkitRelativePath || file?.originFileObj?.webkitRelativePath + if (isHiddenPath(file?.name, relativePath)) { + return Upload.LIST_IGNORE + } if (!isSupportedExtension(file?.name)) { - message.error(`不支持的文件类型:${file?.name || '未知文件'}`) + if (!isFolderUpload.value) { + message.error(`不支持的文件类型:${file?.name || '未知文件'}`) + } return Upload.LIST_IGNORE } return true @@ -1258,7 +1301,8 @@ const chunkData = async () => { const items = [] const content_hashes = {} const file_sizes = {} - for (const file of fileList.value) { + const source_paths = {} + for (const file of validFileList.value) { if (file.status !== 'done') continue const file_path = file.response?.file_path const content_hash = file.response?.content_hash @@ -1268,6 +1312,11 @@ const chunkData = async () => { if (content_hash) content_hashes[file_path] = content_hash if (Number.isFinite(file.response?.size)) file_sizes[file_path] = file.response.size + const relativePath = file.webkitRelativePath || file.originFileObj?.webkitRelativePath + if (relativePath) { + source_paths[file_path] = relativePath + } + // 检查是否需要OCR const ext = file_path.substring(file_path.lastIndexOf('.')).toLowerCase() if (imageExtensions.includes(ext) && !isOcrEnabled.value) { @@ -1287,6 +1336,9 @@ const chunkData = async () => { try { store.state.chunkLoading = true const params = { ...processingParams.value, content_hashes, file_sizes } + if (Object.keys(source_paths).length > 0) { + params.source_paths = source_paths + } if (autoIndex.value) { params.auto_index = true Object.assign(params, buildAutoIndexParams()) diff --git a/web/src/views/DataBaseInfoView.vue b/web/src/views/DataBaseInfoView.vue index fba56128a..a2c5866a5 100644 --- a/web/src/views/DataBaseInfoView.vue +++ b/web/src/views/DataBaseInfoView.vue @@ -144,6 +144,14 @@ 上传文件 +