Ver Fonte

fix(novel): 优化章节删除与后台记忆清理

非 final 章节删除跳过派生记忆与实体处理。\nfinal 章节改为后台合并清理,并以 16 并发扫描实体。\n章节列表采用乐观删除并限制元数据刷新范围。
darknessomi há 1 mês atrás
pai
commit
a59272de7f

+ 15 - 0
src/components/layout/knowledge-tree.spec.tsx

@@ -16,4 +16,19 @@ describe("KnowledgeTree chapter memory extraction menu", () => {
     expect(source).toContain("useImportProgressStore.getState().startTask")
     expect(previewSource).not.toContain("一键提取所有章节")
   })
+
+  it("dispatches deleted chapter memory cleanup without awaiting it", () => {
+    expect(source).toContain("enqueueDeletedChapterSourceMemoryCleanup")
+    expect(source).toContain('void import("@/lib/novel/delete-source-memory")')
+    expect(source).not.toContain("await cleanupDeletedSourceMemory")
+    expect(source).toContain("await cleanupDeletedOutlineSourceMemory")
+  })
+
+  it("removes deleted chapter rows optimistically without showing chapter loading", () => {
+    expect(source).toContain("setPages((previous) => previous.filter((page) => page.path !== pagePath))")
+    expect(source).toContain('const showDeleteLoading = filterType === "outline" && isDeleting')
+    expect(source).toContain("mapWithConcurrency(files, PAGE_METADATA_CONCURRENCY")
+    expect(source).toContain('listDirectory(`${projectPath}/wiki/chapters`)')
+    expect(source).toContain('listDirectory(`${projectPath}/wiki/outlines`)')
+  })
 })

+ 67 - 26
src/components/layout/knowledge-tree.tsx

@@ -18,6 +18,7 @@ import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
 import { saveLastReadChapter } from "@/lib/project-store"
+import { mapWithConcurrency } from "@/lib/async-pool"
 import type { ReferenceToken } from "@/lib/reference/types"
 
 function formatImportProgressRunningLabel(task: ImportProgressTask, kindLabel: string): string {
@@ -80,6 +81,7 @@ interface CreateMenuState {
 }
 
 const EMPTY_PENDING_PAGES: WikiPageInfo[] = []
+const PAGE_METADATA_CONCURRENCY = 16
 
 function parseChineseNumber(input: string): number | null {
   const digitMap: Record<string, number> = {
@@ -215,9 +217,20 @@ function getRelativePath(path: string, rootPath: string): string {
     : normalizedPath.split("/").pop() ?? ""
 }
 
-async function cleanupDeletedSourceMemory(
+function enqueueDeletedChapterSourceMemoryCleanup(
   projectPath: string,
-  input: { kind: "chapter" | "outline"; pagePath: string; content?: string },
+  input: { kind: "chapter"; pagePath: string; content?: string },
+): void {
+  void import("@/lib/novel/delete-source-memory")
+    .then(({ deleteNovelSourceMemory }) => deleteNovelSourceMemory(projectPath, input))
+    .catch((error) => {
+      console.warn("[KnowledgeTree] 后台清理章节关联记忆失败:", error)
+    })
+}
+
+async function cleanupDeletedOutlineSourceMemory(
+  projectPath: string,
+  input: { kind: "outline"; pagePath: string },
 ): Promise<void> {
   const { deleteNovelSourceMemory } = await import("@/lib/novel/delete-source-memory")
   await deleteNovelSourceMemory(projectPath, input)
@@ -378,19 +391,23 @@ export function KnowledgeTree({
     if (!project) return
     const projectPath = normalizePath(project.path)
     try {
-      const wikiTree = await listDirectory(`${projectPath}/wiki`)
-      const nextPages: WikiPageInfo[] = []
-      for (const file of flattenMdFiles(wikiTree)) {
-        if (file.name === "index.md" || file.name === "log.md") continue
+      const directoryResults = await Promise.allSettled([
+        listDirectory(`${projectPath}/wiki/chapters`),
+        listDirectory(`${projectPath}/wiki/outlines`),
+      ])
+      const files = directoryResults.flatMap((result) =>
+        result.status === "fulfilled" ? flattenMdFiles(result.value) : [],
+      )
+      const loadedPages = await mapWithConcurrency(files, PAGE_METADATA_CONCURRENCY, async (file) => {
         try {
           const content = await readFile(file.path)
-          const info = parsePageInfo(file.path, file.name, content)
-          if (info) nextPages.push(info)
+          return parsePageInfo(file.path, file.name, content)
         } catch {
           // Ignore unreadable pages in the navigator.
+          return null
         }
-      }
-      setPages(nextPages)
+      })
+      setPages(loadedPages.filter((page): page is WikiPageInfo => page !== null))
     } catch (error) {
       console.error("[KnowledgeTree] loadPages failed:", error)
       setPages([])
@@ -791,6 +808,7 @@ export function KnowledgeTree({
 
   const handleDeleteClick = useCallback(async (pagePath: string) => {
     if (!project) return
+    if (deletingPath === pagePath) return
     if (armedPath !== pagePath) {
       setArmedPath(pagePath)
       return
@@ -807,28 +825,36 @@ export function KnowledgeTree({
         } catch { /* ignore */ }
       }
       await moveFileToTrash(projectPath, pagePath, filterType)
-      try {
-        await cleanupDeletedSourceMemory(projectPath, {
-          kind: filterType,
+      setPages((previous) => previous.filter((page) => page.path !== pagePath))
+      onRemovePendingPage?.(pagePath)
+      if (selectedFile === pagePath) setSelectedFile(null)
+      setDeletingPath(null)
+      if (filterType === "chapter") {
+        enqueueDeletedChapterSourceMemoryCleanup(projectPath, {
+          kind: "chapter",
           pagePath,
           content: sourceContent,
         })
-      } catch (e) {
-        // 记忆清理失败不影响文件删除本身
-        console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
+      } else {
+        try {
+          await cleanupDeletedOutlineSourceMemory(projectPath, {
+            kind: "outline",
+            pagePath,
+          })
+        } catch (e) {
+          // 记忆清理失败不影响文件删除本身
+          console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
+        }
       }
-      await loadPages()
-      onRemovePendingPage?.(pagePath)
       const tree = await listDirectory(projectPath)
       setFileTree(tree)
       bumpDataVersion()
-      if (selectedFile === pagePath) setSelectedFile(null)
     } catch (error) {
       console.error("[KnowledgeTree] delete failed:", error)
     } finally {
       setDeletingPath(null)
     }
-  }, [project, armedPath, filterType, loadPages, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
+  }, [project, deletingPath, armedPath, filterType, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
 
   const handleDeleteFolder = useCallback(async (folderPath: string) => {
     if (!project) return
@@ -853,11 +879,25 @@ export function KnowledgeTree({
       const projectPath = normalizePath(project.path)
       for (const filePath of mdFiles) {
         try {
+          let sourceContent: string | undefined
+          if (fileKind === "chapter") {
+            try {
+              sourceContent = await readFile(filePath)
+            } catch { /* ignore */ }
+          }
           await moveFileToTrash(projectPath, filePath, fileKind)
-          await cleanupDeletedSourceMemory(projectPath, {
-            kind: fileKind,
-            pagePath: filePath,
-          })
+          if (fileKind === "chapter") {
+            enqueueDeletedChapterSourceMemoryCleanup(projectPath, {
+              kind: "chapter",
+              pagePath: filePath,
+              content: sourceContent,
+            })
+          } else {
+            await cleanupDeletedOutlineSourceMemory(projectPath, {
+              kind: "outline",
+              pagePath: filePath,
+            })
+          }
         } catch (e) {
           // 单个文件删除失败不影响整体流程(如文件已不存在的幽灵条目)
           console.warn("[KnowledgeTree] 删除文件失败,继续处理下一个:", filePath, e)
@@ -1462,6 +1502,7 @@ export function KnowledgeTree({
       const isChapterChecked = filterType === "chapter" && selectedChapterPaths.has(normalizedPath)
       const isArmed = armedPath === normalizedPath
       const isDeleting = deletingPath === normalizedPath
+      const showDeleteLoading = filterType === "outline" && isDeleting
       const isDragSource = dragSource === normalizedPath
       const chapterIndex = chapterIndexMap.get(normalizedPath)
       const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
@@ -1563,8 +1604,8 @@ export function KnowledgeTree({
           ) : null}
           <DeleteButton
             armed={isArmed}
-            deleting={isDeleting}
-            className={`mr-1 transition-opacity ${isArmed || isDeleting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
+            deleting={showDeleteLoading}
+            className={`mr-1 transition-opacity ${isArmed || showDeleteLoading ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
             onClick={() => void handleDeleteClick(normalizedPath)}
             name={page.title}
           />

+ 27 - 5
src/lib/novel/chapter-ingest.ts

@@ -1395,7 +1395,7 @@ async function syncForeshadowingChanges(projectPath: string, snapshot: ChapterSn
   await saveForeshadowingTracker(projectPath, existingForeshadows)
 }
 
-async function rebuildDerivedMemoryFromSnapshots(projectPath: string, latestSnapshot?: ChapterSnapshot): Promise<void> {
+export async function rebuildDerivedMemoryFromSnapshots(projectPath: string, latestSnapshot?: ChapterSnapshot): Promise<void> {
   const snapshots = await loadValidMemorySnapshots(projectPath, latestSnapshot)
 
   const cognitionState = snapshots.reduce(
@@ -1582,14 +1582,36 @@ export async function listSnapshots(projectPath: string): Promise<number[]> {
   }
 }
 
-export async function deleteChapterSnapshots(projectPath: string, chapterNumber: number): Promise<void> {
+export async function deleteChapterSnapshotArtifacts(projectPath: string, chapterNumber: number): Promise<boolean> {
   const pp = normalizePath(projectPath)
   const jsonPath = snapshotJsonPath(pp, chapterNumber)
   const mdPath = snapshotMarkdownPath(pp, chapterNumber)
   const historyDir = snapshotHistoryDir(pp, chapterNumber)
-  try { if (await fileExists(jsonPath)) await deleteFile(jsonPath) } catch { /* ignore */ }
-  try { if (await fileExists(mdPath)) await deleteFile(mdPath) } catch { /* ignore */ }
-  try { if (await fileExists(historyDir)) await deleteFile(historyDir) } catch { /* ignore */ }
+  let deleted = false
+  try {
+    if (await fileExists(jsonPath)) {
+      await deleteFile(jsonPath)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  try {
+    if (await fileExists(mdPath)) {
+      await deleteFile(mdPath)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  try {
+    if (await fileExists(historyDir)) {
+      await deleteFile(historyDir)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  return deleted
+}
+
+export async function deleteChapterSnapshots(projectPath: string, chapterNumber: number): Promise<void> {
+  const pp = normalizePath(projectPath)
+  await deleteChapterSnapshotArtifacts(pp, chapterNumber)
   await rebuildDerivedMemoryFromSnapshots(pp)
   clearGraphCache()
   useWikiStore.getState().bumpDataVersion()

+ 223 - 57
src/lib/novel/delete-source-memory.spec.ts

@@ -2,111 +2,277 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
 
 const fsMocks = vi.hoisted(() => ({
   deleteFile: vi.fn(),
+  fileExists: vi.fn(),
   listDirectory: vi.fn(),
   readFile: vi.fn(),
   writeFileAtomic: vi.fn(),
 }))
 
+const ingestMocks = vi.hoisted(() => ({
+  deleteChapterSnapshotArtifacts: vi.fn(),
+  deleteChapterSnapshots: vi.fn(),
+  rebuildDerivedMemoryFromSnapshots: vi.fn(),
+}))
+
+const graphMocks = vi.hoisted(() => ({
+  clearGraphCache: vi.fn(),
+}))
+
+const storeMocks = vi.hoisted(() => ({
+  bumpDataVersion: vi.fn(),
+}))
+
 vi.mock("@/commands/fs", () => ({
   deleteFile: fsMocks.deleteFile,
+  fileExists: fsMocks.fileExists,
   listDirectory: fsMocks.listDirectory,
   readFile: fsMocks.readFile,
   writeFileAtomic: fsMocks.writeFileAtomic,
 }))
 
+vi.mock("@/lib/novel/chapter-ingest", () => ingestMocks)
+vi.mock("@/lib/graph-relevance", () => graphMocks)
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: {
+    getState: () => storeMocks,
+  },
+}))
+
 import {
   deleteNovelSourceMemory,
+  flushDeletedChapterMemoryCleanup,
   getOutlineSnapshotNumberFromPath,
+  shouldCleanupDeletedChapterMemory,
 } from "./delete-source-memory"
-import { deleteChapterSnapshots } from "@/lib/novel/chapter-ingest"
 
-vi.mock("@/lib/novel/chapter-ingest", () => ({
-  deleteChapterSnapshots: vi.fn(),
-}))
+function chapterContent(status: string | null, chapterNumber = 12): string {
+  return [
+    "---",
+    "type: chapter",
+    `chapter_number: ${chapterNumber}`,
+    ...(status === null ? [] : [`chapter_status: ${status}`]),
+    "---",
+    `# 第${chapterNumber}章`,
+  ].join("\n")
+}
+
+function entityContent(sources: string[]): string {
+  return [
+    "---",
+    "type: entity",
+    `sources: [${sources.map((source) => `"${source}"`).join(", ")}]`,
+    'source_type: "chapter"',
+    "---",
+    "# 主角",
+  ].join("\n")
+}
 
 describe("deleteNovelSourceMemory", () => {
-  beforeEach(() => {
+  beforeEach(async () => {
+    await flushDeletedChapterMemoryCleanup()
     vi.clearAllMocks()
+    fsMocks.listDirectory.mockResolvedValue([])
+    fsMocks.fileExists.mockResolvedValue(false)
+    fsMocks.readFile.mockResolvedValue("")
+    ingestMocks.deleteChapterSnapshotArtifacts.mockResolvedValue(true)
+    ingestMocks.rebuildDerivedMemoryFromSnapshots.mockResolvedValue(undefined)
+    ingestMocks.deleteChapterSnapshots.mockResolvedValue(undefined)
   })
 
-  it("deletes chapter snapshots by chapter_number before the page disappears", async () => {
+  it.each(["outline", "draft", "revised", "archived"])(
+    "does not clean memory for non-final chapter status %s",
+    async (status) => {
+      await deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-012.md",
+        content: chapterContent(status),
+      })
+      await flushDeletedChapterMemoryCleanup()
+
+      expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+      expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).not.toHaveBeenCalled()
+      expect(fsMocks.listDirectory).not.toHaveBeenCalled()
+    },
+  )
+
+  it.each([null, "unknown"])(
+    "treats missing or invalid status %s as non-final",
+    async (status) => {
+      const content = chapterContent(status)
+      expect(shouldCleanupDeletedChapterMemory(content)).toBe(false)
+    },
+  )
+
+  it("queues final chapter cleanup and returns before the background batch runs", async () => {
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
 
-    expect(deleteChapterSnapshots).toHaveBeenCalledWith("/project", 12)
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+
+    await flushDeletedChapterMemoryCleanup()
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).toHaveBeenCalledWith("/project", 12)
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).toHaveBeenCalledTimes(1)
   })
 
-  it("deletes outline snapshots using the same filename hash as outline ingest", async () => {
-    const outlinePath = "/project/wiki/outlines/人物小传/主角.md"
-    const expected = getOutlineSnapshotNumberFromPath(outlinePath)
+  it("coalesces consecutive final deletions into one rebuild and one entity scan", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [{ name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(entityContent(["012.snapshot.json", "013.snapshot.json", "014.snapshot.json"]))
+
+    await Promise.all([
+      deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-012.md",
+        content: chapterContent("final", 12),
+      }),
+      deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-013.md",
+        content: chapterContent("final", 13),
+      }),
+    ])
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).toHaveBeenCalledTimes(2)
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).toHaveBeenCalledTimes(1)
+    expect(fsMocks.listDirectory).toHaveBeenCalledWith("/project/wiki/entities")
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "/project/wiki/entities/主角.md",
+      expect.stringContaining("014.snapshot.json"),
+    )
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "/project/wiki/entities/主角.md",
+      expect.not.stringContaining("012.snapshot.json"),
+    )
+  })
+
+  it("deletes an entity whose only source belongs to the deleted chapter", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [{ name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(entityContent(["012.snapshot.json"]))
 
     await deleteNovelSourceMemory("/project", {
-      kind: "outline",
-      pagePath: outlinePath,
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(expected).toBeLessThan(0)
-    expect(deleteChapterSnapshots).toHaveBeenCalledWith("/project", expected)
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/主角.md")
   })
 
-  it("deletes entity pages that only came from the deleted chapter source", async () => {
-    fsMocks.listDirectory.mockResolvedValueOnce([
-      { name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false },
-    ])
-    fsMocks.readFile.mockResolvedValueOnce([
-      "---",
-      "type: entity",
-      'sources: ["012.snapshot.json"]',
-      'source_type: "chapter"',
-      "source_sequence: 12",
-      "---",
-      "# 主角",
-    ].join("\n"))
+  it("scans entity files concurrently with a maximum of 16 readers", async () => {
+    const files = Array.from({ length: 40 }, (_, index) => ({
+      name: `实体-${index}.md`,
+      path: `/project/wiki/entities/实体-${index}.md`,
+      is_dir: false,
+    }))
+    fsMocks.listDirectory.mockImplementation(async (path: string) => path.endsWith("/wiki/entities") ? files : [])
+
+    let activeReads = 0
+    let maxActiveReads = 0
+    fsMocks.readFile.mockImplementation(async () => {
+      activeReads += 1
+      maxActiveReads = Math.max(maxActiveReads, activeReads)
+      await new Promise((resolve) => setTimeout(resolve, 2))
+      activeReads -= 1
+      return entityContent(["999.snapshot.json"])
+    })
 
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/主角.md")
+    expect(maxActiveReads).toBeGreaterThan(1)
+    expect(maxActiveReads).toBeLessThanOrEqual(16)
   })
 
-  it("preserves entity pages that still reference other sources", async () => {
-    fsMocks.listDirectory.mockResolvedValueOnce([
-      { name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false },
-    ])
-    fsMocks.readFile.mockResolvedValueOnce([
-      "---",
-      "type: entity",
-      'sources: ["012.snapshot.json", "013.snapshot.json"]',
-      'source_type: "chapter"',
-      "source_sequence: 13",
-      "---",
-      "# 主角",
-      "",
-      "## 章节信息",
-      "",
-      "- **相关章节**: 12",
-      "",
-      "## 章节信息",
-      "",
-      "- **相关章节**: 13",
-    ].join("\n"))
+  it("continues cleaning other entities when one entity read fails", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [
+          { name: "损坏.md", path: "/project/wiki/entities/损坏.md", is_dir: false },
+          { name: "正常.md", path: "/project/wiki/entities/正常.md", is_dir: false },
+        ]
+      }
+      return []
+    })
+    fsMocks.readFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/损坏.md")) throw new Error("read failed")
+      return entityContent(["012.snapshot.json"])
+    })
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined)
 
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(fsMocks.deleteFile).not.toHaveBeenCalledWith("/project/wiki/entities/主角.md")
-    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
-      "/project/wiki/entities/主角.md",
-      expect.not.stringContaining("012.snapshot.json"),
-    )
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/正常.md")
+    expect(consoleError).toHaveBeenCalled()
+    consoleError.mockRestore()
+  })
+
+  it("skips cleanup if the chapter number exists again before the worker runs", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/chapters")) {
+        return [{ name: "restored.md", path: "/project/wiki/chapters/restored.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(chapterContent("final"))
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
+    })
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).not.toHaveBeenCalled()
+  })
+
+  it("skips cleanup if the original chapter path was restored", async () => {
+    fsMocks.fileExists.mockResolvedValue(true)
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
+    })
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+    expect(fsMocks.listDirectory).not.toHaveBeenCalled()
+  })
+
+  it("keeps outline deletion synchronous and uses its ingest hash", async () => {
+    const outlinePath = "/project/wiki/outlines/人物小传/主角.md"
+    const expected = getOutlineSnapshotNumberFromPath(outlinePath)
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "outline",
+      pagePath: outlinePath,
+    })
+
+    expect(expected).toBeLessThan(0)
+    expect(ingestMocks.deleteChapterSnapshots).toHaveBeenCalledWith("/project", expected)
   })
 })

+ 187 - 7
src/lib/novel/delete-source-memory.ts

@@ -1,6 +1,11 @@
 import { normalizePath } from "@/lib/path-utils"
-import { deleteFile, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import { deleteFile, fileExists, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
 import { parseSources, writeSources } from "@/lib/sources-merge"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { clearGraphCache } from "@/lib/graph-relevance"
+import { mapWithConcurrency } from "@/lib/async-pool"
+import { useWikiStore } from "@/stores/wiki-store"
+import { isChapterPage, isFinalChapter, parseChapterNumber } from "./chapter-meta"
 import type { FileNode } from "@/types/wiki"
 
 export type NovelSourceKind = "chapter" | "outline"
@@ -11,6 +16,17 @@ export interface DeleteNovelSourceMemoryInput {
   content?: string
 }
 
+interface PendingChapterCleanup {
+  chapterNumber: number
+  pagePath: string
+}
+
+const ENTITY_CLEANUP_CONCURRENCY = 16
+const BACKGROUND_CLEANUP_DEBOUNCE_MS = 50
+const pendingChapterCleanups = new Map<string, Map<number, PendingChapterCleanup>>()
+let cleanupTimer: ReturnType<typeof setTimeout> | null = null
+let cleanupWorker: Promise<void> | null = null
+
 export function getOutlineSnapshotNumberFromPath(outlinePath: string): number {
   const normalizedPath = normalizePath(outlinePath)
   const fileName = normalizedPath.split("/").pop() ?? "outline"
@@ -54,6 +70,20 @@ function flattenEntityFiles(nodes: readonly FileNode[]): FileNode[] {
   return files
 }
 
+function flattenMarkdownFiles(nodes: readonly FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir && node.children) {
+      files.push(...flattenMarkdownFiles(node.children))
+      continue
+    }
+    if (!node.is_dir && node.name.toLowerCase().endsWith(".md")) {
+      files.push(node)
+    }
+  }
+  return files
+}
+
 function snapshotSourceFileNameCandidates(snapshotNumber: number): string[] {
   if (snapshotNumber < 0) {
     const absolute = Math.abs(snapshotNumber)
@@ -68,9 +98,9 @@ function snapshotSourceFileNameCandidates(snapshotNumber: number): string[] {
   ]
 }
 
-async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumber: number): Promise<void> {
+async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumbers: readonly number[]): Promise<void> {
   const pp = normalizePath(projectPath)
-  const deletedSources = new Set(snapshotSourceFileNameCandidates(snapshotNumber))
+  const deletedSources = new Set(snapshotNumbers.flatMap(snapshotSourceFileNameCandidates))
   let entityFiles: FileNode[] = []
 
   try {
@@ -79,22 +109,165 @@ async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumber:
     return
   }
 
-  for (const file of entityFiles) {
+  await mapWithConcurrency(entityFiles, ENTITY_CLEANUP_CONCURRENCY, async (file) => {
     try {
       const content = await readFile(file.path)
       const sources = parseSources(content)
       const remainingSources = sources.filter((source) => !deletedSources.has(source))
-      if (remainingSources.length === sources.length) continue
+      if (remainingSources.length === sources.length) return
 
       if (remainingSources.length === 0) {
         await deleteFile(file.path)
-        continue
+        return
       }
 
       await writeFileAtomic(file.path, writeSources(content, remainingSources))
     } catch (error) {
       console.error("[delete-source-memory] failed to clean entity source:", file.path, error)
     }
+  })
+}
+
+async function listCurrentChapterNumbers(projectPath: string): Promise<Set<number> | null> {
+  let chapterFiles: FileNode[] = []
+  try {
+    chapterFiles = flattenMarkdownFiles(await listDirectory(`${normalizePath(projectPath)}/wiki/chapters`))
+  } catch {
+    return null
+  }
+
+  const chapterNumbers = await mapWithConcurrency(
+    chapterFiles,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (file): Promise<number | null> => {
+      try {
+        const parsed = parseFrontmatter(await readFile(file.path))
+        const frontmatter = parsed.frontmatter as Record<string, unknown> | null
+        if (!frontmatter || !isChapterPage(frontmatter)) return null
+        return parseChapterNumber(frontmatter.chapter_number)
+      } catch {
+        return null
+      }
+    },
+  )
+
+  return new Set(chapterNumbers.filter((chapterNumber): chapterNumber is number => chapterNumber !== null))
+}
+
+export function shouldCleanupDeletedChapterMemory(content?: string): boolean {
+  if (!content) return false
+  try {
+    const parsed = parseFrontmatter(content)
+    const frontmatter = parsed.frontmatter as Record<string, unknown> | null
+    return Boolean(frontmatter && isChapterPage(frontmatter) && isFinalChapter(frontmatter))
+  } catch {
+    return false
+  }
+}
+
+async function runDeletedChapterCleanupBatch(
+  projectPath: string,
+  entries: readonly PendingChapterCleanup[],
+): Promise<void> {
+  const restoredPaths = await mapWithConcurrency(
+    entries,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (entry) => {
+      try {
+        return await fileExists(entry.pagePath)
+      } catch {
+        return false
+      }
+    },
+  )
+  const missingEntries = entries.filter((_, index) => !restoredPaths[index])
+  if (missingEntries.length === 0) return
+
+  const currentChapterNumbers = await listCurrentChapterNumbers(projectPath)
+  if (!currentChapterNumbers) {
+    console.error("[delete-source-memory] failed to verify current chapters; cleanup skipped:", projectPath)
+    return
+  }
+  const activeEntries = missingEntries.filter((entry) => !currentChapterNumbers.has(entry.chapterNumber))
+  if (activeEntries.length === 0) return
+
+  const snapshotNumbers = [...new Set(activeEntries.map((entry) => entry.chapterNumber))]
+  const {
+    deleteChapterSnapshotArtifacts,
+    rebuildDerivedMemoryFromSnapshots,
+  } = await import("@/lib/novel/chapter-ingest")
+
+  await mapWithConcurrency(
+    snapshotNumbers,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (chapterNumber) => deleteChapterSnapshotArtifacts(projectPath, chapterNumber),
+  )
+
+  try {
+    await rebuildDerivedMemoryFromSnapshots(projectPath)
+  } catch (error) {
+    console.error("[delete-source-memory] failed to rebuild derived memory:", projectPath, error)
+  }
+
+  await cleanupDeletedSourceEntities(projectPath, snapshotNumbers)
+  clearGraphCache()
+  useWikiStore.getState().bumpDataVersion()
+}
+
+async function processPendingChapterCleanups(): Promise<void> {
+  while (pendingChapterCleanups.size > 0) {
+    const next = pendingChapterCleanups.entries().next().value as
+      | [string, Map<number, PendingChapterCleanup>]
+      | undefined
+    if (!next) return
+
+    const [projectPath, pending] = next
+    pendingChapterCleanups.delete(projectPath)
+    try {
+      await runDeletedChapterCleanupBatch(projectPath, [...pending.values()])
+    } catch (error) {
+      console.error("[delete-source-memory] background cleanup failed:", projectPath, error)
+    }
+  }
+}
+
+function startCleanupWorker(): void {
+  if (cleanupWorker) return
+  cleanupWorker = processPendingChapterCleanups().finally(() => {
+    cleanupWorker = null
+    if (pendingChapterCleanups.size > 0) scheduleCleanupWorker()
+  })
+}
+
+function scheduleCleanupWorker(): void {
+  if (cleanupTimer || cleanupWorker) return
+  cleanupTimer = setTimeout(() => {
+    cleanupTimer = null
+    startCleanupWorker()
+  }, BACKGROUND_CLEANUP_DEBOUNCE_MS)
+}
+
+export function enqueueDeletedChapterMemoryCleanup(
+  projectPath: string,
+  chapterNumber: number,
+  pagePath: string,
+): void {
+  const pp = normalizePath(projectPath)
+  const pending = pendingChapterCleanups.get(pp) ?? new Map<number, PendingChapterCleanup>()
+  pending.set(chapterNumber, { chapterNumber, pagePath: normalizePath(pagePath) })
+  pendingChapterCleanups.set(pp, pending)
+  scheduleCleanupWorker()
+}
+
+export async function flushDeletedChapterMemoryCleanup(): Promise<void> {
+  if (cleanupTimer) {
+    clearTimeout(cleanupTimer)
+    cleanupTimer = null
+    startCleanupWorker()
+  }
+  if (cleanupWorker) await cleanupWorker
+  if (pendingChapterCleanups.size > 0 || cleanupTimer || cleanupWorker) {
+    await flushDeletedChapterMemoryCleanup()
   }
 }
 
@@ -104,7 +277,14 @@ export async function deleteNovelSourceMemory(
 ): Promise<void> {
   const snapshotNumber = getChapterSnapshotNumberFromDeletedSource(input)
   if (snapshotNumber === null) return
+
+  if (input.kind === "chapter") {
+    if (!shouldCleanupDeletedChapterMemory(input.content)) return
+    enqueueDeletedChapterMemoryCleanup(projectPath, snapshotNumber, input.pagePath)
+    return
+  }
+
   const { deleteChapterSnapshots } = await import("@/lib/novel/chapter-ingest")
   await deleteChapterSnapshots(projectPath, snapshotNumber)
-  await cleanupDeletedSourceEntities(projectPath, snapshotNumber)
+  await cleanupDeletedSourceEntities(projectPath, [snapshotNumber])
 }