Explorar el Código

feat(outline): 一键提取前支持选择全部或仅未提取

避免误覆盖已提取大纲记忆,并在无可处理项时给出明确提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hace 1 mes
padre
commit
f75fd27908

+ 7 - 0
src/components/sources/outline-action-toolbar.spec.ts

@@ -10,4 +10,11 @@ describe("OutlineActionToolbar", () => {
     expect(source).toContain("setOutlineChatOpen(!outlineChatOpen)")
     expect(source).toContain('aria-pressed={outlineChatOpen}')
   })
+
+  it("asks whether to extract all or only pending outlines before bulk ingest", () => {
+    expect(source).toContain("bulkIngestDialogOpen")
+    expect(source).toContain('handleBulkIngest("pending")')
+    expect(source).toContain('handleBulkIngest("all")')
+    expect(source).toContain('runBulkOutlineIngest(project.path, { mode })')
+  })
 })

+ 53 - 4
src/components/sources/outline-action-toolbar.tsx

@@ -2,7 +2,20 @@ import { useCallback, useState } from "react"
 import { Loader2, MessageSquare } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { Button } from "@/components/ui/button"
-import { runBulkOutlineIngest, formatBulkOutlineIngestResult, OutlineIngestNotReadyError } from "@/lib/novel/outline-generation"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import {
+  runBulkOutlineIngest,
+  formatBulkOutlineIngestResult,
+  OutlineIngestNotReadyError,
+  type BulkOutlineIngestMode,
+} from "@/lib/novel/outline-generation"
 import { cn } from "@/lib/utils"
 import { toast } from "@/lib/toast"
 import { useImportProgressStore } from "@/stores/import-progress-store"
@@ -26,6 +39,7 @@ export function OutlineActionToolbar({
   const outlineChatOpen = useOutlineGenerationStore((s) => s.panelOpen)
   const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
   const [bulkIngestRunning, setBulkIngestRunning] = useState(false)
+  const [bulkIngestDialogOpen, setBulkIngestDialogOpen] = useState(false)
 
   const bulkOutlineProgressRunning = useImportProgressStore((s) => (
     project != null && s.tasks.some((task) => (
@@ -46,12 +60,13 @@ export function OutlineActionToolbar({
     setActiveView("sources")
   }, [onToggleOutlineChat, outlineChatOpen, setActiveView, setOutlineChatOpen])
 
-  const handleBulkIngest = useCallback(async () => {
+  const handleBulkIngest = useCallback(async (mode: BulkOutlineIngestMode) => {
     if (!project || bulkIngestActive) return
+    setBulkIngestDialogOpen(false)
     setBulkIngestRunning(true)
     onBulkIngestResult?.(null)
     try {
-      const result = await runBulkOutlineIngest(project.path)
+      const result = await runBulkOutlineIngest(project.path, { mode })
       onBulkIngestResult?.(formatBulkOutlineIngestResult(result))
     } catch (err) {
       if (err instanceof OutlineIngestNotReadyError) {
@@ -72,7 +87,12 @@ export function OutlineActionToolbar({
         <MessageSquare className="mr-1 h-4 w-4" />
         AI大纲
       </Button>
-      <Button size="sm" variant="outline" onClick={() => void handleBulkIngest()} disabled={bulkIngestActive}>
+      <Button
+        size="sm"
+        variant="outline"
+        onClick={() => setBulkIngestDialogOpen(true)}
+        disabled={bulkIngestActive}
+      >
         {bulkIngestActive ? (
           <>
             <Loader2 className="mr-1 h-4 w-4 animate-spin" />
@@ -82,6 +102,35 @@ export function OutlineActionToolbar({
           t("novel.outlineGenerator.bulkIngest")
         )}
       </Button>
+      <Dialog
+        open={bulkIngestDialogOpen}
+        onOpenChange={(open) => {
+          if (!open) setBulkIngestDialogOpen(false)
+        }}
+      >
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>{t("novel.outlineGenerator.bulkIngestDialogTitle")}</DialogTitle>
+            <DialogDescription>
+              {t("novel.outlineGenerator.bulkIngestDialogDescription")}
+            </DialogDescription>
+          </DialogHeader>
+          <div className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
+            {t("novel.outlineGenerator.bulkIngestDialogHint")}
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setBulkIngestDialogOpen(false)}>
+              {t("common.cancel")}
+            </Button>
+            <Button type="button" variant="secondary" onClick={() => void handleBulkIngest("pending")}>
+              {t("novel.outlineGenerator.bulkIngestPending")}
+            </Button>
+            <Button type="button" onClick={() => void handleBulkIngest("all")}>
+              {t("novel.outlineGenerator.bulkIngestAll")}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 6 - 0
src/i18n/en.json

@@ -1487,7 +1487,13 @@
       "reingestTitle": "Re-extract initial memory (overwrites the previous extraction)",
       "bulkIngest": "Extract All",
       "bulkIngesting": "Extracting all...",
+      "bulkIngestDialogTitle": "Choose extraction scope",
+      "bulkIngestDialogDescription": "Choose which outlines this bulk extraction should process.",
+      "bulkIngestDialogHint": "“Extract all” overwrites already extracted outline memory. “Unextracted only” skips outlines that already have a snapshot.",
+      "bulkIngestAll": "Extract all",
+      "bulkIngestPending": "Unextracted only",
       "bulkIngestEmpty": "There are no outline documents to extract in the current outline library.",
+      "bulkIngestAlreadyExtracted": "All outlines have already been extracted. Nothing left to process.",
       "bulkIngestResult": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total.",
       "bulkIngestResultWithFailures": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total. Failures:",
       "bulkIngestMoreFailures": "{{count}} more outline(s) failed to extract.",

+ 6 - 0
src/i18n/zh.json

@@ -1429,7 +1429,13 @@
       "reingestTitle": "重新提取初始记忆(将覆盖上次提取的内容)",
       "bulkIngest": "一键提取",
       "bulkIngesting": "一键提取中...",
+      "bulkIngestDialogTitle": "选择提取范围",
+      "bulkIngestDialogDescription": "请选择本次一键提取要处理的大纲范围。",
+      "bulkIngestDialogHint": "“提取全部”会覆盖已提取过的大纲记忆;“仅未提取”会跳过已有快照的大纲。",
+      "bulkIngestAll": "提取全部",
+      "bulkIngestPending": "仅未提取",
       "bulkIngestEmpty": "当前大纲库里没有可提取的大纲文档。",
+      "bulkIngestAlreadyExtracted": "所有大纲都已提取过,没有需要处理的未提取大纲。",
       "bulkIngestResult": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。",
       "bulkIngestResultWithFailures": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。失败明细:",
       "bulkIngestMoreFailures": "另有 {{count}} 个大纲提取失败。",

+ 75 - 0
src/lib/novel/outline-generation.spec.ts

@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
   finalizeProjectMemoryRebuildMock: vi.fn(),
   refreshProjectStateMock: vi.fn(),
   hasUsableLlmMock: vi.fn(() => true),
+  listDirectoryMock: vi.fn(),
+  outlineSnapshotExistsMock: vi.fn(),
   outlineStore: {
     tasks: [] as Array<{ id: string; projectPath: string; outlinePath: string | null; status: string; message: string; error: string | null; updatedAt: number }>,
     createTask: vi.fn((input: { projectPath: string; outlinePath?: string | null }) => {
@@ -61,6 +63,22 @@ vi.mock("@/lib/has-usable-llm", () => ({
   hasUsableLlm: mocks.hasUsableLlmMock,
 }))
 
+vi.mock("@/commands/fs", async () => {
+  const actual = await vi.importActual<typeof import("@/commands/fs")>("@/commands/fs")
+  return {
+    ...actual,
+    listDirectory: mocks.listDirectoryMock,
+  }
+})
+
+vi.mock("./outline-ingest-utils", async () => {
+  const actual = await vi.importActual<typeof import("./outline-ingest-utils")>("./outline-ingest-utils")
+  return {
+    ...actual,
+    outlineSnapshotExists: mocks.outlineSnapshotExistsMock,
+  }
+})
+
 vi.mock("@/stores/wiki-store", () => ({
   useWikiStore: {
     getState: () => ({
@@ -89,6 +107,7 @@ import {
   buildOutlineRefinementContext,
   formatBulkOutlineIngestResult,
   OutlineIngestNotReadyError,
+  runBulkOutlineIngest,
   runOutlineIngestPaths,
 } from "./outline-generation"
 
@@ -145,6 +164,8 @@ describe("bulk outline ingest", () => {
     mocks.refreshProjectStateMock.mockReset()
     mocks.hasUsableLlmMock.mockReset()
     mocks.hasUsableLlmMock.mockReturnValue(true)
+    mocks.listDirectoryMock.mockReset()
+    mocks.outlineSnapshotExistsMock.mockReset()
     mocks.outlineStore.tasks = []
     mocks.importProgressStore.tasks = []
     mocks.importProgressStore.startTask.mockClear()
@@ -234,4 +255,58 @@ describe("bulk outline ingest", () => {
     expect(message).toContain("b")
     expect(message).toContain("JSON 解析失败")
   })
+
+  it("formats already-extracted empty result", () => {
+    const message = formatBulkOutlineIngestResult({
+      total: 0,
+      succeeded: 0,
+      failed: 0,
+      failures: [],
+      emptyReason: "already_extracted",
+    })
+
+    expect(message).toContain("已提取")
+  })
+
+  it("pending mode skips outlines that already have snapshots", async () => {
+    mocks.listDirectoryMock.mockResolvedValueOnce([
+      { path: "E:/Novel/wiki/outlines/a.md", name: "a.md", is_dir: false },
+      { path: "E:/Novel/wiki/outlines/b.md", name: "b.md", is_dir: false },
+    ])
+    mocks.outlineSnapshotExistsMock
+      .mockResolvedValueOnce(true)
+      .mockResolvedValueOnce(false)
+    mocks.ingestOutlineMock.mockResolvedValueOnce({
+      snapshot: { chapterId: "outline-b", chapterNumber: -2 },
+      truncated: false,
+      originalLength: 100,
+      bodyLength: 100,
+      bodyBudget: 1000,
+      failureReason: null,
+    })
+
+    const result = await runBulkOutlineIngest("E:/Novel", { mode: "pending" })
+
+    expect(result).toMatchObject({ total: 1, succeeded: 1, failed: 0 })
+    expect(mocks.ingestOutlineMock).toHaveBeenCalledTimes(1)
+    expect(mocks.ingestOutlineMock.mock.calls[0]?.[1]).toContain("b.md")
+  })
+
+  it("pending mode returns already_extracted when nothing is left", async () => {
+    mocks.listDirectoryMock.mockResolvedValueOnce([
+      { path: "E:/Novel/wiki/outlines/a.md", name: "a.md", is_dir: false },
+    ])
+    mocks.outlineSnapshotExistsMock.mockResolvedValueOnce(true)
+
+    const result = await runBulkOutlineIngest("E:/Novel", { mode: "pending" })
+
+    expect(result).toEqual({
+      total: 0,
+      succeeded: 0,
+      failed: 0,
+      failures: [],
+      emptyReason: "already_extracted",
+    })
+    expect(mocks.ingestOutlineMock).not.toHaveBeenCalled()
+  })
 })

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

@@ -663,12 +663,20 @@ export interface OutlineIngestFailure {
   reason: string
 }
 
+export type BulkOutlineIngestMode = "all" | "pending"
+
 export interface BulkOutlineIngestResult {
   total: number
   succeeded: number
   failed: number
   cancelled?: boolean
   failures: OutlineIngestFailure[]
+  /** Present when total is 0 and the caller asked for a scoped bulk run. */
+  emptyReason?: "no_outlines" | "already_extracted"
+}
+
+export interface RunBulkOutlineIngestOptions {
+  mode?: BulkOutlineIngestMode
 }
 
 export interface RunOutlineIngestPathsOptions {
@@ -692,7 +700,7 @@ export interface RunOutlineIngestTaskOptions {
   manageProgress?: boolean
 }
 
-import { getOutlineFileName } from "./outline-ingest-utils"
+import { getOutlineFileName, outlineSnapshotExists } from "./outline-ingest-utils"
 
 function buildOutlineIngestFailureReason(err: unknown): string {
   if (err instanceof Error) return err.message
@@ -763,6 +771,9 @@ function buildBulkIngestProgressMessage(result: BulkOutlineIngestResult): string
 
 export function formatBulkOutlineIngestResult(result: BulkOutlineIngestResult): string {
   if (result.total === 0) {
+    if (result.emptyReason === "already_extracted") {
+      return i18n.t("novel.outlineGenerator.bulkIngestAlreadyExtracted")
+    }
     return i18n.t("novel.outlineGenerator.bulkIngestEmpty")
   }
   if (result.cancelled) {
@@ -1054,8 +1065,12 @@ function collectOutlineMarkdownPaths(
   return paths
 }
 
-export async function runBulkOutlineIngest(projectPath: string): Promise<BulkOutlineIngestResult> {
+export async function runBulkOutlineIngest(
+  projectPath: string,
+  options?: RunBulkOutlineIngestOptions,
+): Promise<BulkOutlineIngestResult> {
   const pp = normalizePath(projectPath)
+  const mode: BulkOutlineIngestMode = options?.mode ?? "all"
   let outlinePaths: string[] = []
 
   try {
@@ -1063,7 +1078,24 @@ export async function runBulkOutlineIngest(projectPath: string): Promise<BulkOut
     outlinePaths = collectOutlineMarkdownPaths(tree as Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>)
       .sort((a, b) => a.localeCompare(b, "zh-CN"))
   } catch {
-    return { total: 0, succeeded: 0, failed: 0, failures: [] }
+    return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "no_outlines" }
+  }
+
+  if (outlinePaths.length === 0) {
+    return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "no_outlines" }
+  }
+
+  if (mode === "pending") {
+    const pending: string[] = []
+    for (const outlinePath of outlinePaths) {
+      if (!(await outlineSnapshotExists(pp, outlinePath))) {
+        pending.push(outlinePath)
+      }
+    }
+    if (pending.length === 0) {
+      return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "already_extracted" }
+    }
+    outlinePaths = pending
   }
 
   return runOutlineIngestPaths(pp, outlinePaths)