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

24 KiB
Raw Blame History

知识库文件夹分层实施计划

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)