فهرست منبع

feat(novel): 支持大纲单个重新提取并修复取消后状态

知识树与预览面板支持对已提取大纲单独重新提取
抽取 outline-ingest-utils 统一 snapshot 身份判定
取消一键提取后 reconcile 残留 ingesting task

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 ماه پیش
والد
کامیت
2b55d1d6ae

+ 88 - 1
src/components/layout/knowledge-tree.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, Pencil, Plus, Trash2, Check, X } from "lucide-react"
+import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, Pencil, Plus, Sparkles, Trash2, Check, X } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { ScrollArea } from "@/components/ui/scroll-area"
 import { Button } from "@/components/ui/button"
@@ -13,6 +13,9 @@ import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-
 import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore, type ImportProgressTask } from "@/stores/import-progress-store"
+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"
 
 function formatImportProgressRunningLabel(task: ImportProgressTask, kindLabel: string): string {
@@ -287,13 +290,17 @@ export function KnowledgeTree({
 }: KnowledgeTreeProps) {
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
+  const novelMode = useWikiStore((s) => s.novelMode)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const fileTree = useWikiStore((s) => s.fileTree)
   const setFileTree = useWikiStore((s) => s.setFileTree)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
   const dataVersion = useWikiStore((s) => s.dataVersion)
+  const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
+  const outlineImportTasks = useImportProgressStore((s) => s.tasks)
   const [pages, setPages] = useState<WikiPageInfo[]>([])
+  const [extractedOutlinePaths, setExtractedOutlinePaths] = useState<Set<string>>(() => new Set())
   const [collapsedFolders, setCollapsedFolders] = useState<Record<string, boolean>>({})
   const [armedPath, setArmedPath] = useState<string | null>(null)
   const [deletingPath, setDeletingPath] = useState<string | null>(null)
@@ -395,6 +402,59 @@ export function KnowledgeTree({
 
   const effectivePages = useMemo(() => [...pageInfoByPath.values()], [pageInfoByPath])
 
+  const outlinePages = useMemo(
+    () => effectivePages.filter((page): page is WikiPageInfo & { type: "outline" } => page.type === "outline"),
+    [effectivePages],
+  )
+
+  useEffect(() => {
+    if (!project || filterType !== "outline" || !novelMode) {
+      setExtractedOutlinePaths(new Set())
+      return
+    }
+    let cancelled = false
+    void (async () => {
+      const extracted = new Set<string>()
+      await Promise.all(outlinePages.map(async (page) => {
+        if (await outlineSnapshotExists(project.path, page.path)) {
+          extracted.add(normalizePath(page.path))
+        }
+      }))
+      if (!cancelled) setExtractedOutlinePaths(extracted)
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [filterType, novelMode, outlinePages, project, dataVersion, outlineTasks, outlineImportTasks])
+
+  const isOutlinePathIngesting = useCallback((outlinePath: string) => {
+    if (!project) return false
+    const normalizedPath = normalizePath(outlinePath)
+    const fileName = getOutlineFileName(normalizedPath)
+    const pp = normalizePath(project.path)
+    const importRunning = outlineImportTasks.find((task) => (
+      task.projectPath === pp &&
+      task.kind === "outline" &&
+      task.status === "running"
+    ))
+    if (importRunning) {
+      if (importRunning.total === 1 && importRunning.currentTitle === fileName) return true
+      if (importRunning.activeTitles?.includes(fileName)) return true
+    }
+    return outlineTasks.some((task) => (
+      task.projectPath === pp &&
+      task.kind === "ingest" &&
+      task.outlinePath != null &&
+      normalizePath(task.outlinePath) === normalizedPath &&
+      task.status === "ingesting"
+    ))
+  }, [outlineImportTasks, outlineTasks, project])
+
+  const handleOutlineIngest = useCallback((outlinePath: string) => {
+    if (!project || !novelMode || isOutlinePathIngesting(outlinePath)) return
+    startOutlineIngestTask(project.path, outlinePath)
+  }, [isOutlinePathIngesting, novelMode, project])
+
   const sortedChapterPages = useMemo(() => {
     return effectivePages
       .filter((page): page is WikiPageInfo & { type: "chapter" } => page.type === "chapter")
@@ -1066,6 +1126,8 @@ export function KnowledgeTree({
       const isDragSource = dragSource === normalizedPath
       const chapterIndex = chapterIndexMap.get(normalizedPath)
       const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
+      const isOutlineExtracted = filterType === "outline" && extractedOutlinePaths.has(normalizedPath)
+      const isOutlineIngesting = filterType === "outline" && isOutlinePathIngesting(normalizedPath)
       return [
         <div
           key={normalizedPath}
@@ -1139,6 +1201,27 @@ export function KnowledgeTree({
               </>
             )}
           </button>
+          {filterType === "outline" && novelMode ? (
+            <Button
+              variant="ghost"
+              size="icon"
+              className={`mr-0 h-7 w-7 shrink-0 ${isOutlineExtracted ? "text-emerald-600 hover:text-emerald-700" : ""}`}
+              title={isOutlineExtracted ? t("novel.outlineGenerator.reingestTitle") : t("novel.outlineGenerator.ingest")}
+              disabled={isOutlineIngesting}
+              onClick={(event) => {
+                event.stopPropagation()
+                handleOutlineIngest(normalizedPath)
+              }}
+            >
+              {isOutlineIngesting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : isOutlineExtracted ? (
+                <Check className="h-4 w-4" />
+              ) : (
+                <Sparkles className="h-4 w-4" />
+              )}
+            </Button>
+          ) : null}
           <DeleteButton
             armed={isArmed}
             deleting={isDeleting}
@@ -1174,6 +1257,10 @@ export function KnowledgeTree({
     dragInsertIndex,
     isDragging,
     sortedChapterPages,
+    novelMode,
+    extractedOutlinePaths,
+    isOutlinePathIngesting,
+    handleOutlineIngest,
     t,
   ])
 

+ 51 - 17
src/components/layout/preview-panel.tsx

@@ -31,6 +31,7 @@ import {
   setLastChapterDeAiSkill,
 } from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
+import { getOutlineIngestIdentity, getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
 import { streamChat } from "@/lib/llm-client"
 import {
   extractChapterNumberFromMarkdown,
@@ -174,6 +175,7 @@ export function PreviewPanel() {
   const pendingEditorHighlight = useWikiStore((s) => s.pendingEditorHighlight)
   const setPendingEditorHighlight = useWikiStore((s) => s.setPendingEditorHighlight)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
   const finalChapterSave = useWikiStore((s) => s.finalChapterSave)
   const setFinalChapterSave = useWikiStore((s) => s.setFinalChapterSave)
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
@@ -555,6 +557,26 @@ export function PreviewPanel() {
       .sort((a: OutlineGenerationTask, b: OutlineGenerationTask) => b.updatedAt - a.updatedAt)[0] ?? null
   }, [canIngestOutline, outlineTasks, project, selectedFile])
 
+  const outlineIngestProgressRunning = useImportProgressStore((s) => {
+    if (!project || !canIngestOutline || !selectedFile) return null
+    const pp = normalizePath(project.path)
+    return s.tasks.find((task) => (
+      task.projectPath === pp &&
+      task.kind === "outline" &&
+      task.status === "running"
+    )) ?? null
+  })
+  const isOutlineIngesting = useMemo(() => {
+    if (!project || !selectedFile || !canIngestOutline) return false
+    const fileName = getOutlineFileName(selectedFile)
+    if (outlineIngestProgressRunning) {
+      if (outlineIngestProgressRunning.total === 1) return true
+      if (outlineIngestProgressRunning.currentTitle === fileName) return true
+      if (outlineIngestProgressRunning.activeTitles?.includes(fileName)) return true
+    }
+    return currentOutlineTask?.status === "ingesting"
+  }, [canIngestOutline, currentOutlineTask, outlineIngestProgressRunning, project, selectedFile])
+
   // 检测大纲是否已经提取过初始记忆(持久化状态)
   useEffect(() => {
     if (!canIngestOutline || !project || !selectedFile) {
@@ -562,24 +584,29 @@ export function PreviewPanel() {
       setOutlineSnapshotNumber(null)
       return
     }
-    const normalizedOutlinePath = normalizePath(selectedFile)
-    const fileName = normalizedOutlinePath.split("/").pop() ?? "outline"
-    const outlineName = fileName.replace(/\.\w+$/, "")
-    let hash = 0
-    for (let i = 0; i < outlineName.length; i++) {
-      hash = ((hash << 5) - hash + outlineName.charCodeAt(i)) | 0
+    const { chapterNumber } = getOutlineIngestIdentity(project.path, selectedFile)
+    setOutlineSnapshotNumber(chapterNumber)
+    let cancelled = false
+    void outlineSnapshotExists(project.path, selectedFile)
+      .then((exists) => {
+        if (!cancelled) setOutlineIngested(exists)
+      })
+      .catch(() => {
+        if (!cancelled) setOutlineIngested(false)
+      })
+    return () => {
+      cancelled = true
     }
-    const outlineNum = -(Math.abs(hash % 999) + 1)
-    setOutlineSnapshotNumber(outlineNum)
-    const prefix = `outline-${String(Math.abs(outlineNum)).padStart(3, "0")}`
-    const jsonPath = `${normalizePath(project.path)}/.novel/snapshots/${prefix}.snapshot.json`
-    fileExists(jsonPath).then((exists) => setOutlineIngested(exists)).catch(() => setOutlineIngested(false))
-  }, [canIngestOutline, project, selectedFile])
+  }, [canIngestOutline, project, selectedFile, dataVersion, currentOutlineTask?.status, currentOutlineTask?.updatedAt])
   useEffect(() => {
     if (!canIngestOutline) return
     if (!currentOutlineTask?.message) return
+    if (currentOutlineTask.status === "ingesting" && !outlineIngestProgressRunning) {
+      setSaveStatus("")
+      return
+    }
     setSaveStatus(currentOutlineTask.message)
-  }, [canIngestOutline, currentOutlineTask])
+  }, [canIngestOutline, currentOutlineTask, outlineIngestProgressRunning])
   const chapterNumber = useMemo(() => {
     if (!chapterFrontmatter) return null
     const meta = parseChapterMeta(chapterFrontmatter)
@@ -588,7 +615,6 @@ export function PreviewPanel() {
   const canViewSnapshot = Boolean(novelMode && project && chapterNumber !== null)
   const currentFinalChapterSave = finalChapterSave != null && finalChapterSave.projectPath === project?.path && finalChapterSave.filePath === selectedFile ? finalChapterSave : null
   const isFinalChapterSaving = currentFinalChapterSave?.saving ?? isSavingFinal
-  const isOutlineIngesting = currentOutlineTask?.status === "ingesting"
 
   const phaseLabelMap: Record<FinalChapterSavePhase, string> = {
     saving: t("novel.chapter.savingAsFinal"),
@@ -1363,7 +1389,11 @@ export function PreviewPanel() {
                       disabled={isOutlineIngesting}
                       className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
                     >
-                      {isOutlineIngesting ? t("novel.outlineGenerator.ingesting") : outlineIngested ? "已提取记忆" : t("novel.outlineGenerator.ingest")}
+                      {isOutlineIngesting
+                        ? t("novel.outlineGenerator.ingesting")
+                        : outlineIngested
+                          ? t("novel.outlineGenerator.reingestButton")
+                          : t("novel.outlineGenerator.ingest")}
                     </button>
                   ) : null}
                   {canIngestOutline && outlineIngested && outlineSnapshotNumber !== null ? (
@@ -1479,9 +1509,13 @@ export function PreviewPanel() {
                   ? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30"
                   : "border-border text-foreground hover:bg-accent"
               }`}
-              title={outlineIngested ? "重新提取初始记忆(将覆盖上次提取的内容)" : t("novel.outlineGenerator.ingest")}
+              title={outlineIngested ? t("novel.outlineGenerator.reingestTitle") : t("novel.outlineGenerator.ingest")}
             >
-              {isOutlineIngesting ? t("novel.outlineGenerator.ingesting") : outlineIngested ? "✓ 已提取记忆" : t("novel.outlineGenerator.ingest")}
+              {isOutlineIngesting
+                ? t("novel.outlineGenerator.ingesting")
+                : outlineIngested
+                  ? t("novel.outlineGenerator.reingestButton")
+                  : t("novel.outlineGenerator.ingest")}
             </button>
           ) : null}
           {!chapterToolbarCompact && canIngestOutline && outlineIngested && outlineSnapshotNumber !== null ? (

+ 2 - 0
src/i18n/en.json

@@ -1352,6 +1352,8 @@
       "error": "Generation failed",
       "ingest": "Extract Initial Memory",
       "ingesting": "Extracting initial memory...",
+      "reingestButton": "Re-extract Memory",
+      "reingestTitle": "Re-extract initial memory (overwrites the previous extraction)",
       "bulkIngest": "Extract All",
       "bulkIngesting": "Extracting all...",
       "bulkIngestEmpty": "There are no outline documents to extract in the current outline library.",

+ 2 - 0
src/i18n/zh.json

@@ -1294,6 +1294,8 @@
       "error": "生成失败",
       "ingest": "提取初始记忆",
       "ingesting": "正在提取初始记忆...",
+      "reingestButton": "重新提取记忆",
+      "reingestTitle": "重新提取初始记忆(将覆盖上次提取的内容)",
       "bulkIngest": "一键提取",
       "bulkIngesting": "一键提取中...",
       "bulkIngestEmpty": "当前大纲库里没有可提取的大纲文档。",

+ 27 - 3
src/lib/novel/outline-generation.ts

@@ -701,9 +701,7 @@ export interface RunOutlineIngestTaskOptions {
   manageProgress?: boolean
 }
 
-function getOutlineFileName(outlinePath: string): string {
-  return outlinePath.split("/").pop()?.replace(".md", "") || "大纲"
-}
+import { getOutlineFileName } from "./outline-ingest-utils"
 
 function buildOutlineIngestFailureReason(err: unknown): string {
   if (err instanceof Error) return err.message
@@ -729,6 +727,25 @@ function finalizeOutstandingIngestTasks(taskIds: string[], cancelled: boolean):
   }
 }
 
+/** Clear orphaned ingest tasks when no outline import progress is running. */
+export function reconcileStaleOutlineIngestTasks(projectPath: string): void {
+  const pp = normalizePath(projectPath)
+  const hasRunningOutlineImport = useImportProgressStore.getState().tasks.some(
+    (task) => task.projectPath === pp && task.kind === "outline" && task.status === "running",
+  )
+  if (hasRunningOutlineImport) return
+
+  const message = getOutlineIngestCancelledMessage()
+  for (const task of useOutlineGenerationStore.getState().tasks) {
+    if (task.projectPath !== pp || task.kind !== "ingest" || task.status !== "ingesting") continue
+    useOutlineGenerationStore.getState().updateTask(task.id, {
+      status: "error",
+      message,
+      error: message,
+    })
+  }
+}
+
 function buildBulkIngestProgressMessage(result: BulkOutlineIngestResult): string {
   if (result.cancelled) {
     return i18n.t("novel.outlineGenerator.bulkIngestCancelledProgress", {
@@ -1017,6 +1034,13 @@ export async function runOutlineIngestPaths(
   return result
 }
 
+export async function runSingleOutlineIngest(
+  projectPath: string,
+  outlinePath: string,
+): Promise<BulkOutlineIngestResult> {
+  return runOutlineIngestPaths(projectPath, [normalizePath(outlinePath)])
+}
+
 export function startOutlineIngestTask(projectPath: string, outlinePath: string): string {
   const taskId = createOutlineIngestTask(projectPath, outlinePath)
   void runOutlineIngestTask(taskId)

+ 38 - 0
src/lib/novel/outline-ingest-utils.spec.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest"
+import { getOutlineFileName, getOutlineIngestIdentity, outlineSnapshotExists } from "./outline-ingest-utils"
+
+vi.mock("@/commands/fs", () => ({
+  fileExists: vi.fn(),
+}))
+
+import { fileExists } from "@/commands/fs"
+
+describe("getOutlineIngestIdentity", () => {
+  it("derives a stable negative chapter number and snapshot path from outline filename", () => {
+    const identity = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/vol-1-outline.md")
+    expect(identity.outlineName).toBe("vol-1-outline")
+    expect(identity.chapterNumber).toBeLessThan(0)
+    expect(identity.snapshotJsonPath).toMatch(/\.novel\/snapshots\/outline-\d{3}\.snapshot\.json$/)
+  })
+
+  it("returns the same identity for the same outline path", () => {
+    const left = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/plot.md")
+    const right = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/plot.md")
+    expect(left).toEqual(right)
+  })
+})
+
+describe("getOutlineFileName", () => {
+  it("strips extension and falls back when path is empty", () => {
+    expect(getOutlineFileName("/a/b/story-outline.md")).toBe("story-outline")
+    expect(getOutlineFileName("")).toBe("大纲")
+  })
+})
+
+describe("outlineSnapshotExists", () => {
+  it("checks the derived snapshot json path", async () => {
+    vi.mocked(fileExists).mockResolvedValueOnce(true)
+    await expect(outlineSnapshotExists("/proj", "/proj/wiki/outlines/plot.md")).resolves.toBe(true)
+    expect(fileExists).toHaveBeenCalledWith(expect.stringContaining("/proj/.novel/snapshots/outline-"))
+  })
+})

+ 37 - 0
src/lib/novel/outline-ingest-utils.ts

@@ -0,0 +1,37 @@
+import { fileExists } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+
+export interface OutlineIngestIdentity {
+  outlineName: string
+  chapterNumber: number
+  snapshotJsonPath: string
+}
+
+export function getOutlineIngestIdentity(projectPath: string, outlinePath: string): OutlineIngestIdentity {
+  const normalizedOutlinePath = normalizePath(outlinePath)
+  const fileName = normalizedOutlinePath.split("/").pop() ?? "outline"
+  const outlineName = fileName.replace(/\.\w+$/, "")
+
+  let hash = 0
+  for (let i = 0; i < outlineName.length; i++) {
+    hash = ((hash << 5) - hash + outlineName.charCodeAt(i)) | 0
+  }
+  const chapterNumber = -(Math.abs(hash % 999) + 1)
+  const prefix = `outline-${String(Math.abs(chapterNumber)).padStart(3, "0")}`
+  const snapshotJsonPath = `${normalizePath(projectPath)}/.novel/snapshots/${prefix}.snapshot.json`
+
+  return { outlineName, chapterNumber, snapshotJsonPath }
+}
+
+export function getOutlineFileName(outlinePath: string): string {
+  return outlinePath.split("/").pop()?.replace(/\.\w+$/, "") || "大纲"
+}
+
+export async function outlineSnapshotExists(projectPath: string, outlinePath: string): Promise<boolean> {
+  const { snapshotJsonPath } = getOutlineIngestIdentity(projectPath, outlinePath)
+  try {
+    return await fileExists(snapshotJsonPath)
+  } catch {
+    return false
+  }
+}

+ 12 - 1
src/stores/import-progress-store.ts

@@ -88,7 +88,13 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
     }))
   },
   finishTask: (taskId, status, patch = {}) => {
+    const task = get().tasks.find((item) => item.id === taskId)
     get().updateTask(taskId, { ...patch, status, cancelling: false })
+    if (task?.kind === "outline" && status !== "running") {
+      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
+        reconcileStaleOutlineIngestTasks(task.projectPath)
+      })
+    }
   },
   markCancelling: (taskId) => {
     get().updateTask(taskId, { cancelling: true })
@@ -97,7 +103,12 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
     const task = get().tasks.find((t) => t.id === taskId)
     if (!task) return
     task.abortController?.abort()
-    get().updateTask(taskId, { status: "cancelled", cancelling: false })
+    get().updateTask(taskId, { status: "cancelled", cancelling: false, activeTitles: [] })
+    if (task.kind === "outline") {
+      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
+        reconcileStaleOutlineIngestTasks(task.projectPath)
+      })
+    }
     setTimeout(() => {
       get().clearTask(taskId)
     }, 3000)