Files
JARVIS/docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md

942 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 知识库文件夹分层实施计划
> **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<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 类型**
```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
<!-- 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 组件
- 添加文件夹状态和加载逻辑
- 修改上传逻辑(需先选择文件夹)
- 添加新建/重命名/删除文件夹的弹窗
```vue
<!-- 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)