diff --git a/server/routers/knowledge_router.py b/server/routers/knowledge_router.py index 79ae3605..bfa65056 100644 --- a/server/routers/knowledge_router.py +++ b/server/routers/knowledge_router.py @@ -394,10 +394,17 @@ async def get_document_content(db_id: str, doc_id: str, current_user: User = Dep @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)): - """删除文档""" + """删除文档或文件夹""" logger.debug(f"DELETE document {doc_id} info in {db_id}") try: file_meta_info = await knowledge_base.get_file_basic_info(db_id, doc_id) + + # Check if it is a folder + is_folder = file_meta_info.get("meta", {}).get("is_folder", False) + if is_folder: + await knowledge_base.delete_folder(db_id, doc_id) + return {"message": "文件夹删除成功"} + file_name = file_meta_info.get("meta", {}).get("filename") # 尝试从MinIO删除文件,如果失败(例如旧知识库没有MinIO实例),则忽略 @@ -1150,6 +1157,39 @@ async def get_sample_questions(db_id: str, current_user: User = Depends(get_admi # ============================================================================= +@knowledge.post("/databases/{db_id}/folders") +async def create_folder( + db_id: str, + folder_name: str = Body(..., embed=True), + parent_id: str | None = Body(None, embed=True), + current_user: User = Depends(get_admin_user), +): + """创建文件夹""" + try: + return await knowledge_base.create_folder(db_id, folder_name, parent_id) + except Exception as e: + logger.error(f"创建文件夹失败 {e}, {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + + +@knowledge.put("/databases/{db_id}/documents/{doc_id}/move") +async def move_document( + db_id: str, + doc_id: str, + new_parent_id: str | None = Body(..., embed=True), + current_user: User = Depends(get_admin_user), +): + """移动文件或文件夹""" + logger.debug(f"Move document {doc_id} to {new_parent_id} in {db_id}") + try: + return await knowledge_base.move_file(db_id, doc_id, new_parent_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"移动文件失败 {e}, {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + + @knowledge.post("/files/upload") async def upload_file( file: UploadFile = File(...), diff --git a/src/knowledge/base.py b/src/knowledge/base.py index 0c950ca7..7d03ffa2 100644 --- a/src/knowledge/base.py +++ b/src/knowledge/base.py @@ -210,6 +210,26 @@ def delete_database(self, db_id: str) -> dict: return {"message": "删除成功"} + def create_folder(self, db_id: str, folder_name: str, parent_id: str | None = None) -> dict: + """Create a folder in the database.""" + import uuid + + folder_id = f"folder-{uuid.uuid4()}" + + self.files_meta[folder_id] = { + "file_id": folder_id, + "filename": folder_name, + "is_folder": True, + "parent_id": parent_id, + "database_id": db_id, + "created_at": utc_isoformat(), + "status": "done", + "path": folder_name, + "file_type": "folder", + } + self._save_metadata() + return self.files_meta[folder_id] + @abstractmethod async def add_content(self, db_id: str, items: list[str], params: dict | None = None) -> list[dict]: """ @@ -307,6 +327,8 @@ def get_database_info(self, db_id: str) -> dict | None: "status": file_info.get("status", "done"), "created_at": created_at, "processing_params": file_info.get("processing_params", None), + "is_folder": file_info.get("is_folder", False), + "parent_id": file_info.get("parent_id", None), } # 按创建时间倒序排序文件列表 @@ -350,6 +372,8 @@ def get_databases(self) -> dict: "type": file_info.get("file_type", ""), "status": file_info.get("status", "done"), "created_at": created_at, + "is_folder": file_info.get("is_folder", False), + "parent_id": file_info.get("parent_id", None), } # 按创建时间倒序排序文件列表 @@ -439,6 +463,72 @@ def _check_and_fix_processing_status(self, db_id: str) -> None: except Exception as e: logger.error(f"Error checking processing status for database {db_id}: {e}") + async def delete_folder(self, db_id: str, folder_id: str) -> None: + """ + Recursively delete a folder and its content. + + Args: + db_id: Database ID + folder_id: Folder ID to delete + """ + # Find all children + children = [ + fid + for fid, meta in self.files_meta.items() + if meta.get("database_id") == db_id and meta.get("parent_id") == folder_id + ] + + for child_id in children: + child_meta = self.files_meta.get(child_id) + if child_meta and child_meta.get("is_folder"): + await self.delete_folder(db_id, child_id) + else: + await self.delete_file(db_id, child_id) + + # Delete the folder itself + # We call delete_file which should handle the actual removal. + # Implementations should ensure they handle folder deletion gracefully (e.g. skip vector deletion) + await self.delete_file(db_id, folder_id) + + async def move_file(self, db_id: str, file_id: str, new_parent_id: str | None) -> dict: + """ + Move a file or folder to a new parent folder. + + Args: + db_id: Database ID + file_id: File/Folder ID to move + new_parent_id: New parent folder ID (None for root) + + Returns: + dict: Updated metadata + """ + if file_id not in self.files_meta: + raise ValueError(f"File {file_id} not found") + + meta = self.files_meta[file_id] + if meta.get("database_id") != db_id: + raise ValueError(f"File {file_id} does not belong to database {db_id}") + + # Basic cycle detection for folders + if meta.get("is_folder") and new_parent_id: + # Check if new_parent_id is a child of file_id (or is file_id itself) + if new_parent_id == file_id: + raise ValueError("Cannot move a folder into itself") + + # Walk up the tree from new_parent_id + current = new_parent_id + while current: + parent_meta = self.files_meta.get(current) + if not parent_meta: + break # Should not happen if integrity is maintained + if current == file_id: + raise ValueError("Cannot move a folder into its own subfolder") + current = parent_meta.get("parent_id") + + meta["parent_id"] = new_parent_id + self._save_metadata() + return meta + @abstractmethod async def delete_file(self, db_id: str, file_id: str) -> None: """ diff --git a/src/knowledge/manager.py b/src/knowledge/manager.py index f19ed6c0..a409b57d 100644 --- a/src/knowledge/manager.py +++ b/src/knowledge/manager.py @@ -174,6 +174,13 @@ def _get_or_create_kb_instance(self, kb_type: str) -> KnowledgeBase: logger.info(f"Created {kb_type} knowledge base instance") return kb_instance + async def move_file(self, db_id: str, file_id: str, new_parent_id: str | None) -> dict: + """ + 移动文件/文件夹 + """ + kb_instance = self._get_kb_for_database(db_id) + return await kb_instance.move_file(db_id, file_id, new_parent_id) + def _get_kb_for_database(self, db_id: str) -> KnowledgeBase: """ 根据数据库ID获取对应的知识库实例 @@ -220,8 +227,13 @@ def get_databases(self) -> dict: return {"databases": all_databases} + async def create_folder(self, db_id: str, folder_name: str, parent_id: str = None) -> dict: + """Create a folder in the database.""" + kb_instance = self._get_kb_for_database(db_id) + return kb_instance.create_folder(db_id, folder_name, parent_id) + async def create_database( - self, database_name: str, description: str, kb_type: str, embed_info: dict | None = None, **kwargs + self, database_name: str, description: str, kb_type: str = "lightrag", embed_info: dict | None = None, **kwargs ) -> dict: """ 创建数据库 @@ -315,6 +327,11 @@ def get_database_info(self, db_id: str) -> dict | None: except KBNotFoundError: return None + async def delete_folder(self, db_id: str, folder_id: str) -> None: + """递归删除文件夹""" + kb_instance = self._get_kb_for_database(db_id) + await kb_instance.delete_folder(db_id, folder_id) + async def delete_file(self, db_id: str, file_id: str) -> None: """删除文件""" kb_instance = self._get_kb_for_database(db_id) diff --git a/src/knowledge/utils/kb_utils.py b/src/knowledge/utils/kb_utils.py index 569ff62d..c4cd7935 100644 --- a/src/knowledge/utils/kb_utils.py +++ b/src/knowledge/utils/kb_utils.py @@ -213,6 +213,7 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params "created_at": utc_isoformat(), "file_id": file_id, "content_hash": content_hash, + "parent_id": params.get("parent_id") if params else None, } # 保存处理参数到元数据 diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index aa3fa638..df074946 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -74,6 +74,34 @@ export const databaseApi = { // ============================================================================= export const documentApi = { + /** + * 创建文件夹 + * @param {string} dbId - 知识库ID + * @param {string} folderName - 文件夹名称 + * @param {string} parentId - 父文件夹ID + * @returns {Promise} - 创建结果 + */ + createFolder: async (dbId, folderName, parentId = null) => { + return apiAdminPost(`/api/knowledge/databases/${dbId}/folders`, { + folder_name: folderName, + parent_id: parentId + }) + }, + + /** + * 移动文档/文件夹 + * @param {string} dbId - 知识库ID + * @param {string} docId - 文档/文件夹ID + * @param {string} newParentId - 新的父文件夹ID + * @returns {Promise} - 移动结果 + */ + moveDocument: async (dbId, docId, newParentId) => { + return apiAdminPut(`/api/knowledge/databases/${dbId}/documents/${docId}/move`, { + new_parent_id: newParentId + }) + }, + + /** * 添加文档到知识库 * @param {string} dbId - 知识库ID diff --git a/web/src/components/FileTable.vue b/web/src/components/FileTable.vue index f2621f9c..e1e8043c 100644 --- a/web/src/components/FileTable.vue +++ b/web/src/components/FileTable.vue @@ -8,6 +8,13 @@ :loading="refreshing" :icon="h(PlusOutlined)" >添加文件 + 新建文件夹
+ + + + +