24 KiB
知识库文件夹分层实施计划
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 模型
# 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: 创建测试文件验证模型
# 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
# 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 路由
# 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 外键
# 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表单字段
# 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
# 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 参数
# 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 迁移
# 迁移脚本
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 客户端
// 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<FolderTree[]>('/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 类型
// 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: 创建递归文件夹树组件
<!-- frontend/src/components/FolderTree.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { folderApi, type FolderTree } from '@/api/folder'
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
folders: FolderTree[]
selectedId?: string | null
onSelect: (folder: FolderTree) => void
onCreate: (parentId: string | null) => void
onRename: (folder: FolderTree) => void
onDelete: (folder: FolderTree) => void
}>()
const expandedIds = ref<Set<string>>(new Set())
function toggleExpand(id: string) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
e.preventDefault()
// 显示右键菜单
}
</script>
<template>
<div class="folder-tree">
<div
v-for="folder in folders"
:key="folder.id"
class="folder-item"
>
<div
class="folder-row"
:class="{ selected: folder.id === selectedId }"
@click="props.onSelect(folder)"
@contextmenu="handleContextMenu($event, folder)"
>
<!-- 展开/折叠箭头 -->
<button
v-if="folder.children?.length"
class="expand-btn"
@click.stop="toggleExpand(folder.id)"
>
<ChevronRight
:size="12"
:class="{ rotated: expandedIds.has(folder.id) }"
/>
</button>
<span v-else class="expand-placeholder"></span>
<!-- 文件夹图标 -->
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
<Folder v-else :size="14" class="folder-icon" />
<!-- 文件夹名称 -->
<span class="folder-name">{{ folder.name }}</span>
<!-- 操作按钮 -->
<div class="folder-actions">
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
<Plus :size="12" />
</button>
<button @click.stop="props.onRename(folder)" title="重命名">
<Edit2 :size="12" />
</button>
<button @click.stop="props.onDelete(folder)" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<!-- 子文件夹(递归) -->
<div
v-if="folder.children?.length && expandedIds.has(folder.id)"
class="folder-children"
>
<FolderTree
:folders="folder.children"
:selected-id="selectedId"
:on-select="onSelect"
:on-create="onCreate"
:on-rename="onRename"
:on-delete="onDelete"
/>
</div>
</div>
</div>
</template>
<style scoped>
/* sci-fi 风格 */
.folder-tree {
font-family: var(--font-mono);
font-size: 12px;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.folder-row:hover {
background: rgba(0, 245, 212, 0.04);
}
.folder-row.selected {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
}
.expand-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-dim);
display: flex;
align-items: center;
}
.expand-placeholder {
width: 12px;
}
.folder-icon {
color: var(--accent-amber);
flex-shrink: 0;
}
.folder-name {
flex: 1;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
display: none;
gap: 2px;
}
.folder-row:hover .folder-actions {
display: flex;
}
.folder-actions button {
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: var(--text-dim);
border-radius: 3px;
transition: all var(--transition-fast);
}
.folder-actions button:hover {
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.1);
}
.folder-children {
padding-left: 16px;
}
</style>
- Step 2: 提交
Task 11: 前端 - 改造 KnowledgeView
Files:
-
Modify:
frontend/src/views/KnowledgeView.vue -
Step 1: 添加文件夹侧边栏和交互逻辑
主要改动:
- 导入 FolderTree 组件
- 添加文件夹状态和加载逻辑
- 修改上传逻辑(需先选择文件夹)
- 添加新建/重命名/删除文件夹的弹窗
<!-- KnowledgeView.vue 核心逻辑改动 -->
<script setup lang="ts">
// ... 现有代码 ...
// 新增
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
import FolderTreeComponent from '@/components/FolderTree.vue'
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
// 状态
const folders = ref<FolderTree[]>([])
const selectedFolderId = ref<string | null>(null)
// 加载文件夹树
async function loadFolders() {
const res = await folderApi.getTree()
folders.value = res.data
}
// 选择文件夹
function onSelectFolder(folder: FolderTree) {
selectedFolderId.value = folder.id
loadDocumentsByFolder(folder.id)
}
// 加载指定文件夹的文档
async function loadDocumentsByFolder(folderId: string) {
const res = await documentApi.list(folderId)
documents.value = res.data
}
// 新建文件夹弹窗
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const newFolderParentId = ref<string | null>(null)
function openNewFolderDialog(parentId: string | null = null) {
newFolderParentId.value = parentId
newFolderName.value = ''
showNewFolderDialog.value = true
}
async function createFolder() {
await folderApi.create({
name: newFolderName.value,
parent_id: newFolderParentId.value
})
await loadFolders()
showNewFolderDialog.value = false
}
// 重命名/删除类似...
</script>
<template>
<div class="knowledge-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<Database :size="20" />
<h1>KNOWLEDGE BASE</h1>
</div>
<div class="header-actions">
<button class="btn" @click="openNewFolderDialog(null)">
<FolderPlus :size="14" /> 新建文件夹
</button>
<button class="btn primary" @click="triggerUpload">
<Upload :size="14" /> 上传文档
</button>
</div>
</div>
<!-- 主布局:侧边栏 + 内容 -->
<div class="main-layout">
<!-- 左侧文件夹树 -->
<aside class="folder-sidebar">
<div class="sidebar-header">
<Folder :size="14" />
<span>文件夹</span>
<button class="add-btn" @click="openNewFolderDialog(null)">
<Plus :size="12" />
</button>
</div>
<div class="folder-list">
<FolderTreeComponent
:folders="folders"
:selected-id="selectedFolderId"
:on-select="onSelectFolder"
:on-create="openNewFolderDialog"
:on-rename="openRenameDialog"
:on-delete="openDeleteDialog"
/>
</div>
</aside>
<!-- 右侧内容区 -->
<main class="content-area">
<!-- 搜索栏 -->
<div class="search-panel">...</div>
<!-- 上传区 -->
<div class="upload-zone">...</div>
<!-- 文档列表 -->
<div class="docs-section">...</div>
</main>
</div>
<!-- 新建文件夹弹窗 -->
<div v-if="showNewFolderDialog" class="dialog-overlay">
<div class="dialog">
<h3>新建文件夹</h3>
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
<div class="dialog-actions">
<button @click="showNewFolderDialog = false">取消</button>
<button class="primary" @click="createFolder">创建</button>
</div>
</div>
</div>
</div>
</template>
- 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)