# 知识库文件夹分层实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD、级联删除 **Architecture:** 使用邻接表模式(parent_id)存储文件夹层级,通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。 **Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript --- ## Task 1: 创建 Folder 模型 **Files:** - Create: `backend/app/models/folder.py` - Test: `backend/app/models/test_folder.py` - [ ] **Step 1: 创建 Folder 模型** ```python # backend/app/models/folder.py from sqlalchemy import Column, String, ForeignKey, UniqueConstraint from app.models.base import BaseModel class Folder(BaseModel): __tablename__ = "folders" __table_args__ = ( UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'), ) user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) name = Column(String(255), nullable=False) parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True) ``` - [ ] **Step 2: 创建测试文件验证模型** ```python # backend/app/models/test_folder.py import pytest from app.models.folder import Folder def test_folder_model_creation(): folder = Folder(user_id="test-user", name="Test Folder") assert folder.name == "Test Folder" assert folder.parent_id is None ``` - [ ] **Step 3: 提交** --- ## Task 2: 创建 Folder Schema **Files:** - Create: `backend/app/schemas/folder.py` - [ ] **Step 1: 创建 Pydantic schemas** ```python # backend/app/schemas/folder.py from pydantic import BaseModel, Field, field_validator from typing import Optional, List from datetime import datetime class FolderCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) parent_id: Optional[str] = None @field_validator('name') @classmethod def validate_name(cls, v): forbidden = '/\\*?:' for c in forbidden: if c in v: raise ValueError(f'Folder name cannot contain: {forbidden}') return v class FolderUpdate(BaseModel): name: str = Field(..., min_length=1, max_length=255) class FolderOut(BaseModel): id: str name: str parent_id: Optional[str] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class FolderTreeOut(BaseModel): id: str name: str parent_id: Optional[str] children: List["FolderTreeOut"] = [] model_config = {"from_attributes": True} # 递归模型需要 forward ref FolderTreeOut.model_rebuild() ``` - [ ] **Step 2: 提交** --- ## Task 3: 创建文件夹路由 **Files:** - Create: `backend/app/routers/folder.py` - [ ] **Step 1: 实现文件夹 CRUD 路由** ```python # backend/app/routers/folder.py from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import List from app.database import get_db from app.models.folder import Folder from app.models.user import User from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut from app.services.auth_service import get_current_user router = APIRouter(prefix="/api/folders", tags=["文件夹"]) def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]: """递归构建文件夹树""" tree = [] for folder in folders: if folder.parent_id == parent_id: children = build_folder_tree(folders, folder.id) tree.append(FolderTreeOut( id=folder.id, name=folder.name, parent_id=folder.parent_id, children=children )) return tree @router.get("", response_model=List[FolderTreeOut]) async def get_folders( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """获取用户的完整文件夹树""" result = await db.execute( select(Folder).where(Folder.user_id == current_user.id) ) folders = result.scalars().all() return build_folder_tree(list(folders)) @router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED) async def create_folder( folder_data: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """创建文件夹""" # 验证父文件夹存在且属于当前用户 if folder_data.parent_id: result = await db.execute( select(Folder).where( and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id) ) ) if not result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="父文件夹不存在") # 检查同名文件夹 result = await db.execute( select(Folder).where( and_( Folder.user_id == current_user.id, Folder.parent_id == folder_data.parent_id, Folder.name == folder_data.name ) ) ) if result.scalar_one_or_none(): raise HTTPException(status_code=400, detail="同名文件夹已存在") folder = Folder( user_id=current_user.id, name=folder_data.name, parent_id=folder_data.parent_id ) db.add(folder) await db.commit() await db.refresh(folder) return folder @router.put("/{folder_id}", response_model=FolderOut) async def rename_folder( folder_id: str, folder_data: FolderUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """重命名文件夹""" result = await db.execute( select(Folder).where( and_(Folder.id == folder_id, Folder.user_id == current_user.id) ) ) folder = result.scalar_one_or_none() if not folder: raise HTTPException(status_code=404, detail="文件夹不存在") folder.name = folder_data.name await db.commit() await db.refresh(folder) return folder @router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """删除文件夹(级联删除文档)""" from app.models.document import Document from app.services.knowledge_service import KnowledgeService result = await db.execute( select(Folder).where( and_(Folder.id == folder_id, Folder.user_id == current_user.id) ) ) folder = result.scalar_one_or_none() if not folder: raise HTTPException(status_code=404, detail="文件夹不存在") async def delete_recursive(fid: str): # 删除子文件夹(先递归) children = await db.execute( select(Folder).where(Folder.parent_id == fid) ) for child in children.scalars(): await delete_recursive(child.id) # 删除文档 docs = await db.execute( select(Document).where(Document.folder_id == fid) ) for doc in docs.scalars(): knowledge_service = KnowledgeService(db, current_user.id) await knowledge_service.delete_from_vectorstore(current_user.id, doc.id) await db.delete(doc) # 删除文件夹本身 folder_to_delete = await db.get(Folder, fid) if folder_to_delete: await db.delete(folder_to_delete) await delete_recursive(folder_id) await db.commit() ``` - [ ] **Step 2: 提交** --- ## Task 4: 修改 Document 模型 **Files:** - Modify: `backend/app/models/document.py:14` - [ ] **Step 1: 添加 folder_id 外键** ```python # backend/app/models/document.py 第14行后添加 folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增 ``` - [ ] **Step 2: 提交** --- ## Task 5: 修改 Document 路由和服务 **Files:** - Modify: `backend/app/routers/document.py` - Modify: `backend/app/services/document_service.py` - [ ] **Step 1: 修改 Document 路由** 在 `routers/document.py` 中: - GET `/api/documents` 添加 `folder_id` 可选查询参数 - POST `/api/documents` 添加 `folder_id` 表单字段 ```python # GET /api/documents 修改 @router.get("") async def list_documents( folder_id: Optional[str] = None, # 新增 db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): query = select(Document).where(Document.user_id == current_user.id) if folder_id: query = query.where(Document.folder_id == folder_id) # ... 其余不变 # POST /api/documents 修改 @router.post("") async def upload_document( file: UploadFile = File(...), folder_id: Optional[str] = Form(None), # 新增 db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): # ... 文件处理逻辑 ... doc = await doc_svc.upload_document( user_id=current_user.id, file=file, folder_id=folder_id # 传递 folder_id ) ``` - [ ] **Step 2: 修改 DocumentService.upload_document** ```python # backend/app/services/document_service.py async def upload_document( self, user_id: str, file: UploadFile, folder_id: str | None = None, # 新增 ) -> Document: # ... 文件保存逻辑 ... # 获取文件夹路径(用于 ChromaDB metadata) folder_path = None if folder_id: folder_path = await self._get_folder_path(folder_id) # 创建文档记录 doc = Document( user_id=user_id, title=filename.rsplit('.', 1)[0], filename=filename, file_type=file_type, file_size=file_size, file_path=file_path, folder_id=folder_id, # 新增 ) # ... 其余逻辑 ... return doc async def _get_folder_path(self, folder_id: str) -> str | None: """获取文件夹的完整路径""" folders = await self.db.execute( select(Folder).where(Folder.user_id == self.user_id) ) folder_map = {f.id: f for f in folders.scalars().all()} path_parts = [] current_id = folder_id while current_id: folder = folder_map.get(current_id) if not folder: break path_parts.insert(0, folder.name) current_id = folder.parent_id return "/" + "/".join(path_parts) if path_parts else None ``` - [ ] **Step 2: 提交** --- ## Task 6: 修改 Knowledge Service **Files:** - Modify: `backend/app/services/knowledge_service.py` - [ ] **Step 1: retrieve 方法添加 folder_id 参数** ```python # backend/app/services/knowledge_service.py # 在 index_document 方法中添加 folder_path 到 metadata async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None): """将文档 chunks 向量化存入 ChromaDB""" # ... 现有代码 ... metadatas = [ { "document_id": doc.id, "document_title": doc.title, "chunk_index": chunk.chunk_index, "file_type": doc.file_type, "folder_path": folder_path or "", # 新增 } for chunk in chunks ] # ... 其余不变 async def retrieve( self, query: str, user_id: str, folder_id: str | None = None, # 新增 top_k: int = 5, use_rerank: bool = True, ) -> list[SearchResult]: """混合检索 + Rerank,支持按文件夹过滤""" collection = self.get_collection(user_id) # 构建过滤条件 where = None if folder_id: folder_path = await self._get_folder_path(folder_id) if folder_path: where = {"folder_path": {"$starts_with": folder_path}} try: results = collection.query( query_texts=[query], n_results=top_k * 3, where=where, include=["documents", "metadatas", "distances"], ) except Exception: return [] # ... 其余不变 async def _get_folder_path(self, folder_id: str) -> str | None: """获取文件夹的完整路径""" result = await self.db.execute( select(Folder).where(Folder.id == folder_id) ) folder = result.scalar_one_or_none() if not folder: return None path_parts = [folder.name] current_parent_id = folder.parent_id while current_parent_id: parent_result = await self.db.execute( select(Folder).where(Folder.id == current_parent_id) ) parent = parent_result.scalar_one_or_none() if not parent: break path_parts.insert(0, parent.name) current_parent_id = parent.parent_id return "/" + "/".join(path_parts) ``` - [ ] **Step 2: 提交** --- ## Task 7: 数据库迁移 **Files:** - 数据库操作: 添加 folders 表,添加 documents.folder_id 列 - [ ] **Step 1: 执行 SQL 迁移** ```python # 迁移脚本 import asyncio from app.database import engine from sqlalchemy import text async def migrate(): async with engine.begin() as conn: # 创建 folders 表 await conn.execute(text(""" CREATE TABLE IF NOT EXISTS folders ( id VARCHAR(36) PRIMARY KEY, name VARCHAR(255) NOT NULL, parent_id VARCHAR(36), user_id VARCHAR(36) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FOREIGN KEY (parent_id) REFERENCES folders(id), FOREIGN KEY (user_id) REFERENCES users(id) ) """)) # 添加 documents.folder_id 列 try: await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)")) except Exception: pass # 列已存在 asyncio.run(migrate()) ``` - [ ] **Step 2: 提交** --- ## Task 8: 前端 - 创建 Folder API **Files:** - Create: `frontend/src/api/folder.ts` - [ ] **Step 1: 创建文件夹 API 客户端** ```typescript // frontend/src/api/folder.ts import api from './index' export interface FolderCreate { name: string parent_id?: string | null } export interface FolderUpdate { name: string } export interface FolderItem { id: string name: string parent_id: string | null created_at: string updated_at: string } export interface FolderTree { id: string name: string parent_id: string | null children: FolderTree[] } export const folderApi = { // 获取文件夹树 getTree() { return api.get('/api/folders') }, // 创建文件夹 create(data: FolderCreate) { return api.post('/api/folders', data) }, // 重命名文件夹 rename(id: string, data: FolderUpdate) { return api.put(`/api/folders/${id}`, data) }, // 删除文件夹 delete(id: string) { return api.delete(`/api/folders/${id}`) }, } ``` - [ ] **Step 2: 提交** --- ## Task 9: 前端 - 修改 Document API **Files:** - Modify: `frontend/src/api/document.ts` - [ ] **Step 1: 添加 folder_id 到 Document 类型** ```typescript // frontend/src/api/document.ts export interface Document { id: string title: string filename: string file_type: string file_size: number file_path: string summary?: string chunk_count: number is_indexed: boolean folder_id?: string | null // 新增 } ``` - [ ] **Step 2: 提交** --- ## Task 10: 前端 - 创建 FolderTree 组件 **Files:** - Create: `frontend/src/components/FolderTree.vue` - [ ] **Step 1: 创建递归文件夹树组件** ```vue ``` - [ ] **Step 2: 提交** --- ## Task 11: 前端 - 改造 KnowledgeView **Files:** - Modify: `frontend/src/views/KnowledgeView.vue` - [ ] **Step 1: 添加文件夹侧边栏和交互逻辑** 主要改动: - 导入 FolderTree 组件 - 添加文件夹状态和加载逻辑 - 修改上传逻辑(需先选择文件夹) - 添加新建/重命名/删除文件夹的弹窗 ```vue ``` - [ ] **Step 2: 提交** --- ## Task 12: 集成测试 - [ ] **Step 1: 测试文件夹 CRUD** - [ ] **Step 2: 测试级联删除** - [ ] **Step 3: 测试上传文档到指定文件夹** - [ ] **Step 4: 测试按文件夹搜索** --- ## 总结 共 12 个 Task,分 4 个 Phase: - **Phase 1**: 数据层 (Task 1-4) - **Phase 2**: 后端 API (Task 5-7) - **Phase 3**: 前端 (Task 8-11) - **Phase 4**: 测试 (Task 12)