ソースを参照

merge: 合并拆书库分析流水线重做

Mochocyang 1 ヶ月 前
コミット
91bcaeb68f
53 ファイル変更4496 行追加219 行削除
  1. 7 0
      src/components/common/context-hub-details.spec.tsx
  2. 1 0
      src/components/common/context-hub-details.tsx
  3. 2 3
      src/components/layout/book-analysis-sidebar-panel.spec.tsx
  4. 43 2
      src/components/layout/book-analysis-sidebar-panel.tsx
  5. 42 4
      src/components/novel/book-analysis-input-dialog.tsx
  6. 5 21
      src/components/novel/book-analysis-library-layout.spec.tsx
  7. 80 160
      src/components/novel/book-analysis-library-layout.tsx
  8. 127 0
      src/components/novel/book-analysis-module-view.spec.tsx
  9. 154 0
      src/components/novel/book-analysis-module-view.tsx
  10. 144 0
      src/components/novel/book-analysis-run-dialog.tsx
  11. 112 4
      src/components/novel/book-analysis-view.tsx
  12. 2 0
      src/lib/context-hub/source-paths.spec.ts
  13. 2 0
      src/lib/context-hub/source-paths.ts
  14. 1 0
      src/lib/context-hub/types.ts
  15. 23 0
      src/lib/novel/book-analysis/analysis-active-registry.ts
  16. 52 0
      src/lib/novel/book-analysis/analysis-chunk-planner.spec.ts
  17. 91 0
      src/lib/novel/book-analysis/analysis-chunk-planner.ts
  18. 77 0
      src/lib/novel/book-analysis/analysis-context-index.spec.ts
  19. 202 0
      src/lib/novel/book-analysis/analysis-context-index.ts
  20. 88 0
      src/lib/novel/book-analysis/analysis-evidence-store.spec.ts
  21. 213 0
      src/lib/novel/book-analysis/analysis-evidence-store.ts
  22. 164 0
      src/lib/novel/book-analysis/analysis-pipeline-storage.spec.ts
  23. 291 0
      src/lib/novel/book-analysis/analysis-pipeline-storage.ts
  24. 40 0
      src/lib/novel/book-analysis/analysis-pipeline-types.spec.ts
  25. 132 0
      src/lib/novel/book-analysis/analysis-pipeline-types.ts
  26. 245 0
      src/lib/novel/book-analysis/analysis-scheduler.spec.ts
  27. 461 0
      src/lib/novel/book-analysis/analysis-scheduler.ts
  28. 40 0
      src/lib/novel/book-analysis/analysis-skill-adapter.ts
  29. 7 0
      src/lib/novel/book-analysis/batch-import-storage.ts
  30. 4 1
      src/lib/novel/book-analysis/batch-import-types.ts
  31. 8 1
      src/lib/novel/book-analysis/book-deletion.ts
  32. 147 0
      src/lib/novel/book-analysis/character-analysis-adapter.spec.ts
  33. 269 0
      src/lib/novel/book-analysis/character-analysis-adapter.ts
  34. 40 0
      src/lib/novel/book-analysis/character-extraction-engine.spec.ts
  35. 60 10
      src/lib/novel/book-analysis/character-extraction-engine.ts
  36. 7 0
      src/lib/novel/book-analysis/library-state.ts
  37. 32 0
      src/lib/novel/book-analysis/skill-generator.ts
  38. 120 0
      src/lib/novel/book-analysis/story-analysis-adapter.spec.ts
  39. 205 0
      src/lib/novel/book-analysis/story-analysis-adapter.ts
  40. 11 0
      src/lib/novel/book-analysis/story-framework-extraction.ts
  41. 102 0
      src/lib/novel/book-analysis/style-analysis-adapter.spec.ts
  42. 216 0
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  43. 11 3
      src/lib/novel/book-analysis/style-extraction-engine.ts
  44. 46 0
      src/lib/novel/book-analysis/style-prompts.ts
  45. 15 0
      src/lib/novel/book-analysis/types.ts
  46. 1 0
      src/lib/novel/context-data-source.ts
  47. 1 0
      src/lib/novel/context-data-sources.spec.ts
  48. 11 0
      src/lib/novel/context-data-sources.ts
  49. 7 3
      src/lib/novel/context-engine.ts
  50. 27 0
      src/lib/novel/writing-style-store.spec.ts
  51. 31 4
      src/lib/novel/writing-style-store.ts
  52. 16 3
      src/stores/book-analysis-import-store.ts
  53. 261 0
      src/stores/book-analysis-pipeline-store.ts

+ 7 - 0
src/components/common/context-hub-details.spec.tsx

@@ -41,6 +41,12 @@ const snapshot: ContextHubSnapshot = {
       status: "refreshed",
       status: "refreshed",
       dependencyPaths: ["wiki/settings/world.md"],
       dependencyPaths: ["wiki/settings/world.md"],
     },
     },
+    {
+      key: "data-source:book-analysis",
+      sourceName: "bookAnalysisReferences",
+      status: "hit",
+      dependencyPaths: [".qmai/book-analysis-context.json"],
+    },
   ],
   ],
   stableCore: "稳定核心正文",
   stableCore: "稳定核心正文",
   sessionSummary: "会话摘要正文",
   sessionSummary: "会话摘要正文",
@@ -94,6 +100,7 @@ describe("ContextHubDetails", () => {
 
 
     expect(loadSnapshot).toHaveBeenCalledWith(reference)
     expect(loadSnapshot).toHaveBeenCalledWith(reference)
     expect(host.textContent).toContain("大纲资料")
     expect(host.textContent).toContain("大纲资料")
+    expect(host.textContent).toContain("拆书库分析")
     expect(host.textContent).toContain("稳定核心缓存")
     expect(host.textContent).toContain("稳定核心缓存")
     expect(host.textContent).toContain("wiki/outlines/main.md")
     expect(host.textContent).toContain("wiki/outlines/main.md")
     expect(host.textContent).toContain("稳定核心正文")
     expect(host.textContent).toContain("稳定核心正文")

+ 1 - 0
src/components/common/context-hub-details.tsx

@@ -33,6 +33,7 @@ const SOURCE_LABELS: Record<string, string> = {
   relatedSettings: "相关设定",
   relatedSettings: "相关设定",
   canonRules: "硬性世界规则",
   canonRules: "硬性世界规则",
   writingStyle: "写作风格",
   writingStyle: "写作风格",
+  bookAnalysisReferences: "拆书库分析",
   searchResults: "任务检索结果",
   searchResults: "任务检索结果",
   graphSearchResults: "关系图检索结果",
   graphSearchResults: "关系图检索结果",
   revisionFeedback: "修订反馈",
   revisionFeedback: "修订反馈",

+ 2 - 3
src/components/layout/book-analysis-sidebar-panel.spec.tsx

@@ -160,9 +160,8 @@ describe("BookAnalysisSidebarPanel", () => {
     const { cleanup } = renderPanel()
     const { cleanup } = renderPanel()
     await flushAsync(50)
     await flushAsync(50)
     // 点击作品行(作品内容区域的 button,不是刷新按钮)
     // 点击作品行(作品内容区域的 button,不是刷新按钮)
-    const allButtons = document.querySelectorAll("button")
-    // 第一个 button 是刷新按钮,第二个是作品内容区域
-    const bookBtn = allButtons[1] as HTMLButtonElement
+    const bookBtn = Array.from(document.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("测试书")) as HTMLButtonElement
     expect(bookBtn).toBeTruthy()
     expect(bookBtn).toBeTruthy()
     await act(async () => {
     await act(async () => {
       bookBtn.click()
       bookBtn.click()

+ 43 - 2
src/components/layout/book-analysis-sidebar-panel.tsx

@@ -8,7 +8,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
-import { BookOpen, Trash2, RefreshCw, Loader2, Square, CheckCircle2 } from "lucide-react"
+import { BookOpen, Trash2, RefreshCw, Loader2, Square, CheckCircle2, Plus } from "lucide-react"
 import { listDirectory, readFile } from "@/commands/fs"
 import { listDirectory, readFile } from "@/commands/fs"
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
@@ -16,6 +16,8 @@ import { deleteOrphanAurasForBook } from "@/lib/novel/book-analysis/aura-cleanup
 import { listCharacterAuras } from "@/lib/novel/character-aura"
 import { listCharacterAuras } from "@/lib/novel/character-aura"
 import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
 import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
 import type { BookAnalysisMetadata } from "@/lib/novel/book-analysis/types"
 import type { BookAnalysisMetadata } from "@/lib/novel/book-analysis/types"
+import { BookAnalysisInputDialog } from "@/components/novel/book-analysis-input-dialog"
+import { BookAnalysisImportTaskPanel } from "@/components/novel/book-analysis-import-task-panel"
 
 
 interface BookItem {
 interface BookItem {
   id: string
   id: string
@@ -38,10 +40,25 @@ export function BookAnalysisSidebarPanel() {
   const cancelTask = useBookAnalysisStore((s) => s.cancelTask)
   const cancelTask = useBookAnalysisStore((s) => s.cancelTask)
   const requestReopenChapterSelection = useBookAnalysisStore((s) => s.requestReopenChapterSelection)
   const requestReopenChapterSelection = useBookAnalysisStore((s) => s.requestReopenChapterSelection)
   const deletePublishedBook = useBookAnalysisImportStore((s) => s.deletePublishedBook)
   const deletePublishedBook = useBookAnalysisImportStore((s) => s.deletePublishedBook)
+  const importBatches = useBookAnalysisImportStore((s) => s.batches) ?? []
+  const importTasks = useBookAnalysisImportStore((s) => s.tasks) ?? []
+  const createBatch = useBookAnalysisImportStore((s) => s.createBatch)
+  const continueImport = useBookAnalysisImportStore((s) => s.continueTask)
+  const regenerateImport = useBookAnalysisImportStore((s) => s.regenerateTask)
+  const cancelImport = useBookAnalysisImportStore((s) => s.cancelTask)
+  const cancelAllQueued = useBookAnalysisImportStore((s) => s.cancelAllQueued)
+  const deleteFailed = useBookAnalysisImportStore((s) => s.deleteFailedTask)
+  const renameCompleted = useBookAnalysisImportStore((s) => s.renameCompletedTask)
+  const collapsed = useBookAnalysisImportStore((s) => s.panelCollapsed)
+  const setCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const [books, setBooks] = useState<BookItem[]>([])
   const [books, setBooks] = useState<BookItem[]>([])
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [bookAuraCount, setBookAuraCount] = useState<Record<string, number>>({})
   const [bookAuraCount, setBookAuraCount] = useState<Record<string, number>>({})
+  const [inputOpen, setInputOpen] = useState(false)
+  const multiBatchIds = new Set(importBatches.filter((batch) => batch.taskIds.length > 1 && !batch.panelDismissedAt).map((batch) => batch.id))
+  const visibleBatches = importBatches.filter((batch) => multiBatchIds.has(batch.id))
+  const visibleImportTasks = importTasks.filter((task) => multiBatchIds.has(task.batchId))
 
 
   // 正在运行的任务:识别已完成的任务退出"运行中",不再显示 Loader2 转圈
   // 正在运行的任务:识别已完成的任务退出"运行中",不再显示 Loader2 转圈
   const runningTasks = tasks.filter((t) => t.status === "running" && t.progress.recognitionStatus !== "done")
   const runningTasks = tasks.filter((t) => t.status === "running" && t.progress.recognitionStatus !== "done")
@@ -232,15 +249,35 @@ export function BookAnalysisSidebarPanel() {
         >
         >
           <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
           <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
         </Button>
         </Button>
+        <Button type="button" size="icon" variant="ghost" className="h-8 w-8" onClick={() => setInputOpen(true)} title="导入小说">
+          <Plus className="h-4 w-4" />
+        </Button>
       </div>
       </div>
 
 
+      <BookAnalysisImportTaskPanel
+        batches={visibleBatches}
+        tasks={visibleImportTasks}
+        collapsed={collapsed}
+        onCollapsedChange={setCollapsed}
+        onContinue={(id) => void continueImport(id)}
+        onRegenerate={(id) => void regenerateImport(id)}
+        onCancel={(id) => void cancelImport(id)}
+        onCancelAllQueued={(id) => void cancelAllQueued(id)}
+        onDeleteFailed={(id) => void deleteFailed(id)}
+        onRenameCompleted={(id, title) => void renameCompleted(id, title)}
+        onOpenBook={(id) => {
+          setSelectedLibraryBookId(id)
+          setActiveView("bookAnalysis")
+        }}
+      />
+
       {/* 作品列表 */}
       {/* 作品列表 */}
       <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
       <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
         {loading ? (
         {loading ? (
           <div className="px-2 py-4 text-xs text-muted-foreground">正在加载...</div>
           <div className="px-2 py-4 text-xs text-muted-foreground">正在加载...</div>
         ) : books.length === 0 ? (
         ) : books.length === 0 ? (
           <div className="rounded-lg border border-dashed p-4 text-xs leading-5 text-muted-foreground">
           <div className="rounded-lg border border-dashed p-4 text-xs leading-5 text-muted-foreground">
-            还没有作品。点击主面板的"导入小说"按钮开始分析。
+            还没有作品。点击上方“导入小说”按钮添加作品
           </div>
           </div>
         ) : (
         ) : (
           books.map((book) => (
           books.map((book) => (
@@ -347,6 +384,10 @@ export function BookAnalysisSidebarPanel() {
           })}
           })}
         </div>
         </div>
       )}
       )}
+      <BookAnalysisInputDialog open={inputOpen} onOpenChange={setInputOpen} onSubmit={async (files, skills) => {
+        await createBatch(files, skills)
+        setInputOpen(false)
+      }} />
     </div>
     </div>
   )
   )
 }
 }

+ 42 - 4
src/components/novel/book-analysis-input-dialog.tsx

@@ -4,12 +4,13 @@ import { getFileSize } from "@/commands/fs"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
 import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
+import type { AnalysisSkill } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 
 
 interface BookAnalysisInputDialogProps {
 interface BookAnalysisInputDialogProps {
   open: boolean
   open: boolean
   onOpenChange: (open: boolean) => void
   onOpenChange: (open: boolean) => void
-  onSubmit: (files: BatchImportCandidate[]) => Promise<void> | void
+  onSubmit: (files: BatchImportCandidate[], analysisSkills?: AnalysisSkill[]) => Promise<void> | void
 }
 }
 
 
 function getFileName(path: string) {
 function getFileName(path: string) {
@@ -38,6 +39,7 @@ export function BookAnalysisInputDialog({
   const [error, setError] = useState("")
   const [error, setError] = useState("")
   const [isSubmitting, setIsSubmitting] = useState(false)
   const [isSubmitting, setIsSubmitting] = useState(false)
   const [isSelecting, setIsSelecting] = useState(false)
   const [isSelecting, setIsSelecting] = useState(false)
+  const [analysisSkills, setAnalysisSkills] = useState<AnalysisSkill[]>([])
   const filesRef = useRef<BatchImportCandidate[]>([])
   const filesRef = useRef<BatchImportCandidate[]>([])
   const isSubmittingRef = useRef(false)
   const isSubmittingRef = useRef(false)
   const isSelectingRef = useRef(false)
   const isSelectingRef = useRef(false)
@@ -66,6 +68,7 @@ export function BookAnalysisInputDialog({
     setError("")
     setError("")
     setIsSubmitting(false)
     setIsSubmitting(false)
     setIsSelecting(false)
     setIsSelecting(false)
+    setAnalysisSkills([])
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
@@ -170,7 +173,8 @@ export function BookAnalysisInputDialog({
     const submittedPathKeys = new Set(submittedFiles.map((file) => getPathKey(file.sourcePath)))
     const submittedPathKeys = new Set(submittedFiles.map((file) => getPathKey(file.sourcePath)))
 
 
     try {
     try {
-      await onSubmit(submittedFiles)
+      if (analysisSkills.length > 0) await onSubmit(submittedFiles, analysisSkills)
+      else await onSubmit(submittedFiles)
       if (sessionTokenRef.current !== sessionToken) return
       if (sessionTokenRef.current !== sessionToken) return
       replaceFiles((currentFiles) => (
       replaceFiles((currentFiles) => (
         currentFiles.filter((file) => !submittedPathKeys.has(getPathKey(file.sourcePath)))
         currentFiles.filter((file) => !submittedPathKeys.has(getPathKey(file.sourcePath)))
@@ -196,6 +200,12 @@ export function BookAnalysisInputDialog({
   }
   }
 
 
   const isBusy = isSelecting || isSubmitting
   const isBusy = isSelecting || isSubmitting
+  const skillOptions: Array<{ skill: AnalysisSkill; label: string }> = [
+    { skill: "characters", label: "角色 Skill" },
+    { skill: "story", label: "故事 Skill" },
+    { skill: "style", label: "文风 Skill" },
+  ]
+  const allSelected = analysisSkills.length === skillOptions.length
 
 
   return (
   return (
     <Dialog open={open} onOpenChange={handleOpenChange}>
     <Dialog open={open} onOpenChange={handleOpenChange}>
@@ -204,7 +214,7 @@ export function BookAnalysisInputDialog({
         showCloseButton={!isBusy}
         showCloseButton={!isBusy}
       >
       >
         <DialogHeader>
         <DialogHeader>
-          <DialogTitle>批量导入小说</DialogTitle>
+          <DialogTitle>{files.length > 1 ? "批量导入小说" : "导入小说"}</DialogTitle>
         </DialogHeader>
         </DialogHeader>
 
 
         <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden py-4">
         <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden py-4">
@@ -254,6 +264,34 @@ export function BookAnalysisInputDialog({
             )}
             )}
           </div>
           </div>
 
 
+          <fieldset className="shrink-0 border-t pt-3" disabled={isBusy}>
+            <legend className="text-sm font-medium">导入后提取项目</legend>
+            <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2 text-sm">
+              {skillOptions.map(({ skill, label }) => (
+                <label key={skill} className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    checked={analysisSkills.includes(skill)}
+                    onChange={(event) => setAnalysisSkills((current) => (
+                      event.target.checked
+                        ? [...new Set([...current, skill])]
+                        : current.filter((item) => item !== skill)
+                    ))}
+                  />
+                  {label}
+                </label>
+              ))}
+              <label className="flex items-center gap-2 font-medium">
+                <input
+                  type="checkbox"
+                  checked={allSelected}
+                  onChange={(event) => setAnalysisSkills(event.target.checked ? skillOptions.map((item) => item.skill) : [])}
+                />
+                全部提取
+              </label>
+            </div>
+          </fieldset>
+
           {error && (
           {error && (
             <div
             <div
               role="alert"
               role="alert"
@@ -276,4 +314,4 @@ export function BookAnalysisInputDialog({
       </DialogContent>
       </DialogContent>
     </Dialog>
     </Dialog>
   )
   )
-}
+}

+ 5 - 21
src/components/novel/book-analysis-library-layout.spec.tsx

@@ -141,32 +141,16 @@ describe("BookAnalysisLibraryLayout", () => {
     )
     )
 
 
     expect(html).toContain("拆书库")
     expect(html).toContain("拆书库")
-    expect(html).toContain("批量导入任务")
-    const headerTextIndex = html.indexOf("管理作品文风、角色 Skill 和小说人物绑定。")
-    const importTaskPanelIndex = html.indexOf(">批量导入任务</div>")
-    const bookContentTitleIndex = html.indexOf('<h3 class="text-lg font-semibold">凡人修仙传</h3>')
-    expect(headerTextIndex).toBeGreaterThanOrEqual(0)
-    expect(importTaskPanelIndex).toBeGreaterThan(headerTextIndex)
-    expect(bookContentTitleIndex).toBeGreaterThan(importTaskPanelIndex)
-    expect(html).toContain('class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5"')
+    expect(html).not.toContain("批量导入任务")
+    expect(html).not.toContain("导入小说")
     expect(html).toContain("启用文风")
     expect(html).toContain("启用文风")
     expect(html).toContain("凡人修仙传")
     expect(html).toContain("凡人修仙传")
-    expect(html).toContain("作品文风")
     expect(html).toContain("角色 Skill")
     expect(html).toContain("角色 Skill")
+    expect(html).toContain("文风 Skill")
+    expect(html).toContain("证据片段")
     expect(html).toContain("当前 AI 会话约束")
     expect(html).toContain("当前 AI 会话约束")
     expect(html).toContain("主角")
     expect(html).toContain("主角")
     expect(html).toContain("韩立")
     expect(html).toContain("韩立")
-    expect(html).toContain("重新提取角色")
-    expect(html).toContain("重新提取文风")
-    expect(html).toContain("故事框架提取")
-    expect(html).toContain("故事框架")
-    expect(html).toContain("从选中章节提取钩子、铺垫、爽点和结尾钩子")
-    expect(html).toContain("查看完整框架")
-    expect(html).toContain("开局钩子")
-    expect(html).toContain("铺垫")
-    expect(html).toContain("爽点")
-    expect(html).toContain("结尾钩子")
-    expect(html).toContain("结尾抛出更高层的新误会,推动下一轮期待。")
-    expect(html).toContain("基于此框架创建章纲")
+    expect(html).toContain("重新提取")
   })
   })
 })
 })

+ 80 - 160
src/components/novel/book-analysis-library-layout.tsx

@@ -1,11 +1,10 @@
 import type { ReactNode } from "react"
 import type { ReactNode } from "react"
-import { Plus, RefreshCw, Users } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryState } from "@/lib/novel/book-analysis/library-state"
 import type { BookAnalysisLibraryState } from "@/lib/novel/book-analysis/library-state"
-import { BookAnalysisActiveContext } from "./book-analysis-active-context"
-import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
-import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
+import type { AnalysisChunkRecord, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { BookAnalysisActiveContext } from "./book-analysis-active-context"
+import { BookAnalysisModuleView } from "./book-analysis-module-view"
 
 
 interface BookAnalysisLibraryLayoutProps {
 interface BookAnalysisLibraryLayoutProps {
   state: BookAnalysisLibraryState
   state: BookAnalysisLibraryState
@@ -17,6 +16,8 @@ interface BookAnalysisLibraryLayoutProps {
   addingToSoul: boolean
   addingToSoul: boolean
   storyFrameworks?: PlotFramework[]
   storyFrameworks?: PlotFramework[]
   importTaskPanel?: ReactNode
   importTaskPanel?: ReactNode
+  analysisTask?: BookAnalysisPipelineTask | null
+  analysisChunks?: AnalysisChunkRecord[]
   onSelectBook: (bookId: string) => void
   onSelectBook: (bookId: string) => void
   onSelectCharacter: (characterId: string) => void
   onSelectCharacter: (characterId: string) => void
   onImportNovel: () => void
   onImportNovel: () => void
@@ -26,6 +27,12 @@ interface BookAnalysisLibraryLayoutProps {
   onToggleStyle: () => void
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
   onReextractCharacters: () => void
   onReextractCharacters: () => void
+  onReextractSkill?: (skill: AnalysisSkill) => void
+  onConfigureAnalysisTask?: () => void
+  onPauseAnalysisTask?: () => void
+  onContinueAnalysisTask?: () => void
+  onRetryAnalysisTask?: () => void
+  onCancelAnalysisTask?: () => void
   onDeleteBook: (bookId: string) => void
   onDeleteBook: (bookId: string) => void
 }
 }
 
 
@@ -34,176 +41,89 @@ export function BookAnalysisLibraryLayout({
   selectedBookId,
   selectedBookId,
   selectedCharacterId,
   selectedCharacterId,
   extractingStyle,
   extractingStyle,
-  extractingCharacters,
-  extractingStoryFramework,
   addingToSoul,
   addingToSoul,
   storyFrameworks = [],
   storyFrameworks = [],
-  importTaskPanel,
+  analysisTask,
+  analysisChunks = [],
   onSelectCharacter,
   onSelectCharacter,
-  onImportNovel,
-  onExtractStyle,
-  onExtractStoryFramework,
   onCreateOutlineFromFramework,
   onCreateOutlineFromFramework,
   onToggleStyle,
   onToggleStyle,
   onAddSelectedSkillsToSoul,
   onAddSelectedSkillsToSoul,
   onReextractCharacters,
   onReextractCharacters,
+  onExtractStoryFramework,
+  onExtractStyle,
+  onReextractSkill,
+  onConfigureAnalysisTask,
+  onPauseAnalysisTask,
+  onContinueAnalysisTask,
+  onRetryAnalysisTask,
+  onCancelAnalysisTask,
 }: BookAnalysisLibraryLayoutProps) {
 }: BookAnalysisLibraryLayoutProps) {
   const selectedBook = state.books.find((book) => book.id === selectedBookId) ?? state.books[0] ?? null
   const selectedBook = state.books.find((book) => book.id === selectedBookId) ?? state.books[0] ?? null
 
 
-  return (
-    <div className="flex h-full min-h-0 bg-muted/20">
-      <main className="flex min-w-0 flex-1 flex-col">
-        <header className="flex shrink-0 items-center justify-between border-b bg-background px-5 py-3">
-          <div>
-            <h2 className="text-xl font-semibold">拆书库</h2>
-            <p className="mt-1 text-xs text-muted-foreground">管理作品文风、角色 Skill 和小说人物绑定。</p>
-          </div>
-          <div className="flex items-center gap-2">
-            {selectedBook && (
-              <>
-                <Button variant="outline" size="sm" onClick={onReextractCharacters} disabled={extractingCharacters}>
-                  <Users className="mr-1.5 h-3.5 w-3.5" />
-                  {extractingCharacters ? "提取中..." : "重新提取角色"}
-                </Button>
-                <Button variant="outline" size="sm" onClick={onExtractStyle} disabled={extractingStyle}>
-                  <RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${extractingStyle ? "animate-spin" : ""}`} />
-                  {extractingStyle ? "提取中..." : "重新提取文风"}
-                </Button>
-                <Button
-                  variant="outline"
-                  size="sm"
-                  onClick={onExtractStoryFramework}
-                  disabled={extractingStoryFramework}
-                >
-                  <RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${extractingStoryFramework ? "animate-spin" : ""}`} />
-                  {extractingStoryFramework ? "提取中..." : "故事框架提取"}
-                </Button>
-              </>
-            )}
-            <Button onClick={onImportNovel}>
-              <Plus className="mr-2 h-4 w-4" />
-              导入小说
-            </Button>
-          </div>
-        </header>
-        {importTaskPanel}
-        {selectedBook ? (
-          <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
+  if (!selectedBook) {
+    return (
+      <div className="flex h-full items-center justify-center bg-muted/20 p-8 text-sm text-muted-foreground">
+        请在左侧作品列表导入或选择一本小说
+      </div>
+    )
+  }
+
+  const storyContent = (
+    <section className="space-y-3">
+      {storyFrameworks.length === 0 ? (
+        <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">尚未提取故事框架。</div>
+      ) : storyFrameworks.map((framework) => (
+        <article key={framework.id} className="rounded-md border bg-background p-4 text-sm">
+          <div className="flex items-start justify-between gap-3">
             <div>
             <div>
-              <h3 className="text-lg font-semibold">{selectedBook.metadata.title}</h3>
-              <p className="mt-1 text-xs text-muted-foreground">
-                {selectedBook.metadata.totalChapters} 章 · {selectedBook.metadata.totalWords.toLocaleString()} 字
-              </p>
+              <h3 className="font-medium">{framework.title}</h3>
+              <p className="mt-1 text-xs text-muted-foreground">{framework.line === "main" ? "主线" : "支线"} · 覆盖 {framework.rangeChapterIds.length} 章</p>
             </div>
             </div>
-            <BookAnalysisStyleCard
-              book={selectedBook}
-              extracting={extractingStyle}
-              onExtractStyle={onExtractStyle}
-              onToggleStyle={onToggleStyle}
-            />
-            <section className="rounded-lg border bg-background p-4">
-              <div className="flex items-start justify-between gap-3">
-                <div>
-                  <h3 className="text-sm font-semibold">故事框架</h3>
-                  <p className="mt-1 text-xs text-muted-foreground">
-                    从选中章节提取钩子、铺垫、爽点和结尾钩子,可用于后续章纲生成。
-                  </p>
-                </div>
-                <Button
-                  variant="outline"
-                  size="sm"
-                  onClick={onExtractStoryFramework}
-                  disabled={extractingStoryFramework}
-                >
-                  {extractingStoryFramework ? "提取中..." : "提取故事框架"}
-                </Button>
-              </div>
-              {storyFrameworks.length === 0 ? (
-                <div className="mt-3 rounded-md border border-dashed px-3 py-3 text-xs text-muted-foreground">
-                  尚未提取故事框架。点击“故事框架提取”后选择章节,生成可复用的四段框架。
-                </div>
-              ) : (
-                <div className="mt-3 grid gap-2">
-                  {storyFrameworks.map((framework) => (
-                    <div key={framework.id} className="rounded-md border bg-muted/30 p-3 text-xs">
-                      <div className="flex items-start justify-between gap-3">
-                        <div className="min-w-0">
-                          <div className="truncate text-sm font-medium">{framework.title}</div>
-                          <div className="mt-1 text-muted-foreground">
-                            {framework.line === "main" ? "主线" : "支线"} · 覆盖 {framework.rangeChapterIds.length} 章
-                          </div>
-                        </div>
-                        {onCreateOutlineFromFramework && (
-                          <Button
-                            variant="outline"
-                            size="sm"
-                            onClick={() => onCreateOutlineFromFramework(framework.id)}
-                          >
-                            基于此框架创建章纲
-                          </Button>
-                        )}
-                      </div>
-                      <div className="mt-2 grid gap-2 sm:grid-cols-2">
-                        <div>
-                          <div className="font-medium">开局钩子</div>
-                          <div className="mt-1 line-clamp-2 text-muted-foreground">{framework.beats.hook}</div>
-                        </div>
-                        <div>
-                          <div className="font-medium">爽点</div>
-                          <div className="mt-1 line-clamp-2 text-muted-foreground">{framework.beats.payoff}</div>
-                        </div>
-                      </div>
-                      <details className="mt-3 rounded-md border bg-background px-3 py-2">
-                        <summary className="cursor-pointer text-xs font-medium text-primary">
-                          查看完整框架
-                        </summary>
-                        <div className="mt-3 grid gap-3 text-xs leading-5">
-                          <div>
-                            <div className="font-medium">开局钩子</div>
-                            <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.hook}</div>
-                          </div>
-                          <div>
-                            <div className="font-medium">铺垫</div>
-                            <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.buildup}</div>
-                          </div>
-                          <div>
-                            <div className="font-medium">爽点</div>
-                            <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.payoff}</div>
-                          </div>
-                          <div>
-                            <div className="font-medium">结尾钩子</div>
-                            <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.endingHook}</div>
-                          </div>
-                          {framework.handcraftHints ? (
-                            <div>
-                              <div className="font-medium">作者手搓留白</div>
-                              <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.handcraftHints}</div>
-                            </div>
-                          ) : null}
-                        </div>
-                      </details>
-                    </div>
-                  ))}
-                </div>
-              )}
-            </section>
-            <BookAnalysisCharacterPanel
-              book={selectedBook}
-              selectedCharacterId={selectedCharacterId}
-              addingToSoul={addingToSoul}
-              onSelectCharacter={onSelectCharacter}
-              onAddSelectedSkillsToSoul={onAddSelectedSkillsToSoul}
-            />
+            {onCreateOutlineFromFramework && <Button variant="outline" size="sm" onClick={() => onCreateOutlineFromFramework(framework.id)}>基于此框架创建章纲</Button>}
           </div>
           </div>
-        ) : (
-          <div className="flex flex-1 items-center justify-center p-8 text-center">
-            <div>
-              <h3 className="text-lg font-semibold">还没有拆书作品</h3>
-              <p className="mt-2 text-sm text-muted-foreground">导入 TXT 小说后,可以提取角色 Skill 和作品文风。</p>
-              <Button className="mt-4" onClick={onImportNovel}>导入小说</Button>
-            </div>
+          <div className="mt-4 grid gap-3 sm:grid-cols-2">
+            <div><div className="font-medium">开局钩子</div><p className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.hook}</p></div>
+            <div><div className="font-medium">铺垫</div><p className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.buildup}</p></div>
+            <div><div className="font-medium">爽点</div><p className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.payoff}</p></div>
+            <div><div className="font-medium">结尾钩子</div><p className="mt-1 whitespace-pre-wrap text-muted-foreground">{framework.beats.endingHook}</p></div>
           </div>
           </div>
-        )}
+        </article>
+      ))}
+    </section>
+  )
+
+  return (
+    <div className="flex h-full min-h-0 bg-muted/20">
+      <main className="flex min-w-0 flex-1 flex-col">
+        <header className="shrink-0 border-b bg-background px-5 py-3">
+          <div className="text-xs font-medium text-muted-foreground">拆书库</div>
+          <h2 className="text-lg font-semibold">{selectedBook.metadata.title}</h2>
+          <p className="mt-1 text-xs text-muted-foreground">{selectedBook.metadata.totalChapters} 章 · {selectedBook.metadata.totalWords.toLocaleString()} 字</p>
+        </header>
+        <BookAnalysisModuleView
+        book={selectedBook}
+        task={analysisTask}
+        chunks={analysisChunks}
+        selectedCharacterId={selectedCharacterId}
+        storyContent={storyContent}
+        extractingStyle={extractingStyle}
+        addingToSoul={addingToSoul}
+        onSelectCharacter={onSelectCharacter}
+        onToggleStyle={onToggleStyle}
+        onAddSelectedSkillsToSoul={onAddSelectedSkillsToSoul}
+        onReextract={(skill) => {
+          if (onReextractSkill) onReextractSkill(skill)
+          else if (skill === "characters") onReextractCharacters()
+          else if (skill === "story") onExtractStoryFramework()
+          else onExtractStyle()
+        }}
+        onConfigureTask={onConfigureAnalysisTask}
+        onPauseTask={onPauseAnalysisTask}
+        onContinueTask={onContinueAnalysisTask}
+        onRetryTask={onRetryAnalysisTask}
+        onCancelTask={onCancelAnalysisTask}
+        />
       </main>
       </main>
       <BookAnalysisActiveContext enabledStyle={state.enabledStyle} bindings={state.bindings} />
       <BookAnalysisActiveContext enabledStyle={state.enabledStyle} bindings={state.bindings} />
     </div>
     </div>

+ 127 - 0
src/components/novel/book-analysis-module-view.spec.tsx

@@ -0,0 +1,127 @@
+import { renderToStaticMarkup } from "react-dom/server"
+import { describe, expect, it, vi } from "vitest"
+import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
+import type { AnalysisModuleState, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { BookAnalysisModuleView } from "./book-analysis-module-view"
+
+function moduleState(skill: AnalysisModuleState["skill"], status: AnalysisModuleState["status"]): AnalysisModuleState {
+  return {
+    skill,
+    status,
+    range: { startOrder: 1, endOrder: 20 },
+    chunkIds: ["chunk-0001-0010", "chunk-0011-0020"],
+    completedChunkIds: [],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 1,
+  }
+}
+
+const task: BookAnalysisPipelineTask = {
+  version: 1,
+  id: "task-1",
+  batchId: null,
+  projectPath: "E:/Novel",
+  bookId: "book-1",
+  bookPath: "E:/Novel/book-analysis/book-1",
+  selectedSkills: ["characters", "style"],
+  range: { startOrder: 1, endOrder: 20 },
+  status: "running",
+  currentSkill: "characters",
+  modules: {
+    characters: moduleState("characters", "running"),
+    story: moduleState("story", "skipped"),
+    style: moduleState("style", "pending"),
+  },
+  error: null,
+  createdAt: 1,
+  startedAt: 1,
+  completedAt: null,
+  updatedAt: 1,
+}
+
+const book: BookAnalysisLibraryBook = {
+  id: "book-1",
+  path: task.bookPath,
+  metadata: {
+    title: "测试作品",
+    totalChapters: 20,
+    totalWords: 20000,
+    sourceType: "file",
+    createdAt: 1,
+    updatedAt: 1,
+  },
+  recognizedCharacters: [],
+  characters: [],
+  skills: [],
+  styleStatus: "missing",
+  boundAurasCount: 0,
+  addedAuraCharacterIds: [],
+  evidence: [],
+  analysisManifest: {
+    version: 1,
+    bookId: "book-1",
+    modules: { characters: moduleState("characters", "skipped") },
+    updatedAt: 0,
+  },
+}
+
+describe("BookAnalysisModuleView 分析进度", () => {
+  it("当前任务状态优先于旧 manifest,并显示区块与下一步", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={task}
+        chunks={[
+          {
+            version: 1,
+            id: "chunk-0001-0010",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-1"],
+            startOrder: 1,
+            endOrder: 10,
+            wordCount: 1000,
+            status: "running",
+            attempts: 1,
+            resultPath: null,
+            error: null,
+            startedAt: 1,
+            completedAt: null,
+            updatedAt: 1,
+          },
+          {
+            version: 1,
+            id: "chunk-0011-0020",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-11"],
+            startOrder: 11,
+            endOrder: 20,
+            wordCount: 1000,
+            status: "pending",
+            attempts: 0,
+            resultPath: null,
+            error: null,
+            startedAt: null,
+            completedAt: null,
+            updatedAt: 1,
+          },
+        ]}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+      />,
+    )
+
+    expect(html).toContain("正在进行:角色 Skill")
+    expect(html).toContain("当前区块:第 1/2 个(第 1~10 章)")
+    expect(html).toContain("下一步:文风 Skill")
+    expect(html).not.toContain("未选择")
+  })
+})

+ 154 - 0
src/components/novel/book-analysis-module-view.tsx

@@ -0,0 +1,154 @@
+import { useState, type ReactNode } from "react"
+import { Button } from "@/components/ui/button"
+import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
+import type { AnalysisChunkRecord, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
+import { BookAnalysisStyleCard } from "./book-analysis-style-card"
+
+export type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
+
+interface BookAnalysisModuleViewProps {
+  book: BookAnalysisLibraryBook
+  task?: BookAnalysisPipelineTask | null
+  chunks?: AnalysisChunkRecord[]
+  selectedCharacterId: string | null
+  storyContent?: ReactNode
+  extractingStyle: boolean
+  addingToSoul: boolean
+  onSelectCharacter: (id: string) => void
+  onToggleStyle: () => void
+  onAddSelectedSkillsToSoul: (skillId: string) => void
+  onReextract: (skill: AnalysisSkill) => void
+  onConfigureTask?: () => void
+  onPauseTask?: () => void
+  onContinueTask?: () => void
+  onRetryTask?: () => void
+  onCancelTask?: () => void
+}
+
+const TABS: Array<{ id: BookAnalysisModuleTab; label: string }> = [
+  { id: "characters", label: "角色 Skill" },
+  { id: "story", label: "故事 Skill" },
+  { id: "style", label: "文风 Skill" },
+  { id: "evidence", label: "证据片段" },
+]
+
+const STATUS_LABELS = {
+  pending: "待分析",
+  running: "分析中",
+  completed: "已完成",
+  failed: "失败",
+  skipped: "未选择",
+} as const
+
+const SKILL_LABELS: Record<AnalysisSkill, string> = {
+  characters: "角色 Skill",
+  story: "故事 Skill",
+  style: "文风 Skill",
+}
+
+export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
+  const [active, setActive] = useState<BookAnalysisModuleTab>("characters")
+  const skill = active === "evidence" ? null : active
+  const currentTaskModule = skill && props.task?.selectedSkills.includes(skill)
+    ? props.task.modules[skill]
+    : null
+  const moduleState = skill ? currentTaskModule ?? props.book.analysisManifest?.modules[skill] : null
+  const completed = skill && props.task
+    ? props.chunks?.filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === skill && chunk.status === "completed").length ?? 0
+    : 0
+  const total = moduleState?.chunkIds.length ?? 0
+  const currentSkill = props.task?.currentSkill
+  const currentSkillChunks = currentSkill
+    ? (props.chunks ?? [])
+        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === currentSkill)
+        .sort((left, right) => left.startOrder - right.startOrder)
+    : []
+  const activeChunk = currentSkillChunks.find((chunk) => chunk.status === "running")
+    ?? currentSkillChunks.find((chunk) => chunk.status === "failed")
+  const activeChunkIndex = activeChunk
+    ? currentSkillChunks.findIndex((chunk) => chunk.id === activeChunk.id) + 1
+    : 0
+  const completedCurrentChunks = currentSkillChunks.filter((chunk) => chunk.status === "completed").length
+  const currentSkillIndex = currentSkill ? props.task?.selectedSkills.indexOf(currentSkill) ?? -1 : -1
+  const nextSkill = currentSkillIndex >= 0 ? props.task?.selectedSkills[currentSkillIndex + 1] : undefined
+
+  return (
+    <div className="flex min-h-0 flex-1 flex-col">
+      <div role="tablist" aria-label="拆书分析模块" className="flex shrink-0 border-b bg-background px-5">
+        {TABS.map((tab) => (
+          <button key={tab.id} role="tab" aria-selected={active === tab.id} onClick={() => setActive(tab.id)} className={`border-b-2 px-3 py-3 text-sm ${active === tab.id ? "border-primary font-medium" : "border-transparent text-muted-foreground"}`}>
+            {tab.label}
+          </button>
+        ))}
+      </div>
+      <div className="min-h-0 flex-1 overflow-y-auto p-5">
+        {props.task && ["queued", "running", "paused", "failed"].includes(props.task.status) && (
+          <section className={`mb-4 rounded-md border p-3 ${props.task.status === "failed" ? "border-destructive/40 bg-destructive/5" : "border-primary/30 bg-primary/5"}`}>
+            {currentSkill ? (
+              <div className="space-y-1 text-sm">
+                <div className="font-medium">
+                  {props.task.status === "failed"
+                    ? `${SKILL_LABELS[currentSkill]} 分析失败`
+                    : `正在进行:${SKILL_LABELS[currentSkill]}`}
+                </div>
+                {activeChunk && (
+                  <div>当前区块:第 {activeChunkIndex}/{currentSkillChunks.length} 个(第 {activeChunk.startOrder}~{activeChunk.endOrder} 章)</div>
+                )}
+                <div>已完成区块:{completedCurrentChunks}/{currentSkillChunks.length}</div>
+                <div>{props.task.status === "failed"
+                  ? "后续步骤已暂停"
+                  : `下一步:${nextSkill ? SKILL_LABELS[nextSkill] : "汇总并保存结果"}`}</div>
+              </div>
+            ) : (
+              <div className="text-sm font-medium">分析任务等待开始</div>
+            )}
+            {props.task.error && <div role="alert" className="mt-2 break-words text-sm text-destructive">失败原因:{props.task.error}</div>}
+            <div className="mt-3 flex flex-wrap gap-2">
+              {props.task.status === "running" && props.onPauseTask && <Button size="sm" variant="outline" onClick={props.onPauseTask}>暂停</Button>}
+              {props.task.status === "paused" && props.onContinueTask && <Button size="sm" onClick={props.onContinueTask}>继续</Button>}
+              {props.task.status === "failed" && props.onRetryTask && <Button size="sm" onClick={props.onRetryTask}>重试当前步骤</Button>}
+              {["queued", "running", "paused", "failed"].includes(props.task.status) && props.onCancelTask && (
+                <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
+              )}
+            </div>
+          </section>
+        )}
+        {props.task?.status === "awaiting-range" && (
+          <div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-primary/30 bg-primary/5 p-3">
+            <div><div className="text-sm font-medium">待选择章节</div><div className="mt-1 text-xs text-muted-foreground">已选择 {props.task.selectedSkills.length} 个提取项目</div></div>
+            <Button size="sm" onClick={props.onConfigureTask}>选择章节</Button>
+          </div>
+        )}
+        {skill && (
+          <div className="mb-4 flex items-start justify-between gap-3 border-b pb-3">
+            <div className="text-sm text-muted-foreground">
+              <div>{moduleState ? STATUS_LABELS[moduleState.status] : "尚未分析"}</div>
+              {moduleState && <div className="mt-1">最近范围:第 {moduleState.range.startOrder}~{moduleState.range.endOrder} 章{total > 0 ? ` · 完成区块 ${completed}/${total}` : ""}</div>}
+              {moduleState?.summary && <div className="mt-1 break-words">{moduleState.summary}</div>}
+            </div>
+            <Button variant="outline" size="sm" onClick={() => props.onReextract(skill)}>重新提取</Button>
+          </div>
+        )}
+        {active === "characters" && (
+          <BookAnalysisCharacterPanel book={props.book} selectedCharacterId={props.selectedCharacterId} onSelectCharacter={props.onSelectCharacter} addingToSoul={props.addingToSoul} onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul} />
+        )}
+        {active === "story" && props.storyContent}
+        {active === "style" && (
+          <BookAnalysisStyleCard book={props.book} extracting={props.extractingStyle} onExtractStyle={() => props.onReextract("style")} onToggleStyle={props.onToggleStyle} />
+        )}
+        {active === "evidence" && (
+          <div className="space-y-3">
+            {props.book.evidence.length === 0 ? <p className="text-sm text-muted-foreground">暂无证据片段</p> : props.book.evidence.map((item) => (
+              <article key={item.id} className="rounded-md border p-3 text-sm">
+                <div className="text-xs text-muted-foreground">第 {item.chapterOrder} 章 · {item.tags.join("、") || item.skill}</div>
+                <p className="mt-2 whitespace-pre-wrap break-words">{item.text}</p>
+                <p className="mt-2 text-xs text-muted-foreground">用途:{item.purpose};保存原因:{item.reason}</p>
+              </article>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 144 - 0
src/components/novel/book-analysis-run-dialog.tsx

@@ -0,0 +1,144 @@
+import { useEffect, useMemo, useState } from "react"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import type { ChapterSelectionState } from "@/lib/novel/book-analysis/types"
+import {
+  ANALYSIS_SKILL_ORDER,
+  type AnalysisChapterRange,
+  type AnalysisSkill,
+} from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { MAX_ANALYSIS_CHAPTERS } from "@/lib/novel/book-analysis/analysis-chunk-planner"
+
+interface BookAnalysisRunDialogProps {
+  open: boolean
+  chapters: ChapterSelectionState[]
+  initialSkills?: AnalysisSkill[]
+  lockedSkills?: AnalysisSkill[]
+  initialRange?: AnalysisChapterRange | null
+  onOpenChange: (open: boolean) => void
+  onSubmit: (value: { range: AnalysisChapterRange; selectedSkills: AnalysisSkill[] }) => Promise<void> | void
+}
+
+const SKILL_LABELS: Record<AnalysisSkill, string> = {
+  characters: "角色 Skill",
+  story: "故事 Skill",
+  style: "文风 Skill",
+}
+
+export function BookAnalysisRunDialog({
+  open,
+  chapters,
+  initialSkills = [],
+  lockedSkills,
+  initialRange,
+  onOpenChange,
+  onSubmit,
+}: BookAnalysisRunDialogProps) {
+  const [start, setStart] = useState("")
+  const [end, setEnd] = useState("")
+  const [skills, setSkills] = useState<AnalysisSkill[]>([])
+  const [submitting, setSubmitting] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    setStart(initialRange ? String(initialRange.startOrder) : "")
+    setEnd(initialRange ? String(initialRange.endOrder) : "")
+    setSkills(lockedSkills?.length ? [...lockedSkills] : [...initialSkills])
+  }, [initialRange, initialSkills, lockedSkills, open])
+
+  const range = useMemo<AnalysisChapterRange | null>(() => {
+    const startOrder = Number(start)
+    const endOrder = Number(end)
+    if (!Number.isInteger(startOrder) || !Number.isInteger(endOrder)) return null
+    return { startOrder, endOrder }
+  }, [end, start])
+  const count = range ? range.endOrder - range.startOrder + 1 : 0
+  const available = new Set(chapters.map((chapter) => chapter.order))
+  const missingChapter = range && count > 0
+    ? Array.from({ length: count }, (_, index) => range.startOrder + index).find((order) => !available.has(order))
+    : undefined
+  const error = !range || !start || !end
+    ? ""
+    : range.startOrder < 1
+      ? "起始章节必须大于 0"
+      : range.endOrder < range.startOrder
+        ? "结束章节不能小于起始章节"
+        : count > MAX_ANALYSIS_CHAPTERS
+          ? "单次最多分析 100 章,请分批处理"
+          : missingChapter !== undefined
+            ? `未找到第 ${missingChapter} 章`
+            : ""
+  const canSubmit = Boolean(range && !error && skills.length > 0 && !submitting)
+
+  const submit = async () => {
+    if (!range || !canSubmit) return
+    setSubmitting(true)
+    try {
+      await onSubmit({ range, selectedSkills: ANALYSIS_SKILL_ORDER.filter((skill) => skills.includes(skill)) })
+      onOpenChange(false)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
+      <DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
+        <DialogHeader className="shrink-0">
+          <DialogTitle>设置分析范围</DialogTitle>
+        </DialogHeader>
+        <div className="min-h-0 flex-1 space-y-5 overflow-y-auto py-4 pr-1">
+          <div className="grid grid-cols-2 gap-3">
+            <label className="space-y-1 text-sm">
+              <span>起始章节</span>
+              <input aria-label="起始章节" type="number" min={1} value={start} onChange={(event) => setStart(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
+            </label>
+            <label className="space-y-1 text-sm">
+              <span>结束章节</span>
+              <input aria-label="结束章节" type="number" min={1} value={end} onChange={(event) => setEnd(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
+            </label>
+          </div>
+          <p className="text-xs text-muted-foreground">
+            单次最多分析 100 章。当前作品共 {chapters.length} 章,请分批选择章节范围。
+          </p>
+          {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
+          <fieldset>
+            <legend className="text-sm font-medium">提取项目</legend>
+            <div className="mt-2 grid gap-2 sm:grid-cols-2">
+              {ANALYSIS_SKILL_ORDER.map((skill) => (
+                <label key={skill} className="flex items-center gap-2 text-sm">
+                  <input
+                    type="checkbox"
+                    checked={skills.includes(skill)}
+                    disabled={Boolean(lockedSkills?.length && !lockedSkills.includes(skill))}
+                    onChange={(event) => setSkills((current) => event.target.checked
+                      ? [...new Set([...current, skill])]
+                      : current.filter((item) => item !== skill))}
+                  />
+                  {SKILL_LABELS[skill]}
+                </label>
+              ))}
+              {!lockedSkills?.length && (
+                <label className="flex items-center gap-2 text-sm font-medium">
+                  <input
+                    type="checkbox"
+                    checked={skills.length === ANALYSIS_SKILL_ORDER.length}
+                    onChange={(event) => setSkills(event.target.checked ? [...ANALYSIS_SKILL_ORDER] : [])}
+                  />
+                  全部提取
+                </label>
+              )}
+            </div>
+          </fieldset>
+          {range && !error && (
+            <p className="text-sm text-muted-foreground">预计 {Math.ceil(count / 10)} 个章节区块</p>
+          )}
+        </div>
+        <DialogFooter className="shrink-0">
+          <Button variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>取消</Button>
+          <Button disabled={!canSubmit} onClick={submit}>{submitting ? "正在启动…" : "开始分析"}</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 112 - 4
src/components/novel/book-analysis-view.tsx

@@ -7,6 +7,7 @@ import { BookAnalysisResultViewer } from "./book-analysis-result-viewer"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
+import { useBookAnalysisPipelineStore } from "@/stores/book-analysis-pipeline-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
@@ -35,6 +36,10 @@ import {
 import { loadPlotFrameworkLibrary, upsertPlotFramework } from "@/lib/novel/plot-framework-library"
 import { loadPlotFrameworkLibrary, upsertPlotFramework } from "@/lib/novel/plot-framework-library"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
 import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
+import type { AnalysisSkill } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import type { AnalysisChapterRange } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { loadChapterList } from "@/lib/novel/book-analysis/analysis-engine"
+import { BookAnalysisRunDialog } from "./book-analysis-run-dialog"
 import { OutlineCreatorDialog } from "./outline-editor"
 import { OutlineCreatorDialog } from "./outline-editor"
 
 
 interface StoryFrameworkSelectionData {
 interface StoryFrameworkSelectionData {
@@ -83,6 +88,13 @@ export function BookAnalysisView() {
   })
   })
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null)
   const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null)
+  const [pipelineDialog, setPipelineDialog] = useState<{
+    taskId: string
+    chapters: Awaited<ReturnType<typeof loadChapterList>>
+    initialSkills: AnalysisSkill[]
+    lockedSkills?: AnalysisSkill[]
+    initialRange?: AnalysisChapterRange | null
+  } | null>(null)
 
 
   const currentProject = useWikiStore((s) => s.project)
   const currentProject = useWikiStore((s) => s.project)
   const storeSelectedBookId = useBookAnalysisStore((s) => s.selectedLibraryBookId)
   const storeSelectedBookId = useBookAnalysisStore((s) => s.selectedLibraryBookId)
@@ -108,6 +120,17 @@ export function BookAnalysisView() {
   const renameCompletedImportTask = useBookAnalysisImportStore((s) => s.renameCompletedTask)
   const renameCompletedImportTask = useBookAnalysisImportStore((s) => s.renameCompletedTask)
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
   const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
+  const pipelineTasks = useBookAnalysisPipelineStore((s) => s.tasks)
+  const pipelineChunks = useBookAnalysisPipelineStore((s) => s.chunks)
+  const initializePipelineProject = useBookAnalysisPipelineStore((s) => s.initializeProject)
+  const createAwaitingRangeTask = useBookAnalysisPipelineStore((s) => s.createAwaitingRangeTask)
+  const configureTaskRange = useBookAnalysisPipelineStore((s) => s.configureTaskRange)
+  const startPipelineTask = useBookAnalysisPipelineStore((s) => s.startTask)
+  const pausePipelineTask = useBookAnalysisPipelineStore((s) => s.pauseTask)
+  const continuePipelineTask = useBookAnalysisPipelineStore((s) => s.continueTask)
+  const retryPipelineChunk = useBookAnalysisPipelineStore((s) => s.retryFailedChunk)
+  const cancelPipelineTask = useBookAnalysisPipelineStore((s) => s.cancelTask)
+  const disposePipeline = useBookAnalysisPipelineStore((s) => s.dispose)
   const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
   const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
   const importInitializationSequenceRef = useRef(0)
   const importInitializationSequenceRef = useRef(0)
   const importInitializationTokenRef = useRef<{ sequence: number; projectPath: string } | null>(null)
   const importInitializationTokenRef = useRef<{ sequence: number; projectPath: string } | null>(null)
@@ -213,6 +236,9 @@ export function BookAnalysisView() {
     const initializationToken = { sequence: ++importInitializationSequenceRef.current, projectPath }
     const initializationToken = { sequence: ++importInitializationSequenceRef.current, projectPath }
     importInitializationTokenRef.current = initializationToken
     importInitializationTokenRef.current = initializationToken
     importRevisionBaselineRef.current = { projectPath, revision: useBookAnalysisImportStore.getState().revision }
     importRevisionBaselineRef.current = { projectPath, revision: useBookAnalysisImportStore.getState().revision }
+    void initializePipelineProject(projectPath).catch((error) => {
+      console.error("加载拆书分析任务失败", error)
+    })
     void initializeImportProject(projectPath).then(() => {
     void initializeImportProject(projectPath).then(() => {
       if (importInitializationTokenRef.current !== initializationToken) return
       if (importInitializationTokenRef.current !== initializationToken) return
       const importState = useBookAnalysisImportStore.getState()
       const importState = useBookAnalysisImportStore.getState()
@@ -228,11 +254,26 @@ export function BookAnalysisView() {
     })
     })
 
 
     return () => {
     return () => {
-      void disposeImportStore().catch((error) => {
+      void Promise.all([disposeImportStore(), disposePipeline()]).catch((error) => {
         console.error("释放批量导入任务失败", error)
         console.error("释放批量导入任务失败", error)
       })
       })
     }
     }
-  }, [currentProject?.path, initializeImportProject, disposeImportStore])
+  }, [currentProject?.path, initializeImportProject, disposeImportStore, initializePipelineProject, disposePipeline])
+
+  useEffect(() => {
+    if (!currentProject?.path) return
+    for (const importTask of importTasks) {
+      if (importTask.status !== "completed") continue
+      const batch = importBatches.find((item) => item.id === importTask.batchId)
+      if (!batch?.analysisSkills?.length) continue
+      void createAwaitingRangeTask({
+        batchId: batch.id,
+        bookId: importTask.bookId,
+        bookPath: `${currentProject.path}/book-analysis/${importTask.bookId}`,
+        selectedSkills: batch.analysisSkills,
+      })
+    }
+  }, [createAwaitingRangeTask, currentProject?.path, importBatches, importTasks])
 
 
   useEffect(() => {
   useEffect(() => {
     const projectPath = currentProject?.path ?? null
     const projectPath = currentProject?.path ?? null
@@ -445,11 +486,44 @@ export function BookAnalysisView() {
     }
     }
   }
   }
 
 
-  const handleStartAnalysis = useCallback(async function handleStartAnalysis(files: BatchImportCandidate[]) {
-    await createImportBatch(files)
+  const handleStartAnalysis = useCallback(async function handleStartAnalysis(files: BatchImportCandidate[], analysisSkills?: AnalysisSkill[]) {
+    await createImportBatch(files, analysisSkills)
     setInputDialogOpen(false)
     setInputDialogOpen(false)
   }, [createImportBatch])
   }, [createImportBatch])
 
 
+  const selectedPipelineTask = pipelineTasks
+    .filter((task) => task.bookId === selectedLibraryBook?.id)
+    .sort((left, right) => right.updatedAt - left.updatedAt)[0] ?? null
+
+  const openPipelineDialog = useCallback(async (skill: AnalysisSkill) => {
+    if (!selectedLibraryBook) return
+    const existingModule = selectedLibraryBook.analysisManifest?.modules[skill]
+    const created = await createAwaitingRangeTask({
+      bookId: selectedLibraryBook.id,
+      bookPath: selectedLibraryBook.path,
+      selectedSkills: [skill],
+      forceNew: true,
+    })
+    if (!created) return
+    setPipelineDialog({
+      taskId: created.id,
+      chapters: await loadChapterList(selectedLibraryBook.path),
+      initialSkills: [skill],
+      lockedSkills: [skill],
+      initialRange: existingModule?.range ?? null,
+    })
+  }, [createAwaitingRangeTask, selectedLibraryBook])
+
+  const openExistingPipelineDialog = useCallback(async () => {
+    if (!selectedLibraryBook || !selectedPipelineTask) return
+    setPipelineDialog({
+      taskId: selectedPipelineTask.id,
+      chapters: await loadChapterList(selectedLibraryBook.path),
+      initialSkills: selectedPipelineTask.selectedSkills,
+      initialRange: selectedPipelineTask.range,
+    })
+  }, [selectedLibraryBook, selectedPipelineTask])
+
   const runImportTaskAction = useCallback((action: () => Promise<void>, failureMessage: string) => {
   const runImportTaskAction = useCallback((action: () => Promise<void>, failureMessage: string) => {
     void action().catch((error) => {
     void action().catch((error) => {
       console.error(failureMessage, error)
       console.error(failureMessage, error)
@@ -500,6 +574,7 @@ export function BookAnalysisView() {
   }, [handleSelectBook, libraryState.books])
   }, [handleSelectBook, libraryState.books])
 
 
   const libraryLayout = (
   const libraryLayout = (
+    <>
     <BookAnalysisLibraryLayout
     <BookAnalysisLibraryLayout
       state={libraryState}
       state={libraryState}
       selectedBookId={selectedLibraryBook?.id ?? selectedBookId}
       selectedBookId={selectedLibraryBook?.id ?? selectedBookId}
@@ -524,6 +599,8 @@ export function BookAnalysisView() {
           onOpenBook={handleOpenImportedBook}
           onOpenBook={handleOpenImportedBook}
         />
         />
       }
       }
+      analysisTask={selectedPipelineTask}
+      analysisChunks={pipelineChunks.filter((chunk) => chunk.taskId === selectedPipelineTask?.id)}
       onSelectBook={handleSelectBook}
       onSelectBook={handleSelectBook}
       onSelectCharacter={setSelectedCharacterId}
       onSelectCharacter={setSelectedCharacterId}
       onImportNovel={() => setInputDialogOpen(true)}
       onImportNovel={() => setInputDialogOpen(true)}
@@ -533,8 +610,39 @@ export function BookAnalysisView() {
       onToggleStyle={handleLibraryToggleStyle}
       onToggleStyle={handleLibraryToggleStyle}
       onAddSelectedSkillsToSoul={handleLibraryAddSkillsToSoul}
       onAddSelectedSkillsToSoul={handleLibraryAddSkillsToSoul}
       onReextractCharacters={handleLibraryReextractCharacters}
       onReextractCharacters={handleLibraryReextractCharacters}
+      onReextractSkill={openPipelineDialog}
+      onConfigureAnalysisTask={openExistingPipelineDialog}
+      onPauseAnalysisTask={selectedPipelineTask ? () => void pausePipelineTask(selectedPipelineTask.id) : undefined}
+      onContinueAnalysisTask={selectedPipelineTask ? () => void continuePipelineTask(selectedPipelineTask.id) : undefined}
+      onRetryAnalysisTask={selectedPipelineTask ? () => {
+        const skill = selectedPipelineTask.currentSkill
+        const failedChunkId = skill ? selectedPipelineTask.modules[skill].failedChunkId : null
+        const retry = skill && failedChunkId
+          ? retryPipelineChunk(selectedPipelineTask.id, skill, failedChunkId)
+          : continuePipelineTask(selectedPipelineTask.id)
+        void retry.catch((error) => toast.error(`重试失败:${error instanceof Error ? error.message : String(error)}`))
+      } : undefined}
+      onCancelAnalysisTask={selectedPipelineTask ? () => void cancelPipelineTask(selectedPipelineTask.id) : undefined}
       onDeleteBook={(bookId) => handleLibraryDeleteBook(bookId, selectedBookId)}
       onDeleteBook={(bookId) => handleLibraryDeleteBook(bookId, selectedBookId)}
     />
     />
+    {pipelineDialog && (
+      <BookAnalysisRunDialog
+        open
+        chapters={pipelineDialog.chapters}
+        initialSkills={pipelineDialog.initialSkills}
+        lockedSkills={pipelineDialog.lockedSkills}
+        initialRange={pipelineDialog.initialRange}
+        onOpenChange={(open) => !open && setPipelineDialog(null)}
+        onSubmit={async ({ range, selectedSkills }) => {
+          await configureTaskRange(pipelineDialog.taskId, range, selectedSkills)
+          setPipelineDialog(null)
+          void startPipelineTask(pipelineDialog.taskId).then(reloadLibraryState).catch((error) => {
+            toast.error(`分析失败:${error instanceof Error ? error.message : String(error)}`)
+          })
+        }}
+      />
+    )}
+    </>
   )
   )
 
 
   if (tasks.length === 0) {
   if (tasks.length === 0) {

+ 2 - 0
src/lib/context-hub/source-paths.spec.ts

@@ -19,6 +19,7 @@ describe("context source paths", () => {
     ["E:/Novel/.novel/revision-feedback.json", "snapshot"],
     ["E:/Novel/.novel/revision-feedback.json", "snapshot"],
     ["E:/Novel/.novel/timeline.json", "memory"],
     ["E:/Novel/.novel/timeline.json", "memory"],
     ["E:/Novel/.qmai/writing-style.json", "setting"],
     ["E:/Novel/.qmai/writing-style.json", "setting"],
+    ["E:/Novel/.qmai/book-analysis-context.json", "book-analysis"],
     ["E:/Novel/.qmai/character-aura.json", "entity"],
     ["E:/Novel/.qmai/character-aura.json", "entity"],
     ["E:/Novel/.qmai/simulations/latest.json", "deduction"],
     ["E:/Novel/.qmai/simulations/latest.json", "deduction"],
     ["E:/Novel/.qmai/context-cache/v1/manifest.json", "ignored"],
     ["E:/Novel/.qmai/context-cache/v1/manifest.json", "ignored"],
@@ -41,5 +42,6 @@ describe("context source paths", () => {
     expect(getDataSourceKinds("relatedSettings")).toEqual(["entity", "setting"])
     expect(getDataSourceKinds("relatedSettings")).toEqual(["entity", "setting"])
     expect(getDataSourceKinds("recentChapterContents")).toEqual(["chapter"])
     expect(getDataSourceKinds("recentChapterContents")).toEqual(["chapter"])
     expect(getDataSourceKinds("storyFrameworkBinding")).toEqual(["outline", "setting", "deduction"])
     expect(getDataSourceKinds("storyFrameworkBinding")).toEqual(["outline", "setting", "deduction"])
+    expect(getDataSourceKinds("bookAnalysisReferences")).toEqual(["book-analysis"])
   })
   })
 })
 })

+ 2 - 0
src/lib/context-hub/source-paths.ts

@@ -14,6 +14,7 @@ const DATA_SOURCE_KINDS: Record<string, ContextSourceKind[]> = {
   relatedSettings: ["entity", "setting"],
   relatedSettings: ["entity", "setting"],
   canonRules: ["setting"],
   canonRules: ["setting"],
   writingStyle: ["setting"],
   writingStyle: ["setting"],
+  bookAnalysisReferences: ["book-analysis"],
   searchResults: ["chapter", "outline", "memory", "setting", "entity"],
   searchResults: ["chapter", "outline", "memory", "setting", "entity"],
   graphSearchResults: ["chapter", "outline", "memory", "setting", "entity"],
   graphSearchResults: ["chapter", "outline", "memory", "setting", "entity"],
   revisionFeedback: ["chapter", "snapshot"],
   revisionFeedback: ["chapter", "snapshot"],
@@ -37,6 +38,7 @@ export function classifyContextSourcePath(projectPath: string, path: string): Co
 
 
   if (relative === ".qmai/context-cache" || relative.startsWith(".qmai/context-cache/")) return "ignored"
   if (relative === ".qmai/context-cache" || relative.startsWith(".qmai/context-cache/")) return "ignored"
   if (relative === ".qmai/writing-style.json") return "setting"
   if (relative === ".qmai/writing-style.json") return "setting"
+  if (relative === ".qmai/book-analysis-context.json") return "book-analysis"
   if (relative === ".qmai/character-aura.json") return "entity"
   if (relative === ".qmai/character-aura.json") return "entity"
   if (relative.startsWith("wiki/chapters/")) return "chapter"
   if (relative.startsWith("wiki/chapters/")) return "chapter"
   if (relative.startsWith("wiki/outlines/")) return "outline"
   if (relative.startsWith("wiki/outlines/")) return "outline"

+ 1 - 0
src/lib/context-hub/types.ts

@@ -15,6 +15,7 @@ export type ContextSourceKind =
   | "snapshot"
   | "snapshot"
   | "deduction"
   | "deduction"
   | "soul"
   | "soul"
+  | "book-analysis"
   | "other"
   | "other"
   | "ignored"
   | "ignored"
 
 

+ 23 - 0
src/lib/novel/book-analysis/analysis-active-registry.ts

@@ -0,0 +1,23 @@
+import { normalizePath } from "@/lib/path-utils"
+import type { BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+
+const snapshots = new Map<string, BookAnalysisPipelineTask[]>()
+const ACTIVE_STATUSES = new Set(["queued", "running", "paused"])
+
+function key(projectPath: string): string {
+  return normalizePath(projectPath).replace(/\/+$/, "")
+}
+
+export function setActiveAnalysisSnapshot(projectPath: string, tasks: BookAnalysisPipelineTask[]): void {
+  snapshots.set(key(projectPath), tasks)
+}
+
+export function clearActiveAnalysisSnapshot(projectPath: string): void {
+  snapshots.delete(key(projectPath))
+}
+
+export function hasActiveAnalysisForBook(projectPath: string, bookId: string): boolean {
+  return (snapshots.get(key(projectPath)) ?? []).some((task) => (
+    task.bookId === bookId && ACTIVE_STATUSES.has(task.status)
+  ))
+}

+ 52 - 0
src/lib/novel/book-analysis/analysis-chunk-planner.spec.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildAnalysisChunkPlan,
+  computeAnalysisChunkCharLimit,
+  validateAnalysisRange,
+} from "./analysis-chunk-planner"
+
+const chapters = Array.from({ length: 120 }, (_, index) => ({
+  id: `ch-${String(index + 1).padStart(4, "0")}`,
+  order: index + 1,
+  wordCount: index === 14 ? 50000 : 2000,
+}))
+
+describe("analysis chunk planner", () => {
+  it("拒绝无效范围和超过 100 章的单次任务", () => {
+    expect(() => validateAnalysisRange(chapters, { startOrder: 0, endOrder: 10 }))
+      .toThrow("起始章节")
+    expect(() => validateAnalysisRange(chapters, { startOrder: 20, endOrder: 10 }))
+      .toThrow("结束章节")
+    expect(() => validateAnalysisRange(chapters, { startOrder: 1, endOrder: 101 }))
+      .toThrow("单次最多分析 100 章")
+  })
+
+  it("拒绝包含缺失章节的范围", () => {
+    const withGap = chapters.filter((chapter) => chapter.order !== 12)
+
+    expect(() => validateAnalysisRange(withGap, { startOrder: 10, endOrder: 15 }))
+      .toThrow("未找到第 12 章")
+  })
+
+  it("默认每 10 章切块并把超长章节单独成块", () => {
+    const plan = buildAnalysisChunkPlan(
+      chapters,
+      { startOrder: 1, endOrder: 30 },
+      { targetChapterCount: 10, maxChunkChars: 40000 },
+    )
+
+    expect(plan.flatMap((chunk) => chunk.chapterIds)).toHaveLength(30)
+    expect(plan.some((chunk) => (
+      chunk.chapterIds.length === 1
+      && chunk.startOrder === 15
+      && chunk.endOrder === 15
+    ))).toBe(true)
+    expect(plan.every((chunk) => chunk.chapterIds.length <= 10)).toBe(true)
+  })
+
+  it("按模型上下文计算保守字数上限", () => {
+    expect(computeAnalysisChunkCharLimit(10000)).toBe(8000)
+    expect(computeAnalysisChunkCharLimit(60000)).toBe(27000)
+    expect(computeAnalysisChunkCharLimit(200000)).toBe(40000)
+  })
+})

+ 91 - 0
src/lib/novel/book-analysis/analysis-chunk-planner.ts

@@ -0,0 +1,91 @@
+import type {
+  AnalysisChapterRange,
+  AnalysisChunkPlan,
+} from "./analysis-pipeline-types"
+
+export const MAX_ANALYSIS_CHAPTERS = 100
+export const DEFAULT_CHAPTERS_PER_CHUNK = 10
+
+interface AnalysisChapterSummary {
+  id: string
+  order: number
+  wordCount: number
+}
+
+interface AnalysisChunkPlanOptions {
+  targetChapterCount?: number
+  maxChunkChars: number
+}
+
+export function validateAnalysisRange(
+  chapters: Array<{ order: number }>,
+  range: AnalysisChapterRange,
+): void {
+  if (!Number.isInteger(range.startOrder) || range.startOrder < 1) {
+    throw new Error("起始章节必须大于 0")
+  }
+  if (!Number.isInteger(range.endOrder) || range.endOrder < range.startOrder) {
+    throw new Error("结束章节不能小于起始章节")
+  }
+  if (range.endOrder - range.startOrder + 1 > MAX_ANALYSIS_CHAPTERS) {
+    throw new Error("单次最多分析 100 章,请分批处理")
+  }
+
+  const availableOrders = new Set(chapters.map((chapter) => chapter.order))
+  for (let order = range.startOrder; order <= range.endOrder; order += 1) {
+    if (!availableOrders.has(order)) {
+      throw new Error(`未找到第 ${order} 章,无法开始分析`)
+    }
+  }
+}
+
+export function computeAnalysisChunkCharLimit(maxContextSize: number): number {
+  const requested = Math.floor(maxContextSize * 0.45)
+  return Math.max(8000, Math.min(40000, requested))
+}
+
+export function buildAnalysisChunkPlan(
+  chapters: AnalysisChapterSummary[],
+  range: AnalysisChapterRange,
+  options: AnalysisChunkPlanOptions,
+): AnalysisChunkPlan[] {
+  validateAnalysisRange(chapters, range)
+  const targetChapterCount = Math.max(
+    1,
+    Math.min(DEFAULT_CHAPTERS_PER_CHUNK, Math.floor(options.targetChapterCount ?? DEFAULT_CHAPTERS_PER_CHUNK)),
+  )
+  const maxChunkChars = Math.max(1, Math.floor(options.maxChunkChars))
+  const selected = chapters
+    .filter((chapter) => chapter.order >= range.startOrder && chapter.order <= range.endOrder)
+    .sort((left, right) => left.order - right.order)
+
+  const chunks: AnalysisChunkPlan[] = []
+  let current: AnalysisChapterSummary[] = []
+  let currentWordCount = 0
+
+  const flush = () => {
+    if (current.length === 0) return
+    const startOrder = current[0].order
+    const endOrder = current[current.length - 1].order
+    chunks.push({
+      id: `chunk-${String(startOrder).padStart(4, "0")}-${String(endOrder).padStart(4, "0")}`,
+      chapterIds: current.map((chapter) => chapter.id),
+      startOrder,
+      endOrder,
+      wordCount: currentWordCount,
+    })
+    current = []
+    currentWordCount = 0
+  }
+
+  for (const chapter of selected) {
+    const wouldExceedCount = current.length >= targetChapterCount
+    const wouldExceedChars = current.length > 0 && currentWordCount + chapter.wordCount > maxChunkChars
+    if (wouldExceedCount || wouldExceedChars) flush()
+    current.push(chapter)
+    currentWordCount += chapter.wordCount
+    if (chapter.wordCount > maxChunkChars) flush()
+  }
+  flush()
+  return chunks
+}

+ 77 - 0
src/lib/novel/book-analysis/analysis-context-index.spec.ts

@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest"
+import type { AnalysisEvidenceSnippet } from "./analysis-pipeline-types"
+import {
+  buildBookAnalysisContextIndex,
+  searchBookAnalysisContextIndex,
+} from "./analysis-context-index"
+
+function snippet(id: string, enabled: boolean): AnalysisEvidenceSnippet {
+  return {
+    version: 1,
+    id,
+    bookId: "book-1",
+    skill: "style",
+    taskId: "analysis-1",
+    chapterId: "ch-0008",
+    chapterOrder: 8,
+    text: enabled ? "一本正经地补刀,形成幽默反差。" : "已禁用片段不应出现。",
+    tags: ["幽默对白"],
+    reason: "反差制造笑点",
+    purpose: "对白节奏",
+    enabled,
+    userPinned: false,
+    createdAt: 1,
+    updatedAt: 1,
+  }
+}
+
+describe("analysis context index", () => {
+  it("只索引启用片段并按任务检索作品分析", () => {
+    const index = buildBookAnalysisContextIndex([{
+      bookId: "book-1",
+      title: "轻松修仙录",
+      modules: [{
+        skill: "style",
+        summary: "以严肃口吻制造反差幽默",
+        range: { startOrder: 1, endOrder: 20 },
+        updatedAt: 10,
+      }],
+      evidence: [snippet("enabled", true), snippet("disabled", false)],
+    }], 20)
+
+    const result = searchBookAnalysisContextIndex(index, "按轻松修仙录的幽默对白写一段")
+
+    expect(result).toContain("轻松修仙录")
+    expect(result).toContain("幽默对白")
+    expect(result).toContain("一本正经地补刀")
+    expect(result).not.toContain("已禁用片段")
+  })
+
+  it("无相关词时不返回拆书内容", () => {
+    const index = buildBookAnalysisContextIndex([{
+      bookId: "book-1",
+      title: "轻松修仙录",
+      modules: [],
+      evidence: [snippet("enabled", true)],
+    }], 20)
+
+    expect(searchBookAnalysisContextIndex(index, "检查当前章节标点")).toBe("")
+  })
+
+  it("不会因为模块摘要共享单个汉字而误命中", () => {
+    const index = buildBookAnalysisContextIndex([{
+      bookId: "book-1",
+      title: "轻松修仙录",
+      modules: [{
+        skill: "style",
+        summary: "作者常用短句推进剧情",
+        range: { startOrder: 1, endOrder: 20 },
+        updatedAt: 10,
+      }],
+      evidence: [],
+    }], 20)
+
+    expect(searchBookAnalysisContextIndex(index, "分析当前章节的时间线")).toBe("")
+    expect(searchBookAnalysisContextIndex(index, "参考这本书的文风写法")).toContain("style 分析")
+  })
+})

+ 202 - 0
src/lib/novel/book-analysis/analysis-context-index.ts

@@ -0,0 +1,202 @@
+import {
+  createDirectory,
+  fileExists,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import type {
+  AnalysisChapterRange,
+  AnalysisEvidenceSnippet,
+  AnalysisSkill,
+  BookAnalysisModuleManifest,
+} from "./analysis-pipeline-types"
+
+export interface BookAnalysisContextModule {
+  skill: AnalysisSkill
+  summary: string
+  range: AnalysisChapterRange
+  updatedAt: number
+}
+
+export interface BookAnalysisContextBookInput {
+  bookId: string
+  title: string
+  modules: BookAnalysisContextModule[]
+  evidence: AnalysisEvidenceSnippet[]
+}
+
+export interface BookAnalysisContextIndex {
+  version: 1
+  books: BookAnalysisContextBookInput[]
+  updatedAt: number
+}
+
+interface ContextIndexEntry {
+  name: string
+  path: string
+  is_dir: boolean
+}
+
+export interface BookAnalysisContextIndexIo {
+  createDirectory(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  listDirectory(path: string): Promise<ContextIndexEntry[]>
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, contents: string): Promise<void>
+}
+
+const defaultIo: BookAnalysisContextIndexIo = {
+  createDirectory,
+  fileExists,
+  listDirectory: async (path) => listDirectory(path),
+  readFile,
+  writeFileAtomic,
+}
+
+function normalized(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+function indexPath(projectPath: string): string {
+  return normalized(joinPath(projectPath, ".qmai", "book-analysis-context.json"))
+}
+
+export function buildBookAnalysisContextIndex(
+  books: BookAnalysisContextBookInput[],
+  updatedAt = Date.now(),
+): BookAnalysisContextIndex {
+  return {
+    version: 1,
+    books: books.map((book) => ({
+      ...book,
+      modules: book.modules.filter((module) => module.summary.trim()),
+      evidence: book.evidence.filter((item) => item.enabled),
+    })),
+    updatedAt,
+  }
+}
+
+function normalizedText(value: string): string {
+  return value.toLocaleLowerCase().replace(/\s+/g, "")
+}
+
+function evidenceScore(query: string, item: AnalysisEvidenceSnippet): number {
+  let score = 0
+  for (const tag of item.tags) {
+    if (query.includes(normalizedText(tag))) score += 3
+  }
+  if (item.purpose && query.includes(normalizedText(item.purpose))) score += 2
+  if (item.reason && query.includes(normalizedText(item.reason))) score += 2
+  if (item.text && query.includes(normalizedText(item.text))) score += 1
+  return score
+}
+
+const SKILL_QUERY_TERMS: Record<AnalysisSkill, string[]> = {
+  characters: ["角色", "人物", "人设", "动机", "成长弧", "说话方式"],
+  story: ["故事", "剧情", "情节", "框架", "爽点", "钩子", "铺垫"],
+  style: ["文风", "风格", "幽默", "热血", "高燃", "句式", "词汇", "叙事视角"],
+}
+
+export function searchBookAnalysisContextIndex(
+  index: BookAnalysisContextIndex,
+  task: string,
+  maxEvidence = 8,
+): string {
+  const query = normalizedText(task)
+  if (!query) return ""
+  const sections: Array<{ score: number; text: string }> = []
+
+  for (const book of index.books) {
+    const titleScore = query.includes(normalizedText(book.title)) ? 4 : 0
+    const moduleMatches = book.modules.filter((module) => (
+      titleScore > 0 || SKILL_QUERY_TERMS[module.skill].some((term) => query.includes(normalizedText(term)))
+    ))
+    const scoredEvidence = book.evidence
+      .map((item) => ({ item, score: evidenceScore(query, item) + titleScore }))
+      .filter((entry) => entry.score > 0)
+      .sort((left, right) => right.score - left.score)
+      .slice(0, maxEvidence)
+    const score = titleScore + moduleMatches.length * 2 + scoredEvidence.reduce((sum, entry) => sum + entry.score, 0)
+    if (score === 0) continue
+
+    const lines = [`拆书作品:《${book.title}》`]
+    for (const module of moduleMatches) {
+      lines.push(`${module.skill} 分析(第 ${module.range.startOrder}~${module.range.endOrder} 章):${module.summary}`)
+    }
+    for (const { item } of scoredEvidence) {
+      lines.push(
+        `证据 · 第 ${item.chapterOrder} 章 · ${item.tags.join("、") || item.skill}:${item.text}`,
+        `用途:${item.purpose};保存原因:${item.reason}`,
+      )
+    }
+    sections.push({ score, text: lines.join("\n") })
+  }
+
+  return sections
+    .sort((left, right) => right.score - left.score)
+    .map((section) => section.text)
+    .join("\n\n")
+}
+
+async function readJson<T>(path: string, io: BookAnalysisContextIndexIo): Promise<T | null> {
+  if (!(await io.fileExists(path))) return null
+  try {
+    return JSON.parse(await io.readFile(path)) as T
+  } catch {
+    return null
+  }
+}
+
+export async function rebuildBookAnalysisContextIndex(
+  projectPath: string,
+  io: BookAnalysisContextIndexIo = defaultIo,
+): Promise<BookAnalysisContextIndex> {
+  const booksRoot = normalized(joinPath(projectPath, "book-analysis"))
+  const books: BookAnalysisContextBookInput[] = []
+  if (await io.fileExists(booksRoot)) {
+    for (const entry of await io.listDirectory(booksRoot)) {
+      if (!entry.is_dir || !entry.name.startsWith("book-")) continue
+      const metadata = await readJson<{ title?: string }>(joinPath(entry.path, "metadata.json"), io)
+      if (!metadata?.title) continue
+      const manifest = await readJson<BookAnalysisModuleManifest>(joinPath(entry.path, "analysis", "manifest.json"), io)
+      const evidence = await readJson<{ snippets?: AnalysisEvidenceSnippet[] }>(joinPath(entry.path, "analysis", "evidence.json"), io)
+      const modules = Object.values(manifest?.modules ?? {})
+        .filter((module): module is NonNullable<typeof module> => Boolean(module?.summary?.trim()))
+        .map((module) => ({
+          skill: module.skill,
+          summary: module.summary ?? "",
+          range: module.range,
+          updatedAt: module.updatedAt,
+        }))
+      books.push({
+        bookId: entry.name,
+        title: metadata.title,
+        modules,
+        evidence: Array.isArray(evidence?.snippets) ? evidence.snippets : [],
+      })
+    }
+  }
+
+  const index = buildBookAnalysisContextIndex(books)
+  await io.createDirectory(normalized(joinPath(projectPath, ".qmai")))
+  await io.writeFileAtomic(indexPath(projectPath), JSON.stringify(index, null, 2))
+  return index
+}
+
+export async function loadBookAnalysisContextIndex(
+  projectPath: string,
+  io: BookAnalysisContextIndexIo = defaultIo,
+): Promise<BookAnalysisContextIndex> {
+  return (await readJson<BookAnalysisContextIndex>(indexPath(projectPath), io))
+    ?? buildBookAnalysisContextIndex([], 0)
+}
+
+export async function searchBookAnalysisContext(
+  projectPath: string,
+  task: string,
+  io: BookAnalysisContextIndexIo = defaultIo,
+): Promise<string> {
+  return searchBookAnalysisContextIndex(await loadBookAnalysisContextIndex(projectPath, io), task)
+}

+ 88 - 0
src/lib/novel/book-analysis/analysis-evidence-store.spec.ts

@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest"
+import type { AnalysisEvidenceSnippet } from "./analysis-pipeline-types"
+import {
+  loadEvidence,
+  mergeEvidence,
+  replaceAutomaticEvidence,
+  setEvidenceEnabled,
+  type AnalysisEvidenceStoreIo,
+} from "./analysis-evidence-store"
+
+function evidence(id: string, overrides: Partial<AnalysisEvidenceSnippet> = {}): AnalysisEvidenceSnippet {
+  return {
+    version: 1,
+    id,
+    bookId: "book-1",
+    skill: "style",
+    taskId: "analysis-1",
+    chapterId: "ch-0008",
+    chapterOrder: 8,
+    text: "一本正经地补了一句,屋里反而更安静了。",
+    tags: ["幽默对白"],
+    reason: "严肃语气与结果形成反差",
+    purpose: "学习幽默对白节奏",
+    enabled: true,
+    userPinned: false,
+    createdAt: 1,
+    updatedAt: 1,
+    ...overrides,
+  }
+}
+
+function createMemoryIo() {
+  const files = new Map<string, string>()
+  const directories = new Set<string>()
+  const normalize = (path: string) => path.replace(/\\/g, "/").replace(/\/+$/, "")
+  const io: AnalysisEvidenceStoreIo = {
+    async createDirectory(path) { directories.add(normalize(path)) },
+    async fileExists(path) { return files.has(normalize(path)) || directories.has(normalize(path)) },
+    async readFile(path) {
+      const value = files.get(normalize(path))
+      if (value === undefined) throw new Error("文件不存在")
+      return value
+    },
+    async writeFileAtomic(path, contents) { files.set(normalize(path), contents) },
+  }
+  return { io, files }
+}
+
+describe("analysis evidence store", () => {
+  it("相同 Skill、章节和标准化文本只保留一条", async () => {
+    const memory = createMemoryIo()
+    const bookPath = "E:/Novel/book-analysis/book-1"
+
+    await mergeEvidence(bookPath, [
+      evidence("e1"),
+      evidence("e2", { text: "  一本正经地补了一句,屋里反而更安静了。  " }),
+    ], memory.io)
+
+    expect((await loadEvidence(bookPath, memory.io)).snippets).toHaveLength(1)
+  })
+
+  it("重提当前 Skill 时保留其他 Skill 和用户固定片段", async () => {
+    const memory = createMemoryIo()
+    const bookPath = "E:/Novel/book-analysis/book-1"
+    await mergeEvidence(bookPath, [
+      evidence("old-style"),
+      evidence("pinned-style", { chapterId: "ch-0009", text: "用户固定片段", userPinned: true }),
+      evidence("character", { skill: "characters", chapterId: "ch-0001", text: "角色高光" }),
+    ], memory.io)
+
+    await replaceAutomaticEvidence(bookPath, "style", [
+      evidence("new-style", { chapterId: "ch-0010", text: "新文风片段" }),
+    ], memory.io)
+
+    const ids = (await loadEvidence(bookPath, memory.io)).snippets.map((item) => item.id)
+    expect(ids).toEqual(["pinned-style", "character", "new-style"])
+  })
+
+  it("可以禁用单个片段", async () => {
+    const memory = createMemoryIo()
+    const bookPath = "E:/Novel/book-analysis/book-1"
+    await mergeEvidence(bookPath, [evidence("e1")], memory.io)
+
+    await setEvidenceEnabled(bookPath, "e1", false, memory.io)
+
+    expect((await loadEvidence(bookPath, memory.io)).snippets[0].enabled).toBe(false)
+  })
+})

+ 213 - 0
src/lib/novel/book-analysis/analysis-evidence-store.ts

@@ -0,0 +1,213 @@
+import {
+  createDirectory,
+  fileExists,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import type {
+  AnalysisEvidenceCollection,
+  AnalysisEvidenceSnippet,
+  AnalysisSkill,
+} from "./analysis-pipeline-types"
+
+export interface AnalysisEvidenceStoreIo {
+  createDirectory(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, contents: string): Promise<void>
+}
+
+const defaultIo: AnalysisEvidenceStoreIo = {
+  createDirectory,
+  fileExists,
+  readFile,
+  writeFileAtomic,
+}
+
+function normalized(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+function evidencePath(bookPath: string): string {
+  return normalized(joinPath(bookPath, "analysis", "evidence.json"))
+}
+
+function analysisDir(bookPath: string): string {
+  return normalized(joinPath(bookPath, "analysis"))
+}
+
+function bookIdFromPath(bookPath: string): string {
+  return normalized(bookPath).split("/").pop() || "unknown-book"
+}
+
+function emptyCollection(bookPath: string): AnalysisEvidenceCollection {
+  return {
+    version: 1,
+    bookId: bookIdFromPath(bookPath),
+    snippets: [],
+    updatedAt: 0,
+  }
+}
+
+function isEvidenceCollection(value: unknown): value is AnalysisEvidenceCollection {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false
+  const candidate = value as Partial<AnalysisEvidenceCollection>
+  return candidate.version === 1
+    && typeof candidate.bookId === "string"
+    && Array.isArray(candidate.snippets)
+}
+
+function normalizedEvidenceText(text: string): string {
+  return text.trim().replace(/\s+/g, " ")
+}
+
+function evidenceKey(item: AnalysisEvidenceSnippet): string {
+  return [item.skill, item.chapterId, normalizedEvidenceText(item.text)].join("::")
+}
+
+async function saveEvidence(
+  bookPath: string,
+  collection: AnalysisEvidenceCollection,
+  io: AnalysisEvidenceStoreIo,
+): Promise<AnalysisEvidenceCollection> {
+  await io.createDirectory(analysisDir(bookPath))
+  await io.writeFileAtomic(evidencePath(bookPath), JSON.stringify(collection, null, 2))
+  return collection
+}
+
+export async function loadEvidence(
+  bookPath: string,
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  const path = evidencePath(bookPath)
+  if (!(await io.fileExists(path))) return emptyCollection(bookPath)
+  try {
+    const parsed = JSON.parse(await io.readFile(path)) as unknown
+    return isEvidenceCollection(parsed) ? parsed : emptyCollection(bookPath)
+  } catch {
+    return emptyCollection(bookPath)
+  }
+}
+
+function mergeEvidenceItems(
+  current: AnalysisEvidenceSnippet[],
+  incoming: AnalysisEvidenceSnippet[],
+): AnalysisEvidenceSnippet[] {
+  const merged = [...current]
+  const indexes = new Map(merged.map((item, index) => [evidenceKey(item), index]))
+  for (const item of incoming) {
+    const normalizedItem = { ...item, text: normalizedEvidenceText(item.text) }
+    const key = evidenceKey(normalizedItem)
+    const existingIndex = indexes.get(key)
+    if (existingIndex === undefined) {
+      indexes.set(key, merged.length)
+      merged.push(normalizedItem)
+      continue
+    }
+    const existing = merged[existingIndex]
+    merged[existingIndex] = {
+      ...normalizedItem,
+      id: existing.id,
+      createdAt: existing.createdAt,
+      enabled: existing.enabled,
+      userPinned: existing.userPinned || normalizedItem.userPinned,
+    }
+  }
+  return merged
+}
+
+export async function mergeEvidence(
+  bookPath: string,
+  incoming: AnalysisEvidenceSnippet[],
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  const current = await loadEvidence(bookPath, io)
+  const updatedAt = Date.now()
+  return saveEvidence(bookPath, {
+    ...current,
+    snippets: mergeEvidenceItems(current.snippets, incoming),
+    updatedAt,
+  }, io)
+}
+
+export async function replaceAutomaticEvidence(
+  bookPath: string,
+  skill: AnalysisSkill,
+  incoming: AnalysisEvidenceSnippet[],
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  const current = await loadEvidence(bookPath, io)
+  const retained = current.snippets.filter((item) => item.skill !== skill || item.userPinned)
+  return saveEvidence(bookPath, {
+    ...current,
+    snippets: mergeEvidenceItems(retained, incoming.filter((item) => item.skill === skill)),
+    updatedAt: Date.now(),
+  }, io)
+}
+
+async function updateEvidenceItem(
+  bookPath: string,
+  evidenceId: string,
+  update: (item: AnalysisEvidenceSnippet) => AnalysisEvidenceSnippet | null,
+  io: AnalysisEvidenceStoreIo,
+): Promise<AnalysisEvidenceCollection> {
+  const current = await loadEvidence(bookPath, io)
+  let found = false
+  const snippets: AnalysisEvidenceSnippet[] = []
+  for (const item of current.snippets) {
+    if (item.id !== evidenceId) {
+      snippets.push(item)
+      continue
+    }
+    found = true
+    const next = update(item)
+    if (next) snippets.push(next)
+  }
+  if (!found) throw new Error("未找到证据片段")
+  return saveEvidence(bookPath, { ...current, snippets, updatedAt: Date.now() }, io)
+}
+
+export function setEvidenceEnabled(
+  bookPath: string,
+  evidenceId: string,
+  enabled: boolean,
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  return updateEvidenceItem(bookPath, evidenceId, (item) => ({
+    ...item,
+    enabled,
+    updatedAt: Date.now(),
+  }), io)
+}
+
+export function setEvidencePinned(
+  bookPath: string,
+  evidenceId: string,
+  userPinned: boolean,
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  return updateEvidenceItem(bookPath, evidenceId, (item) => ({
+    ...item,
+    userPinned,
+    updatedAt: Date.now(),
+  }), io)
+}
+
+export function deleteEvidence(
+  bookPath: string,
+  evidenceId: string,
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  return updateEvidenceItem(bookPath, evidenceId, () => null, io)
+}
+
+export async function deleteEvidenceForBook(
+  bookPath: string,
+  io: AnalysisEvidenceStoreIo = defaultIo,
+): Promise<AnalysisEvidenceCollection> {
+  return saveEvidence(bookPath, {
+    ...emptyCollection(bookPath),
+    updatedAt: Date.now(),
+  }, io)
+}

+ 164 - 0
src/lib/novel/book-analysis/analysis-pipeline-storage.spec.ts

@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest"
+import type {
+  AnalysisChunkRecord,
+  AnalysisModuleState,
+  AnalysisSkill,
+  BookAnalysisPipelineTask,
+} from "./analysis-pipeline-types"
+import {
+  loadAndRecoverAnalysisTasks,
+  saveAnalysisChunk,
+  saveAnalysisTask,
+  saveCompletedChunk,
+  type AnalysisPipelineStorageIo,
+} from "./analysis-pipeline-storage"
+
+function moduleState(skill: AnalysisSkill): AnalysisModuleState {
+  return {
+    skill,
+    status: skill === "characters" ? "running" : "pending",
+    range: { startOrder: 1, endOrder: 20 },
+    chunkIds: ["chunk-0001-0010", "chunk-0011-0020"],
+    completedChunkIds: ["chunk-0001-0010"],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 10,
+  }
+}
+
+function task(): BookAnalysisPipelineTask {
+  return {
+    version: 1,
+    id: "analysis-1",
+    batchId: null,
+    projectPath: "E:/Novel",
+    bookId: "book-1",
+    bookPath: "E:/Novel/book-analysis/book-1",
+    selectedSkills: ["characters", "story", "style"],
+    range: { startOrder: 1, endOrder: 20 },
+    status: "running",
+    currentSkill: "characters",
+    modules: {
+      characters: moduleState("characters"),
+      story: moduleState("story"),
+      style: moduleState("style"),
+    },
+    error: null,
+    createdAt: 1,
+    startedAt: 2,
+    completedAt: null,
+    updatedAt: 10,
+  }
+}
+
+function chunk(id: string, status: AnalysisChunkRecord["status"]): AnalysisChunkRecord {
+  const startOrder = id === "chunk-0001-0010" ? 1 : 11
+  const endOrder = startOrder + 9
+  return {
+    version: 1,
+    id,
+    taskId: "analysis-1",
+    skill: "characters",
+    chapterIds: Array.from({ length: 10 }, (_, index) => `ch-${startOrder + index}`),
+    startOrder,
+    endOrder,
+    wordCount: 20000,
+    status,
+    attempts: 1,
+    resultPath: status === "completed" ? `${id}.result.json` : null,
+    error: null,
+    startedAt: 3,
+    completedAt: status === "completed" ? 4 : null,
+    updatedAt: 4,
+  }
+}
+
+function createMemoryIo() {
+  const files = new Map<string, string>()
+  const directories = new Set<string>()
+  const writes: string[] = []
+  const normalize = (path: string) => path.replace(/\\/g, "/").replace(/\/+$/, "")
+
+  const io: AnalysisPipelineStorageIo = {
+    async createDirectory(path) {
+      const normalized = normalize(path)
+      const parts = normalized.split("/")
+      for (let index = 1; index <= parts.length; index += 1) {
+        directories.add(parts.slice(0, index).join("/"))
+      }
+    },
+    async fileExists(path) {
+      const normalized = normalize(path)
+      return files.has(normalized) || directories.has(normalized)
+    },
+    async listDirectory(path) {
+      const normalized = normalize(path)
+      const prefix = `${normalized}/`
+      const names = new Map<string, boolean>()
+      for (const directory of directories) {
+        if (!directory.startsWith(prefix)) continue
+        const rest = directory.slice(prefix.length)
+        if (rest && !rest.includes("/")) names.set(rest, true)
+      }
+      for (const file of files.keys()) {
+        if (!file.startsWith(prefix)) continue
+        const rest = file.slice(prefix.length)
+        if (rest && !rest.includes("/")) names.set(rest, false)
+      }
+      return [...names].map(([name, is_dir]) => ({ name, path: `${normalized}/${name}`, is_dir }))
+    },
+    async readFile(path) {
+      const value = files.get(normalize(path))
+      if (value === undefined) throw new Error("文件不存在")
+      return value
+    },
+    async writeFileAtomic(path, contents) {
+      const normalized = normalize(path)
+      files.set(normalized, contents)
+      writes.push(normalized)
+    },
+  }
+  return { io, files, writes }
+}
+
+describe("analysis pipeline storage", () => {
+  it("启动时把运行任务改为暂停并把运行区块恢复为等待", async () => {
+    const memory = createMemoryIo()
+    await saveAnalysisTask(task(), memory.io)
+    await saveCompletedChunk(
+      task().bookPath,
+      chunk("chunk-0001-0010", "running"),
+      { characters: [] },
+      memory.io,
+      50,
+    )
+    await saveAnalysisChunk(task().bookPath, chunk("chunk-0011-0020", "running"), memory.io)
+
+    const recovered = await loadAndRecoverAnalysisTasks("E:/Novel", memory.io)
+
+    expect(recovered.tasks).toHaveLength(1)
+    expect(recovered.tasks[0].status).toBe("paused")
+    expect(recovered.tasks[0].error).toBe("软件上次关闭时分析尚未完成")
+    expect(recovered.chunks.find((item) => item.id === "chunk-0001-0010")?.status).toBe("completed")
+    expect(recovered.chunks.find((item) => item.id === "chunk-0011-0020")?.status).toBe("pending")
+  })
+
+  it("完成区块时先写结果再写 completed 状态", async () => {
+    const memory = createMemoryIo()
+    const running = chunk("chunk-0011-0020", "running")
+
+    const completed = await saveCompletedChunk(
+      task().bookPath,
+      running,
+      { characters: [] },
+      memory.io,
+      100,
+    )
+
+    expect(completed.status).toBe("completed")
+    expect(completed.resultPath).toContain("chunk-0011-0020.result.json")
+    expect(memory.writes.at(-2)).toContain("chunk-0011-0020.result.json")
+    expect(memory.writes.at(-1)).toContain("chunk-0011-0020.json")
+  })
+})

+ 291 - 0
src/lib/novel/book-analysis/analysis-pipeline-storage.ts

@@ -0,0 +1,291 @@
+import {
+  createDirectory,
+  fileExists,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import {
+  ANALYSIS_SKILL_ORDER,
+  type AnalysisChunkRecord,
+  type AnalysisSkill,
+  type BookAnalysisModuleManifest,
+  type BookAnalysisPipelineTask,
+} from "./analysis-pipeline-types"
+
+const INTERRUPTED_ERROR = "软件上次关闭时分析尚未完成"
+const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]+$/
+
+export interface AnalysisStorageEntry {
+  name: string
+  path: string
+  is_dir: boolean
+}
+
+export interface AnalysisPipelineStorageIo {
+  createDirectory(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  listDirectory(path: string): Promise<AnalysisStorageEntry[]>
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, contents: string): Promise<void>
+}
+
+const defaultIo: AnalysisPipelineStorageIo = {
+  createDirectory,
+  fileExists,
+  listDirectory: async (path) => listDirectory(path),
+  readFile,
+  writeFileAtomic,
+}
+
+export interface RecoveredAnalysisState {
+  tasks: BookAnalysisPipelineTask[]
+  chunks: AnalysisChunkRecord[]
+}
+
+function assertSafeId(value: string, label: string): void {
+  if (!SAFE_ID_PATTERN.test(value)) throw new Error(`${label}不合法`)
+}
+
+function normalized(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+export function analysisRoot(bookPath: string): string {
+  return normalized(joinPath(bookPath, "analysis"))
+}
+
+function analysisTasksRoot(bookPath: string): string {
+  return normalized(joinPath(analysisRoot(bookPath), "tasks"))
+}
+
+function analysisTaskPath(bookPath: string, taskId: string): string {
+  assertSafeId(taskId, "分析任务 ID")
+  return normalized(joinPath(analysisTasksRoot(bookPath), `${taskId}.json`))
+}
+
+function analysisChunksRoot(bookPath: string, taskId: string): string {
+  assertSafeId(taskId, "分析任务 ID")
+  return normalized(joinPath(analysisRoot(bookPath), "chunks", taskId))
+}
+
+function analysisChunkDir(bookPath: string, taskId: string, skill: AnalysisSkill): string {
+  return normalized(joinPath(analysisChunksRoot(bookPath, taskId), skill))
+}
+
+function analysisChunkPath(bookPath: string, chunk: AnalysisChunkRecord): string {
+  assertSafeId(chunk.id, "分析区块 ID")
+  return normalized(joinPath(
+    analysisChunkDir(bookPath, chunk.taskId, chunk.skill),
+    `${chunk.id}.json`,
+  ))
+}
+
+function analysisChunkResultPath(bookPath: string, chunk: AnalysisChunkRecord): string {
+  assertSafeId(chunk.id, "分析区块 ID")
+  return normalized(joinPath(
+    analysisChunkDir(bookPath, chunk.taskId, chunk.skill),
+    `${chunk.id}.result.json`,
+  ))
+}
+
+async function ensureTaskDirectories(
+  bookPath: string,
+  taskId: string,
+  io: AnalysisPipelineStorageIo,
+): Promise<void> {
+  await io.createDirectory(analysisRoot(bookPath))
+  await io.createDirectory(analysisTasksRoot(bookPath))
+  await io.createDirectory(analysisChunksRoot(bookPath, taskId))
+}
+
+export async function saveAnalysisTask(
+  task: BookAnalysisPipelineTask,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<void> {
+  assertSafeId(task.id, "分析任务 ID")
+  assertSafeId(task.bookId, "作品 ID")
+  await ensureTaskDirectories(task.bookPath, task.id, io)
+  await io.writeFileAtomic(analysisTaskPath(task.bookPath, task.id), JSON.stringify(task, null, 2))
+}
+
+export async function saveAnalysisChunk(
+  bookPath: string,
+  chunk: AnalysisChunkRecord,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<void> {
+  await ensureTaskDirectories(bookPath, chunk.taskId, io)
+  await io.createDirectory(analysisChunkDir(bookPath, chunk.taskId, chunk.skill))
+  await io.writeFileAtomic(analysisChunkPath(bookPath, chunk), JSON.stringify(chunk, null, 2))
+}
+
+export async function saveCompletedChunk<T>(
+  bookPath: string,
+  chunk: AnalysisChunkRecord,
+  result: T,
+  io: AnalysisPipelineStorageIo = defaultIo,
+  completedAt = Date.now(),
+): Promise<AnalysisChunkRecord> {
+  await ensureTaskDirectories(bookPath, chunk.taskId, io)
+  await io.createDirectory(analysisChunkDir(bookPath, chunk.taskId, chunk.skill))
+  const resultPath = analysisChunkResultPath(bookPath, chunk)
+  await io.writeFileAtomic(resultPath, JSON.stringify(result, null, 2))
+  const completed: AnalysisChunkRecord = {
+    ...chunk,
+    status: "completed",
+    resultPath,
+    error: null,
+    completedAt,
+    updatedAt: completedAt,
+  }
+  await io.writeFileAtomic(analysisChunkPath(bookPath, completed), JSON.stringify(completed, null, 2))
+  return completed
+}
+
+export async function loadAnalysisChunkResult<T>(
+  chunk: AnalysisChunkRecord,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<T | null> {
+  if (!chunk.resultPath || !(await io.fileExists(chunk.resultPath))) return null
+  try {
+    return JSON.parse(await io.readFile(chunk.resultPath)) as T
+  } catch {
+    return null
+  }
+}
+
+export async function loadAnalysisManifest(
+  bookPath: string,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<BookAnalysisModuleManifest | null> {
+  const path = normalized(joinPath(analysisRoot(bookPath), "manifest.json"))
+  if (!(await io.fileExists(path))) return null
+  try {
+    return JSON.parse(await io.readFile(path)) as BookAnalysisModuleManifest
+  } catch {
+    return null
+  }
+}
+
+export async function saveAnalysisManifest(
+  bookPath: string,
+  manifest: BookAnalysisModuleManifest,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<void> {
+  await io.createDirectory(analysisRoot(bookPath))
+  const path = normalized(joinPath(analysisRoot(bookPath), "manifest.json"))
+  await io.writeFileAtomic(path, JSON.stringify(manifest, null, 2))
+}
+
+function isPipelineTask(value: unknown): value is BookAnalysisPipelineTask {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false
+  const candidate = value as Partial<BookAnalysisPipelineTask>
+  return candidate.version === 1
+    && typeof candidate.id === "string"
+    && typeof candidate.bookId === "string"
+    && typeof candidate.bookPath === "string"
+    && Array.isArray(candidate.selectedSkills)
+    && typeof candidate.status === "string"
+    && typeof candidate.modules === "object"
+}
+
+function isChunkRecord(value: unknown): value is AnalysisChunkRecord {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false
+  const candidate = value as Partial<AnalysisChunkRecord>
+  return candidate.version === 1
+    && typeof candidate.id === "string"
+    && typeof candidate.taskId === "string"
+    && typeof candidate.skill === "string"
+    && Array.isArray(candidate.chapterIds)
+    && typeof candidate.status === "string"
+}
+
+async function loadTaskChunks(
+  task: BookAnalysisPipelineTask,
+  io: AnalysisPipelineStorageIo,
+): Promise<AnalysisChunkRecord[]> {
+  const taskRoot = analysisChunksRoot(task.bookPath, task.id)
+  if (!(await io.fileExists(taskRoot))) return []
+  const chunks: AnalysisChunkRecord[] = []
+
+  for (const skill of ANALYSIS_SKILL_ORDER) {
+    const skillRoot = analysisChunkDir(task.bookPath, task.id, skill)
+    if (!(await io.fileExists(skillRoot))) continue
+    for (const entry of await io.listDirectory(skillRoot)) {
+      if (entry.is_dir || !entry.name.endsWith(".json") || entry.name.endsWith(".result.json")) continue
+      try {
+        const parsed = JSON.parse(await io.readFile(entry.path)) as unknown
+        if (!isChunkRecord(parsed) || parsed.taskId !== task.id || parsed.skill !== skill) continue
+        chunks.push(parsed)
+      } catch {
+        // 损坏区块不能作为已完成结果恢复。
+      }
+    }
+  }
+  return chunks.sort((left, right) => left.startOrder - right.startOrder)
+}
+
+async function recoverChunk(
+  bookPath: string,
+  chunk: AnalysisChunkRecord,
+  io: AnalysisPipelineStorageIo,
+): Promise<AnalysisChunkRecord> {
+  const missingResult = chunk.status === "completed"
+    && (!chunk.resultPath || !(await io.fileExists(chunk.resultPath)))
+  if (chunk.status !== "running" && !missingResult) return chunk
+
+  const recovered: AnalysisChunkRecord = {
+    ...chunk,
+    status: "pending",
+    resultPath: missingResult ? null : chunk.resultPath,
+    error: null,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: Date.now(),
+  }
+  await saveAnalysisChunk(bookPath, recovered, io)
+  return recovered
+}
+
+export async function loadAndRecoverAnalysisTasks(
+  projectPath: string,
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<RecoveredAnalysisState> {
+  const booksRoot = normalized(joinPath(projectPath, "book-analysis"))
+  if (!(await io.fileExists(booksRoot))) return { tasks: [], chunks: [] }
+
+  const tasks: BookAnalysisPipelineTask[] = []
+  const chunks: AnalysisChunkRecord[] = []
+  for (const bookEntry of await io.listDirectory(booksRoot)) {
+    if (!bookEntry.is_dir || !bookEntry.name.startsWith("book-")) continue
+    const taskRoot = analysisTasksRoot(bookEntry.path)
+    if (!(await io.fileExists(taskRoot))) continue
+
+    for (const taskEntry of await io.listDirectory(taskRoot)) {
+      if (taskEntry.is_dir || !taskEntry.name.endsWith(".json")) continue
+      try {
+        const parsed = JSON.parse(await io.readFile(taskEntry.path)) as unknown
+        if (!isPipelineTask(parsed) || normalized(parsed.bookPath) !== normalized(bookEntry.path)) continue
+        const recoveredTask: BookAnalysisPipelineTask = parsed.status === "running"
+          ? {
+              ...parsed,
+              status: "paused",
+              error: INTERRUPTED_ERROR,
+              updatedAt: Date.now(),
+            }
+          : parsed
+        if (recoveredTask !== parsed) await saveAnalysisTask(recoveredTask, io)
+        const recoveredChunks = await Promise.all(
+          (await loadTaskChunks(recoveredTask, io)).map((chunk) => recoverChunk(recoveredTask.bookPath, chunk, io)),
+        )
+        tasks.push(recoveredTask)
+        chunks.push(...recoveredChunks)
+      } catch {
+        // 单个损坏任务不阻止其他作品恢复。
+      }
+    }
+  }
+  tasks.sort((left, right) => left.createdAt - right.createdAt)
+  return { tasks, chunks }
+}

+ 40 - 0
src/lib/novel/book-analysis/analysis-pipeline-types.spec.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest"
+import {
+  ANALYSIS_SKILL_ORDER,
+  normalizeSelectedSkills,
+  type AnalysisEvidenceSnippet,
+} from "./analysis-pipeline-types"
+
+describe("analysis pipeline types", () => {
+  it("只保留用户选择的 Skill 并按固定依赖顺序排列", () => {
+    expect(normalizeSelectedSkills(["style", "characters", "style"])).toEqual([
+      "characters",
+      "style",
+    ])
+    expect(ANALYSIS_SKILL_ORDER).toEqual(["characters", "story", "style"])
+  })
+
+  it("证据片段携带来源章节、保存原因和分析用途", () => {
+    const evidence: AnalysisEvidenceSnippet = {
+      version: 1,
+      id: "evidence-1",
+      bookId: "book-1",
+      skill: "style",
+      taskId: "analysis-1",
+      chapterId: "ch-0008",
+      chapterOrder: 8,
+      text: "他停了一下,认真补充了一句。",
+      tags: ["幽默对白"],
+      reason: "通过一本正经的补充制造反差",
+      purpose: "学习幽默对白节奏",
+      enabled: true,
+      userPinned: false,
+      createdAt: 1,
+      updatedAt: 1,
+    }
+
+    expect(evidence.chapterOrder).toBe(8)
+    expect(evidence.reason).toContain("反差")
+    expect(evidence.purpose).toContain("对白")
+  })
+})

+ 132 - 0
src/lib/novel/book-analysis/analysis-pipeline-types.ts

@@ -0,0 +1,132 @@
+export const ANALYSIS_SKILL_ORDER = ["characters", "story", "style"] as const
+
+export type AnalysisSkill = (typeof ANALYSIS_SKILL_ORDER)[number]
+
+export type AnalysisTaskStatus =
+  | "awaiting-range"
+  | "queued"
+  | "running"
+  | "paused"
+  | "failed"
+  | "cancelled"
+  | "completed"
+
+export type AnalysisChunkStatus =
+  | "pending"
+  | "running"
+  | "completed"
+  | "failed"
+  | "skipped"
+  | "cancelled"
+
+export type AnalysisSkillStatus =
+  | "pending"
+  | "running"
+  | "completed"
+  | "failed"
+  | "skipped"
+
+export interface AnalysisChapterRange {
+  startOrder: number
+  endOrder: number
+}
+
+export interface AnalysisChunkPlan {
+  id: string
+  chapterIds: string[]
+  startOrder: number
+  endOrder: number
+  wordCount: number
+}
+
+export interface AnalysisChunkRecord extends AnalysisChunkPlan {
+  version: 1
+  taskId: string
+  skill: AnalysisSkill
+  status: AnalysisChunkStatus
+  attempts: number
+  resultPath: string | null
+  error: string | null
+  startedAt: number | null
+  completedAt: number | null
+  updatedAt: number
+}
+
+export interface AnalysisModuleState {
+  skill: AnalysisSkill
+  status: AnalysisSkillStatus
+  range: AnalysisChapterRange
+  chunkIds: string[]
+  completedChunkIds: string[]
+  failedChunkId: string | null
+  resultPath: string | null
+  summary?: string
+  analysisVersion: number
+  updatedAt: number
+}
+
+export interface BookAnalysisPipelineTask {
+  version: 1
+  id: string
+  batchId: string | null
+  projectPath: string
+  bookId: string
+  bookPath: string
+  selectedSkills: AnalysisSkill[]
+  range: AnalysisChapterRange | null
+  status: AnalysisTaskStatus
+  currentSkill: AnalysisSkill | null
+  modules: Record<AnalysisSkill, AnalysisModuleState>
+  error: string | null
+  createdAt: number
+  startedAt: number | null
+  completedAt: number | null
+  updatedAt: number
+}
+
+export interface AnalysisPipelineBatch {
+  version: 1
+  id: string
+  projectPath: string
+  taskIds: string[]
+  createdAt: number
+  updatedAt: number
+  dismissedAt: number | null
+}
+
+export interface AnalysisEvidenceSnippet {
+  version: 1
+  id: string
+  bookId: string
+  skill: AnalysisSkill
+  taskId: string
+  chapterId: string
+  chapterOrder: number
+  text: string
+  tags: string[]
+  reason: string
+  purpose: string
+  enabled: boolean
+  userPinned: boolean
+  createdAt: number
+  updatedAt: number
+}
+
+export interface AnalysisEvidenceCollection {
+  version: 1
+  bookId: string
+  snippets: AnalysisEvidenceSnippet[]
+  updatedAt: number
+}
+
+export interface BookAnalysisModuleManifest {
+  version: 1
+  bookId: string
+  modules: Partial<Record<AnalysisSkill, AnalysisModuleState>>
+  updatedAt: number
+}
+
+export function normalizeSelectedSkills(skills: AnalysisSkill[]): AnalysisSkill[] {
+  const selected = new Set(skills)
+  return ANALYSIS_SKILL_ORDER.filter((skill) => selected.has(skill))
+}

+ 245 - 0
src/lib/novel/book-analysis/analysis-scheduler.spec.ts

@@ -0,0 +1,245 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  AnalysisChunkRecord,
+  AnalysisModuleState,
+  AnalysisSkill,
+  BookAnalysisPipelineTask,
+} from "./analysis-pipeline-types"
+import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
+import { createAnalysisScheduler } from "./analysis-scheduler"
+
+function moduleState(skill: AnalysisSkill, chunkIds: string[]): AnalysisModuleState {
+  return {
+    skill,
+    status: "pending",
+    range: { startOrder: 1, endOrder: chunkIds.length * 10 },
+    chunkIds,
+    completedChunkIds: [],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 1,
+  }
+}
+
+function task(selectedSkills: AnalysisSkill[], chunkIds = ["chunk-1", "chunk-2"]): BookAnalysisPipelineTask {
+  return {
+    version: 1,
+    id: "task-1",
+    batchId: null,
+    projectPath: "E:/Novel",
+    bookId: "book-1",
+    bookPath: "E:/Novel/book-analysis/book-1",
+    selectedSkills,
+    range: { startOrder: 1, endOrder: chunkIds.length * 10 },
+    status: "queued",
+    currentSkill: null,
+    modules: {
+      characters: moduleState("characters", chunkIds),
+      story: moduleState("story", chunkIds),
+      style: moduleState("style", chunkIds),
+    },
+    error: null,
+    createdAt: 1,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 1,
+  }
+}
+
+function chunks(skills: AnalysisSkill[], count = 2): AnalysisChunkRecord[] {
+  return skills.flatMap((skill) => Array.from({ length: count }, (_, index) => ({
+    version: 1 as const,
+    id: `chunk-${index + 1}`,
+    taskId: "task-1",
+    skill,
+    chapterIds: [`ch-${index * 10 + 1}`],
+    startOrder: index * 10 + 1,
+    endOrder: index * 10 + 10,
+    wordCount: 1000,
+    status: "pending" as const,
+    attempts: 0,
+    resultPath: null,
+    error: null,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 1,
+  })))
+}
+
+function createHarness(options: {
+  onRun?: (skill: AnalysisSkill, chunkId: string, signal: AbortSignal) => Promise<void>
+} = {}) {
+  const calls: string[] = []
+  let running = 0
+  let maxRunning = 0
+  const adapters = Object.fromEntries(["characters", "story", "style"].map((skill) => [
+    skill,
+    {
+      skill,
+      async runChunk({ chunk, signal }) {
+        calls.push(`${skill}:${chunk.id}:start`)
+        running += 1
+        maxRunning = Math.max(maxRunning, running)
+        await options.onRun?.(skill as AnalysisSkill, chunk.id, signal)
+        running -= 1
+        calls.push(`${skill}:${chunk.id}:done`)
+        return { result: { skill, chunkId: chunk.id }, evidence: [] }
+      },
+      async aggregate() {
+        calls.push(`${skill}:aggregate`)
+        return { skill }
+      },
+      async publish() {
+        calls.push(`${skill}:publish`)
+        return `${skill}.json`
+      },
+    } satisfies AnalysisSkillAdapter,
+  ])) as Record<AnalysisSkill, AnalysisSkillAdapter>
+  const savedResults = new Map<string, unknown>()
+  const scheduler = createAnalysisScheduler({
+    adapters,
+    llmConfig: {} as LlmConfig,
+    saveTask: vi.fn(async () => {}),
+    saveChunk: vi.fn(async () => {}),
+    saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => {
+      const resultPath = `${chunk.skill}-${chunk.id}.result.json`
+      savedResults.set(resultPath, result)
+      return { ...chunk, status: "completed", resultPath, completedAt: 10, updatedAt: 10 }
+    }),
+    loadChunkResult: vi.fn(async (chunk) => chunk.resultPath ? savedResults.get(chunk.resultPath) ?? null : null),
+    now: () => 10,
+  })
+  return { scheduler, calls, getMaxRunning: () => maxRunning }
+}
+
+describe("analysis scheduler", () => {
+  it("只执行所选 Skill 并按角色、故事、文风串行", async () => {
+    const harness = createHarness()
+    harness.scheduler.initialize([task(["style", "characters"])], chunks(["characters", "style"]))
+
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+
+    expect(harness.calls).not.toContain(expect.stringContaining("story"))
+    expect(harness.calls.indexOf("characters:publish")).toBeLessThan(harness.calls.indexOf("style:chunk-1:start"))
+    expect(harness.calls.at(-1)).toBe("style:publish")
+  })
+
+  it("同一 Skill 的区块并发最多为 2", async () => {
+    const harness = createHarness({ onRun: async () => new Promise((resolve) => setTimeout(resolve, 1)) })
+    harness.scheduler.initialize([task(["characters"], ["chunk-1", "chunk-2", "chunk-3"])], chunks(["characters"], 3))
+
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+
+    expect(harness.getMaxRunning()).toBe(2)
+  })
+
+  it("继续任务时跳过已完成区块", async () => {
+    const harness = createHarness()
+    const taskValue = task(["characters"], ["chunk-1", "chunk-2"])
+    const chunkValues = chunks(["characters"])
+    chunkValues[0] = { ...chunkValues[0], status: "completed", resultPath: "existing.json" }
+    taskValue.modules.characters.completedChunkIds = ["chunk-1"]
+    harness.scheduler.initialize([taskValue], chunkValues)
+
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+
+    expect(harness.calls).not.toContain("characters:chunk-1:start")
+    expect(harness.calls).toContain("characters:chunk-2:start")
+  })
+
+  it("暂停后不再派发新的区块并可继续", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const harness = createHarness({ onRun: async () => gate })
+    harness.scheduler.initialize([task(["characters"], ["chunk-1", "chunk-2", "chunk-3"])], chunks(["characters"], 3))
+
+    const continuing = harness.scheduler.continueTask("task-1")
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    const pausing = harness.scheduler.pauseTask("task-1")
+    release()
+    await Promise.all([continuing, pausing])
+
+    expect(harness.calls).not.toContain("characters:chunk-3:start")
+    expect(harness.scheduler.getSnapshot().tasks[0].status).toBe("paused")
+
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+    expect(harness.calls).toContain("characters:chunk-3:start")
+  })
+
+  it("同批区块失败时等待其他已开始区块完成,继续后只重试失败区块", async () => {
+    let failedOnce = false
+    const harness = createHarness({
+      onRun: async (_skill, chunkId) => {
+        if (chunkId === "chunk-1" && !failedOnce) {
+          failedOnce = true
+          throw new Error("模拟区块失败")
+        }
+        await new Promise((resolve) => setTimeout(resolve, 1))
+      },
+    })
+    harness.scheduler.initialize([task(["characters"])], chunks(["characters"]))
+
+    await harness.scheduler.continueTask("task-1")
+    expect(harness.scheduler.getSnapshot().tasks[0].status).toBe("failed")
+    expect(harness.scheduler.getSnapshot().chunks.find((chunk) => chunk.id === "chunk-2")?.status).toBe("completed")
+
+    await harness.scheduler.continueTask("task-1")
+    expect(harness.calls.filter((call) => call === "characters:chunk-1:start")).toHaveLength(2)
+    expect(harness.calls.filter((call) => call === "characters:chunk-2:start")).toHaveLength(1)
+  })
+
+  it("取消聚合时中止聚合请求并把任务标记为已取消", async () => {
+    let aggregateSignal: AbortSignal | null = null
+    let notifyAggregateStarted!: () => void
+    const aggregateStarted = new Promise<void>((resolve) => { notifyAggregateStarted = resolve })
+    const adapters = Object.fromEntries(["characters", "story", "style"].map((skill) => [
+      skill,
+      {
+        skill,
+        async runChunk({ chunk }) {
+          return { result: { chunkId: chunk.id }, evidence: [] }
+        },
+        async aggregate({ signal }) {
+          aggregateSignal = signal
+          notifyAggregateStarted()
+          await new Promise<void>((_resolve, reject) => {
+            signal.addEventListener("abort", () => reject(new Error("已取消")), { once: true })
+          })
+          return {}
+        },
+        async publish() {
+          return `${skill}.json`
+        },
+      } satisfies AnalysisSkillAdapter,
+    ])) as Record<AnalysisSkill, AnalysisSkillAdapter>
+    const savedResults = new Map<string, unknown>()
+    const scheduler = createAnalysisScheduler({
+      adapters,
+      llmConfig: {} as LlmConfig,
+      saveTask: vi.fn(async () => {}),
+      saveChunk: vi.fn(async () => {}),
+      saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => {
+        const resultPath = `${chunk.skill}-${chunk.id}.result.json`
+        savedResults.set(resultPath, result)
+        return { ...chunk, status: "completed", resultPath, completedAt: 10, updatedAt: 10 }
+      }),
+      loadChunkResult: vi.fn(async (chunk) => chunk.resultPath ? savedResults.get(chunk.resultPath) ?? null : null),
+      now: () => 10,
+    })
+    scheduler.initialize([task(["characters"], ["chunk-1"])], chunks(["characters"], 1))
+
+    const running = scheduler.continueTask("task-1")
+    await aggregateStarted
+    await scheduler.cancelTask("task-1")
+    await running
+
+    expect(aggregateSignal?.aborted).toBe(true)
+    expect(scheduler.getSnapshot().tasks[0].status).toBe("cancelled")
+  })
+})

+ 461 - 0
src/lib/novel/book-analysis/analysis-scheduler.ts

@@ -0,0 +1,461 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import {
+  loadAnalysisChunkResult,
+  saveAnalysisChunk,
+  saveAnalysisTask,
+  saveCompletedChunk,
+} from "./analysis-pipeline-storage"
+import {
+  ANALYSIS_SKILL_ORDER,
+  normalizeSelectedSkills,
+  type AnalysisChunkRecord,
+  type AnalysisEvidenceSnippet,
+  type AnalysisModuleState,
+  type AnalysisSkill,
+  type BookAnalysisPipelineTask,
+} from "./analysis-pipeline-types"
+import type {
+  AnalysisChunkOutput,
+  AnalysisSkillAdapter,
+  AnalysisSkillContext,
+} from "./analysis-skill-adapter"
+
+export interface AnalysisSchedulerSnapshot {
+  tasks: BookAnalysisPipelineTask[]
+  chunks: AnalysisChunkRecord[]
+}
+
+export interface AnalysisScheduler {
+  initialize(tasks: BookAnalysisPipelineTask[], chunks: AnalysisChunkRecord[]): void
+  enqueue(task: BookAnalysisPipelineTask, chunks: AnalysisChunkRecord[]): Promise<void>
+  pauseTask(taskId: string): Promise<void>
+  continueTask(taskId: string): Promise<void>
+  retryFailedChunk(taskId: string, skill: AnalysisSkill, chunkId: string): Promise<void>
+  cancelTask(taskId: string): Promise<void>
+  getSnapshot(): AnalysisSchedulerSnapshot
+  subscribe(listener: (snapshot: AnalysisSchedulerSnapshot) => void): () => void
+  whenIdle(): Promise<void>
+  dispose(): Promise<void>
+}
+
+interface AnalysisSchedulerOptions {
+  adapters: Record<AnalysisSkill, AnalysisSkillAdapter>
+  llmConfig: LlmConfig | (() => LlmConfig)
+  concurrency?: number
+  saveTask?: typeof saveAnalysisTask
+  saveChunk?: typeof saveAnalysisChunk
+  saveCompletedChunk?: typeof saveCompletedChunk
+  loadChunkResult?: typeof loadAnalysisChunkResult
+  now?: () => number
+}
+
+function chunkKey(chunk: Pick<AnalysisChunkRecord, "taskId" | "skill" | "id">): string {
+  return `${chunk.taskId}:${chunk.skill}:${chunk.id}`
+}
+
+function copyTask(task: BookAnalysisPipelineTask): BookAnalysisPipelineTask {
+  return {
+    ...task,
+    selectedSkills: [...task.selectedSkills],
+    range: task.range ? { ...task.range } : null,
+    modules: Object.fromEntries(ANALYSIS_SKILL_ORDER.map((skill) => [skill, {
+      ...task.modules[skill],
+      range: { ...task.modules[skill].range },
+      chunkIds: [...task.modules[skill].chunkIds],
+      completedChunkIds: [...task.modules[skill].completedChunkIds],
+    }])) as BookAnalysisPipelineTask["modules"],
+  }
+}
+
+function copyChunk(chunk: AnalysisChunkRecord): AnalysisChunkRecord {
+  return { ...chunk, chapterIds: [...chunk.chapterIds] }
+}
+
+export function createAnalysisScheduler(options: AnalysisSchedulerOptions): AnalysisScheduler {
+  const concurrency = Math.max(1, Math.min(2, Math.floor(options.concurrency ?? 2)))
+  const persistTask = options.saveTask ?? saveAnalysisTask
+  const persistChunk = options.saveChunk ?? saveAnalysisChunk
+  const persistCompletedChunk = options.saveCompletedChunk ?? saveCompletedChunk
+  const readChunkResult = options.loadChunkResult ?? loadAnalysisChunkResult
+  const now = options.now ?? Date.now
+  const tasks = new Map<string, BookAnalysisPipelineTask>()
+  const chunks = new Map<string, AnalysisChunkRecord>()
+  const taskRuns = new Map<string, Promise<void>>()
+  const chunkRuns = new Map<string, Promise<void>>()
+  const controllers = new Map<string, AbortController>()
+  const pauseRequested = new Set<string>()
+  const cancelRequested = new Set<string>()
+  const listeners = new Set<(snapshot: AnalysisSchedulerSnapshot) => void>()
+  let disposed = false
+  let activeChunks = 0
+  const permitWaiters: Array<() => void> = []
+
+  function snapshot(): AnalysisSchedulerSnapshot {
+    return {
+      tasks: [...tasks.values()].map(copyTask),
+      chunks: [...chunks.values()].map(copyChunk),
+    }
+  }
+
+  function notify(): void {
+    if (disposed) return
+    const value = snapshot()
+    for (const listener of listeners) listener(value)
+  }
+
+  function resolveLlmConfig(): LlmConfig {
+    return typeof options.llmConfig === "function" ? options.llmConfig() : options.llmConfig
+  }
+
+  async function acquirePermit(): Promise<void> {
+    if (activeChunks < concurrency) {
+      activeChunks += 1
+      return
+    }
+    await new Promise<void>((resolve) => permitWaiters.push(resolve))
+    activeChunks += 1
+  }
+
+  function releasePermit(): void {
+    activeChunks = Math.max(0, activeChunks - 1)
+    permitWaiters.shift()?.()
+  }
+
+  async function updateTask(task: BookAnalysisPipelineTask): Promise<void> {
+    tasks.set(task.id, task)
+    await persistTask(task)
+    notify()
+  }
+
+  function contextFor(task: BookAnalysisPipelineTask, skill: AnalysisSkill): AnalysisSkillContext {
+    return {
+      task: copyTask(task),
+      skill,
+      bookPath: task.bookPath,
+      projectPath: task.projectPath,
+      llmConfig: resolveLlmConfig(),
+    }
+  }
+
+  async function runChunk(task: BookAnalysisPipelineTask, skill: AnalysisSkill, chunk: AnalysisChunkRecord): Promise<void> {
+    const key = chunkKey(chunk)
+    const existing = chunkRuns.get(key)
+    if (existing) return existing
+
+    const operation = (async () => {
+      await acquirePermit()
+      if (disposed || pauseRequested.has(task.id) || cancelRequested.has(task.id)) {
+        releasePermit()
+        return
+      }
+      const controller = new AbortController()
+      controllers.set(key, controller)
+      const startedAt = now()
+      const running: AnalysisChunkRecord = {
+        ...chunk,
+        status: "running",
+        attempts: chunk.attempts + 1,
+        error: null,
+        startedAt,
+        completedAt: null,
+        updatedAt: startedAt,
+      }
+      chunks.set(key, running)
+      await persistChunk(task.bookPath, running)
+      notify()
+
+      try {
+        const output = await options.adapters[skill].runChunk({
+          ...contextFor(task, skill),
+          chunk: copyChunk(running),
+          signal: controller.signal,
+        })
+        if (controller.signal.aborted) throw new Error("分析任务已取消")
+        const completed = await persistCompletedChunk(task.bookPath, running, output)
+        chunks.set(key, completed)
+        notify()
+      } catch (error) {
+        const failedAt = now()
+        const aborted = controller.signal.aborted || cancelRequested.has(task.id)
+        const failed: AnalysisChunkRecord = {
+          ...running,
+          status: aborted ? "cancelled" : "failed",
+          error: aborted ? "用户取消分析" : (error instanceof Error ? error.message : "章节区块分析失败"),
+          completedAt: null,
+          updatedAt: failedAt,
+        }
+        chunks.set(key, failed)
+        await persistChunk(task.bookPath, failed)
+        notify()
+        throw error
+      } finally {
+        controllers.delete(key)
+        releasePermit()
+      }
+    })().finally(() => chunkRuns.delete(key))
+    chunkRuns.set(key, operation)
+    return operation
+  }
+
+  function chunksFor(taskId: string, skill: AnalysisSkill): AnalysisChunkRecord[] {
+    return [...chunks.values()]
+      .filter((chunk) => chunk.taskId === taskId && chunk.skill === skill)
+      .sort((left, right) => left.startOrder - right.startOrder)
+  }
+
+  async function markTaskStopped(task: BookAnalysisPipelineTask, status: "paused" | "cancelled"): Promise<void> {
+    const stoppedAt = now()
+    await updateTask({
+      ...task,
+      status,
+      error: status === "cancelled" ? "用户取消分析" : null,
+      currentSkill: status === "cancelled" ? null : task.currentSkill,
+      completedAt: status === "cancelled" ? stoppedAt : null,
+      updatedAt: stoppedAt,
+    })
+  }
+
+  async function runTaskInternal(taskId: string): Promise<void> {
+    let task = tasks.get(taskId)
+    if (!task || disposed) return
+    const startedAt = task.startedAt ?? now()
+    task = {
+      ...task,
+      selectedSkills: normalizeSelectedSkills(task.selectedSkills),
+      status: "running",
+      error: null,
+      startedAt,
+      completedAt: null,
+      updatedAt: now(),
+    }
+    await updateTask(task)
+
+    try {
+      for (const skill of task.selectedSkills) {
+        task = tasks.get(taskId) ?? task
+        if (cancelRequested.has(taskId)) {
+          await markTaskStopped(task, "cancelled")
+          return
+        }
+        if (pauseRequested.has(taskId)) {
+          await markTaskStopped(task, "paused")
+          return
+        }
+        if (task.modules[skill].status === "completed") continue
+
+        const runningModule: AnalysisModuleState = {
+          ...task.modules[skill],
+          status: "running" as const,
+          failedChunkId: null,
+          updatedAt: now(),
+        }
+        task = {
+          ...task,
+          currentSkill: skill,
+          modules: { ...task.modules, [skill]: runningModule },
+          updatedAt: now(),
+        }
+        await updateTask(task)
+
+        while (true) {
+          if (cancelRequested.has(taskId)) {
+            await markTaskStopped(tasks.get(taskId) ?? task, "cancelled")
+            return
+          }
+          if (pauseRequested.has(taskId)) {
+            await markTaskStopped(tasks.get(taskId) ?? task, "paused")
+            return
+          }
+          const pending = chunksFor(taskId, skill)
+            .filter((chunk) => chunk.status === "pending" || chunk.status === "failed")
+            .slice(0, concurrency)
+          if (pending.length === 0) break
+          const settled = await Promise.allSettled(pending.map((chunk) => runChunk(task!, skill, chunk)))
+          const rejected = settled.find((result): result is PromiseRejectedResult => result.status === "rejected")
+          if (rejected) throw rejected.reason
+        }
+
+        const completedChunks = chunksFor(taskId, skill).filter((chunk) => chunk.status === "completed")
+        const expectedIds = new Set(task.modules[skill].chunkIds)
+        if (completedChunks.length !== expectedIds.size || completedChunks.some((chunk) => !expectedIds.has(chunk.id))) {
+          throw new Error("章节区块尚未全部完成,无法汇总")
+        }
+
+        const outputs: AnalysisChunkOutput[] = []
+        for (const chunk of completedChunks) {
+          const output = await readChunkResult<AnalysisChunkOutput>(chunk)
+          if (!output) throw new Error(`第 ${chunk.startOrder}~${chunk.endOrder} 章结果缺失,无法汇总`)
+          outputs.push(output)
+        }
+        const aggregateController = new AbortController()
+        const aggregateKey = `${taskId}:${skill}:aggregate`
+        controllers.set(aggregateKey, aggregateController)
+        let result: unknown
+        try {
+          result = await options.adapters[skill].aggregate({
+            ...contextFor(task, skill),
+            chunks: outputs.map((output) => output.result),
+            signal: aggregateController.signal,
+          })
+        } finally {
+          controllers.delete(aggregateKey)
+        }
+        const evidence: AnalysisEvidenceSnippet[] = outputs.flatMap((output) => output.evidence)
+        const publishController = new AbortController()
+        const publishKey = `${taskId}:${skill}:publish`
+        controllers.set(publishKey, publishController)
+        let resultPath: string
+        try {
+          resultPath = await options.adapters[skill].publish({
+            ...contextFor(task, skill),
+            result,
+            evidence,
+            signal: publishController.signal,
+          })
+        } finally {
+          controllers.delete(publishKey)
+        }
+
+        task = tasks.get(taskId) ?? task
+        const completedAt = now()
+        task = {
+          ...task,
+          modules: {
+            ...task.modules,
+            [skill]: {
+              ...task.modules[skill],
+              status: "completed",
+              completedChunkIds: completedChunks.map((chunk) => chunk.id),
+              failedChunkId: null,
+              resultPath,
+              updatedAt: completedAt,
+            },
+          },
+          updatedAt: completedAt,
+        }
+        await updateTask(task)
+      }
+
+      const completedAt = now()
+      await updateTask({
+        ...(tasks.get(taskId) ?? task),
+        status: "completed",
+        currentSkill: null,
+        error: null,
+        completedAt,
+        updatedAt: completedAt,
+      })
+    } catch (error) {
+      task = tasks.get(taskId) ?? task
+      if (cancelRequested.has(taskId)) {
+        await markTaskStopped(task, "cancelled")
+        return
+      }
+      if (pauseRequested.has(taskId)) {
+        await markTaskStopped(task, "paused")
+        return
+      }
+      const failedSkill = task.currentSkill
+      const failedAt = now()
+      await updateTask({
+        ...task,
+        status: "failed",
+        error: error instanceof Error ? error.message : "分析任务失败",
+        modules: failedSkill ? {
+          ...task.modules,
+          [failedSkill]: {
+            ...task.modules[failedSkill],
+            status: "failed",
+            failedChunkId: chunksFor(taskId, failedSkill).find((chunk) => chunk.status === "failed")?.id ?? null,
+            updatedAt: failedAt,
+          },
+        } : task.modules,
+        updatedAt: failedAt,
+      })
+    }
+  }
+
+  function runTask(taskId: string): Promise<void> {
+    if (disposed) return Promise.resolve()
+    const existing = taskRuns.get(taskId)
+    if (existing) return existing
+    const operation = runTaskInternal(taskId).finally(() => {
+      taskRuns.delete(taskId)
+      pauseRequested.delete(taskId)
+      cancelRequested.delete(taskId)
+    })
+    taskRuns.set(taskId, operation)
+    return operation
+  }
+
+  return {
+    initialize(nextTasks, nextChunks) {
+      tasks.clear()
+      chunks.clear()
+      for (const task of nextTasks) tasks.set(task.id, copyTask(task))
+      for (const chunk of nextChunks) chunks.set(chunkKey(chunk), copyChunk(chunk))
+      notify()
+    },
+    async enqueue(task, nextChunks) {
+      tasks.set(task.id, copyTask(task))
+      for (const chunk of nextChunks) chunks.set(chunkKey(chunk), copyChunk(chunk))
+      notify()
+      await runTask(task.id)
+    },
+    async pauseTask(taskId) {
+      const task = tasks.get(taskId)
+      if (!task || task.status === "completed" || task.status === "cancelled") return
+      pauseRequested.add(taskId)
+      const running = taskRuns.get(taskId)
+      if (running) await running
+      else await markTaskStopped(task, "paused")
+    },
+    async continueTask(taskId) {
+      const task = tasks.get(taskId)
+      if (!task || task.status === "completed") return
+      pauseRequested.delete(taskId)
+      cancelRequested.delete(taskId)
+      await runTask(taskId)
+    },
+    async retryFailedChunk(taskId, skill, chunkId) {
+      const task = tasks.get(taskId)
+      if (!task) throw new Error("未找到分析任务")
+      const key = `${taskId}:${skill}:${chunkId}`
+      const chunk = chunks.get(key)
+      if (!chunk || (chunk.status !== "failed" && chunk.status !== "cancelled")) {
+        throw new Error("未找到可重试的失败区块")
+      }
+      const reset = { ...chunk, status: "pending" as const, error: null, startedAt: null, completedAt: null, updatedAt: now() }
+      chunks.set(key, reset)
+      await persistChunk(task.bookPath, reset)
+      await runTask(taskId)
+    },
+    async cancelTask(taskId) {
+      const task = tasks.get(taskId)
+      if (!task || task.status === "completed" || task.status === "cancelled") return
+      cancelRequested.add(taskId)
+      for (const [key, controller] of controllers) {
+        if (key.startsWith(`${taskId}:`)) controller.abort()
+      }
+      const running = taskRuns.get(taskId)
+      if (running) await running
+      else await markTaskStopped(task, "cancelled")
+    },
+    getSnapshot: snapshot,
+    subscribe(listener) {
+      listeners.add(listener)
+      listener(snapshot())
+      return () => listeners.delete(listener)
+    },
+    async whenIdle() {
+      while (taskRuns.size > 0) await Promise.allSettled([...taskRuns.values()])
+    },
+    async dispose() {
+      disposed = true
+      for (const controller of controllers.values()) controller.abort()
+      await Promise.allSettled([...taskRuns.values()])
+      listeners.clear()
+      permitWaiters.splice(0).forEach((resolve) => resolve())
+    },
+  }
+}

+ 40 - 0
src/lib/novel/book-analysis/analysis-skill-adapter.ts

@@ -0,0 +1,40 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  AnalysisChunkRecord,
+  AnalysisEvidenceSnippet,
+  AnalysisSkill,
+  BookAnalysisPipelineTask,
+} from "./analysis-pipeline-types"
+
+export interface AnalysisSkillContext {
+  task: BookAnalysisPipelineTask
+  skill: AnalysisSkill
+  bookPath: string
+  projectPath: string
+  llmConfig: LlmConfig
+}
+
+export interface AnalysisSkillAdapter<TChunk = unknown, TResult = unknown> {
+  skill: AnalysisSkill
+  runChunk(input: AnalysisSkillContext & {
+    chunk: AnalysisChunkRecord
+    signal: AbortSignal
+  }): Promise<{
+    result: TChunk
+    evidence: AnalysisEvidenceSnippet[]
+  }>
+  aggregate(input: AnalysisSkillContext & {
+    chunks: TChunk[]
+    signal: AbortSignal
+  }): Promise<TResult>
+  publish(input: AnalysisSkillContext & {
+    result: TResult
+    evidence: AnalysisEvidenceSnippet[]
+    signal: AbortSignal
+  }): Promise<string>
+}
+
+export interface AnalysisChunkOutput<T = unknown> {
+  result: T
+  evidence: AnalysisEvidenceSnippet[]
+}

+ 7 - 0
src/lib/novel/book-analysis/batch-import-storage.ts

@@ -15,6 +15,7 @@ import type {
   BatchImportTask,
   BatchImportTask,
   BatchImportTaskStatus,
   BatchImportTaskStatus,
 } from "./batch-import-types"
 } from "./batch-import-types"
+import { ANALYSIS_SKILL_ORDER } from "./analysis-pipeline-types"
 
 
 const INTERRUPTED_ERROR = "软件上次关闭时任务尚未完成"
 const INTERRUPTED_ERROR = "软件上次关闭时任务尚未完成"
 const TASK_ID_PATTERN = /^[A-Za-z0-9_-]+$/
 const TASK_ID_PATTERN = /^[A-Za-z0-9_-]+$/
@@ -135,6 +136,12 @@ function isBatchImportBatch(
   ) {
   ) {
     return false
     return false
   }
   }
+  if (
+    value.analysisSkills !== undefined
+    && (!Array.isArray(value.analysisSkills)
+      || !value.analysisSkills.every((skill) => ANALYSIS_SKILL_ORDER.includes(skill as never)))
+  ) return false
+  if (value.panelDismissedAt !== undefined && !isNullableNonNegativeNumber(value.panelDismissedAt)) return false
   try {
   try {
     return normalizeProjectKey(value.projectPath) === expectedProjectKey
     return normalizeProjectKey(value.projectPath) === expectedProjectKey
   } catch {
   } catch {

+ 4 - 1
src/lib/novel/book-analysis/batch-import-types.ts

@@ -51,6 +51,9 @@ export interface BatchImportBatch {
   id: string
   id: string
   projectPath: string
   projectPath: string
   taskIds: string[]
   taskIds: string[]
+  analysisSkills?: AnalysisSkill[]
+  panelDismissedAt?: number | null
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
-}
+}
+import type { AnalysisSkill } from "./analysis-pipeline-types"

+ 8 - 1
src/lib/novel/book-analysis/book-deletion.ts

@@ -18,5 +18,12 @@ export async function deleteBookAnalysisBook(
   const bookPath = joinPath(projectPath, "book-analysis", bookId)
   const bookPath = joinPath(projectPath, "book-analysis", bookId)
   if (await fileExists(bookPath)) await deleteFile(bookPath)
   if (await fileExists(bookPath)) await deleteFile(bookPath)
   await removeBookLibraryEntry(projectPath, bookId)
   await removeBookLibraryEntry(projectPath, bookId)
-  return removeBatchImportHistoryForBook(projectPath, bookId)
+  const history = await removeBatchImportHistoryForBook(projectPath, bookId)
+  try {
+    const { rebuildBookAnalysisContextIndex } = await import("./analysis-context-index")
+    await rebuildBookAnalysisContextIndex(projectPath)
+  } catch (error) {
+    console.warn("删除作品后重建拆书上下文索引失败", error)
+  }
+  return history
 }
 }

+ 147 - 0
src/lib/novel/book-analysis/character-analysis-adapter.spec.ts

@@ -0,0 +1,147 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { AnalysisSkill, BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+import { createCharacterAnalysisAdapter, mergeCharacterChunkResults } from "./character-analysis-adapter"
+import type { ExtractedCharacter } from "./types"
+
+function character(overrides: Partial<ExtractedCharacter>): ExtractedCharacter {
+  return {
+    id: "character-1",
+    name: "林远",
+    aliases: [],
+    importance: 8,
+    category: "protagonist",
+    firstAppearance: 1,
+    lastAppearance: 10,
+    appearanceCount: 3,
+    description: "主角",
+    personality: "克制",
+    speechStyle: "短句",
+    relationships: [],
+    keyEvents: [],
+    ...overrides,
+  }
+}
+
+function task(): BookAnalysisPipelineTask {
+  const module = (skill: AnalysisSkill) => ({
+    skill,
+    status: "running" as const,
+    range: { startOrder: 1, endOrder: 20 },
+    chunkIds: ["chunk-0001-0010", "chunk-0011-0020"],
+    completedChunkIds: [],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 1,
+  })
+  return {
+    version: 1,
+    id: "task-1",
+    batchId: null,
+    projectPath: "E:/Novel",
+    bookId: "book-1",
+    bookPath: "E:/Novel/book-analysis/book-1",
+    selectedSkills: ["characters"],
+    range: { startOrder: 1, endOrder: 20 },
+    status: "running",
+    currentSkill: "characters",
+    modules: { characters: module("characters"), story: module("story"), style: module("style") },
+    error: null,
+    createdAt: 1,
+    startedAt: 1,
+    completedAt: null,
+    updatedAt: 1,
+  }
+}
+
+describe("character analysis adapter", () => {
+  it("合并同一人物的别名、章节、关系、动机和成长弧", () => {
+    const merged = mergeCharacterChunkResults([
+      [character({ name: "林远", aliases: ["小远"], firstAppearance: 1, lastAppearance: 10, motivation: "保护家人" })],
+      [character({ id: "character-2", name: "小远", aliases: ["林远"], firstAppearance: 11, lastAppearance: 20, growthArc: "从退让到承担" })],
+    ])
+
+    expect(merged).toHaveLength(1)
+    expect([merged[0].name, ...merged[0].aliases]).toEqual(expect.arrayContaining(["林远", "小远"]))
+    expect(merged[0].firstAppearance).toBe(1)
+    expect(merged[0].lastAppearance).toBe(20)
+    expect(merged[0].motivation).toBe("保护家人")
+    expect(merged[0].growthArc).toContain("从退让到承担")
+  })
+
+  it("发布时统一写角色、生成一次 Skill 并保存角色证据", async () => {
+    const persistCharacter = vi.fn(async () => {})
+    const generateSkills = vi.fn(async () => [])
+    const replaceEvidence = vi.fn(async () => ({ version: 1 as const, bookId: "book-1", snippets: [], updatedAt: 1 }))
+    const saveManifest = vi.fn(async () => {})
+    const rebuildContextIndex = vi.fn(async () => ({ version: 1 as const, books: [], updatedAt: 1 }))
+    const adapter = createCharacterAnalysisAdapter({
+      persistCharacter,
+      generateSkills,
+      replaceEvidence,
+      saveManifest,
+      rebuildContextIndex,
+      loadManifest: vi.fn(async () => null),
+      loadMetadata: vi.fn(async () => ({
+        title: "测试作品",
+        totalChapters: 20,
+        totalWords: 20000,
+        sourceType: "file" as const,
+        createdAt: 1,
+        updatedAt: 1,
+      })),
+      now: () => 10,
+    })
+    const inputTask = task()
+    const evidence = [{
+      version: 1 as const,
+      id: "evidence-1",
+      bookId: "book-1",
+      skill: "characters" as const,
+      taskId: "task-1",
+      chapterId: "ch-0001",
+      chapterOrder: 1,
+      text: "代表性台词",
+      tags: ["林远"],
+      reason: "体现语言模式",
+      purpose: "角色塑造",
+      enabled: true,
+      userPinned: false,
+      createdAt: 1,
+      updatedAt: 1,
+    }]
+
+    await adapter.publish({
+      task: inputTask,
+      skill: "characters",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      result: [character({})],
+      evidence,
+      signal: new AbortController().signal,
+    })
+
+    expect(persistCharacter).toHaveBeenCalledTimes(1)
+    expect(generateSkills).toHaveBeenCalledTimes(1)
+    expect(replaceEvidence).toHaveBeenCalledWith(inputTask.bookPath, "characters", evidence)
+    expect(saveManifest).toHaveBeenCalledTimes(1)
+    expect(rebuildContextIndex).toHaveBeenCalledWith(inputTask.projectPath)
+  })
+
+  it("所有区块都没有可提取角色时给出明确失败原因", async () => {
+    const adapter = createCharacterAnalysisAdapter()
+    const inputTask = task()
+
+    await expect(adapter.aggregate({
+      task: inputTask,
+      skill: "characters",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunks: [{ characters: [] }, { characters: [] }],
+      signal: new AbortController().signal,
+    })).rejects.toThrow("未识别到可提取角色")
+  })
+})

+ 269 - 0
src/lib/novel/book-analysis/character-analysis-adapter.ts

@@ -0,0 +1,269 @@
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import { extractCharactersFromChapters } from "./character-extraction-engine"
+import { persistCharacterToDisk } from "./character-disk-store"
+import { loadMetadata } from "./analysis-engine"
+import { generateSkillsForCharacters } from "./skill-generator"
+import { replaceAutomaticEvidence } from "./analysis-evidence-store"
+import { rebuildBookAnalysisContextIndex } from "./analysis-context-index"
+import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-storage"
+import type {
+  AnalysisEvidenceSnippet,
+  BookAnalysisModuleManifest,
+} from "./analysis-pipeline-types"
+import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
+import type { ExtractedCharacter } from "./types"
+
+export interface CharacterAnalysisChunkResult {
+  characters: ExtractedCharacter[]
+}
+
+interface CharacterAnalysisAdapterDependencies {
+  extractCharacters: typeof extractCharactersFromChapters
+  persistCharacter: typeof persistCharacterToDisk
+  generateSkills: typeof generateSkillsForCharacters
+  loadMetadata: typeof loadMetadata
+  replaceEvidence: typeof replaceAutomaticEvidence
+  loadManifest: typeof loadAnalysisManifest
+  saveManifest: typeof saveAnalysisManifest
+  rebuildContextIndex: typeof rebuildBookAnalysisContextIndex
+  now: () => number
+}
+
+const defaultDependencies: CharacterAnalysisAdapterDependencies = {
+  extractCharacters: extractCharactersFromChapters,
+  persistCharacter: persistCharacterToDisk,
+  generateSkills: generateSkillsForCharacters,
+  loadMetadata,
+  replaceEvidence: replaceAutomaticEvidence,
+  loadManifest: loadAnalysisManifest,
+  saveManifest: saveAnalysisManifest,
+  rebuildContextIndex: rebuildBookAnalysisContextIndex,
+  now: Date.now,
+}
+
+function normalizedText(value: string): string {
+  return value.trim().toLocaleLowerCase().replace(/\s+/g, "")
+}
+
+function uniqueStrings(values: Array<string | undefined>): string[] {
+  const result: string[] = []
+  const seen = new Set<string>()
+  for (const value of values) {
+    const trimmed = value?.trim()
+    if (!trimmed) continue
+    const key = normalizedText(trimmed)
+    if (seen.has(key)) continue
+    seen.add(key)
+    result.push(trimmed)
+  }
+  return result
+}
+
+function identities(character: ExtractedCharacter): Set<string> {
+  return new Set(uniqueStrings([character.name, ...character.aliases]).map(normalizedText))
+}
+
+function intersects(left: Set<string>, right: Set<string>): boolean {
+  for (const item of left) if (right.has(item)) return true
+  return false
+}
+
+function pickLongest(values: Array<string | undefined>): string {
+  return values.reduce<string>((best, value) => (
+    (value?.trim().length ?? 0) > best.length ? value!.trim() : best
+  ), "")
+}
+
+function mergeCharacterGroup(group: ExtractedCharacter[]): ExtractedCharacter {
+  const nameWeights = new Map<string, { name: string; weight: number }>()
+  for (const character of group) {
+    const key = normalizedText(character.name)
+    const current = nameWeights.get(key)
+    nameWeights.set(key, {
+      name: character.name.trim(),
+      weight: (current?.weight ?? 0) + Math.max(1, character.appearanceCount),
+    })
+  }
+  const canonical = [...nameWeights.values()].sort((left, right) => right.weight - left.weight)[0]?.name
+    ?? group[0].name
+  const dominant = [...group].sort((left, right) => right.appearanceCount - left.appearanceCount)[0]
+  const relationshipKeys = new Set<string>()
+  const relationships = group.flatMap((character) => character.relationships).filter((relationship) => {
+    const key = [relationship.target, relationship.relation, relationship.description ?? ""].map(normalizedText).join("::")
+    if (relationshipKeys.has(key)) return false
+    relationshipKeys.add(key)
+    return true
+  })
+  const eventKeys = new Set<string>()
+  const keyEvents = group.flatMap((character) => character.keyEvents).filter((event) => {
+    const key = [event.chapterId, event.description].map(normalizedText).join("::")
+    if (eventKeys.has(key)) return false
+    eventKeys.add(key)
+    return true
+  })
+  const quoteKeys = new Set<string>()
+  const representativeQuotes = group.flatMap((character) => character.representativeQuotes ?? []).filter((quote) => {
+    const key = [quote.chapterId, quote.text].map(normalizedText).join("::")
+    if (quoteKeys.has(key)) return false
+    quoteKeys.add(key)
+    return true
+  })
+  const categoryRank = { protagonist: 4, antagonist: 3, supporting: 2, minor: 1 } as const
+  const category = [...group].sort((left, right) => categoryRank[right.category] - categoryRank[left.category])[0].category
+
+  return {
+    ...dominant,
+    name: canonical,
+    aliases: uniqueStrings(group.flatMap((character) => [character.name, ...character.aliases]))
+      .filter((name) => normalizedText(name) !== normalizedText(canonical)),
+    importance: Math.max(...group.map((character) => character.importance)),
+    category,
+    firstAppearance: Math.min(...group.map((character) => character.firstAppearance)),
+    lastAppearance: Math.max(...group.map((character) => character.lastAppearance)),
+    appearanceCount: group.reduce((sum, character) => sum + character.appearanceCount, 0),
+    description: pickLongest(group.map((character) => character.description)),
+    personality: pickLongest(group.map((character) => character.personality)),
+    motivation: pickLongest(group.map((character) => character.motivation)),
+    goals: uniqueStrings(group.flatMap((character) => character.goals ?? [])),
+    fears: uniqueStrings(group.flatMap((character) => character.fears ?? [])),
+    growthArc: uniqueStrings(group.map((character) => character.growthArc)).join(";"),
+    behaviorPatterns: uniqueStrings(group.map((character) => character.behaviorPatterns)).join(";"),
+    speechStyle: pickLongest(group.map((character) => character.speechStyle)),
+    relationships,
+    keyEvents,
+    representativeQuotes,
+    corpus: group.map((character) => character.corpus ?? "").filter(Boolean).join("\n\n").slice(0, 12000),
+  }
+}
+
+export function mergeCharacterChunkResults(chunks: ExtractedCharacter[][]): ExtractedCharacter[] {
+  const all = chunks.flat()
+  const parent = all.map((_, index) => index)
+  const find = (index: number): number => {
+    while (parent[index] !== index) {
+      parent[index] = parent[parent[index]]
+      index = parent[index]
+    }
+    return index
+  }
+  const union = (left: number, right: number): void => {
+    const leftRoot = find(left)
+    const rightRoot = find(right)
+    if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot
+  }
+  const identitySets = all.map(identities)
+  for (let left = 0; left < all.length; left += 1) {
+    for (let right = left + 1; right < all.length; right += 1) {
+      if (intersects(identitySets[left], identitySets[right])) union(left, right)
+    }
+  }
+  const groups = new Map<number, ExtractedCharacter[]>()
+  all.forEach((character, index) => {
+    const root = find(index)
+    groups.set(root, [...(groups.get(root) ?? []), character])
+  })
+  return [...groups.values()].map(mergeCharacterGroup)
+}
+
+function characterEvidence(
+  taskId: string,
+  bookId: string,
+  chunkId: string,
+  chapterIds: string[],
+  startOrder: number,
+  characters: ExtractedCharacter[],
+  now: number,
+): AnalysisEvidenceSnippet[] {
+  let index = 0
+  return characters.flatMap((character) => (character.representativeQuotes ?? []).map((quote) => {
+    const chapterIndex = chapterIds.indexOf(quote.chapterId)
+    const evidence: AnalysisEvidenceSnippet = {
+      version: 1,
+      id: `evidence-${taskId}-characters-${chunkId}-${index++}`,
+      bookId,
+      skill: "characters",
+      taskId,
+      chapterId: quote.chapterId,
+      chapterOrder: chapterIndex >= 0 ? startOrder + chapterIndex : startOrder,
+      text: quote.text.trim().slice(0, 500),
+      tags: [character.name, "角色塑造"],
+      reason: `体现${character.name}的动机、语言或行为模式`,
+      purpose: "角色塑造与对话参考",
+      enabled: true,
+      userPinned: false,
+      createdAt: now,
+      updatedAt: now,
+    }
+    return evidence
+  })).filter((item) => item.text)
+}
+
+export function createCharacterAnalysisAdapter(
+  overrides: Partial<CharacterAnalysisAdapterDependencies> = {},
+): AnalysisSkillAdapter<CharacterAnalysisChunkResult, ExtractedCharacter[]> {
+  const dependencies = { ...defaultDependencies, ...overrides }
+  return {
+    skill: "characters",
+    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+      const extracted = await dependencies.extractCharacters({
+        bookPath,
+        selectedChapterIds: chunk.chapterIds,
+        llmConfig,
+        depth: "fast",
+        persistResults: false,
+        signal,
+      })
+      if (!extracted.success) throw new Error("角色区块分析失败")
+      return {
+        result: { characters: extracted.characters },
+        evidence: characterEvidence(
+          task.id,
+          task.bookId,
+          chunk.id,
+          chunk.chapterIds,
+          chunk.startOrder,
+          extracted.characters,
+          dependencies.now(),
+        ),
+      }
+    },
+    async aggregate({ chunks }) {
+      const merged = mergeCharacterChunkResults(chunks.map((chunk) => chunk.characters))
+      if (merged.length === 0) {
+        throw new Error("所选章节未识别到可提取角色,请确认章节正文包含有姓名的重要角色")
+      }
+      return merged
+    },
+    async publish({ task, bookPath, projectPath, llmConfig, result, evidence, signal }) {
+      const metadata = await dependencies.loadMetadata(bookPath)
+      if (!metadata) throw new Error("未找到作品元数据,无法发布角色分析")
+      for (const character of result) await dependencies.persistCharacter(bookPath, character)
+      await dependencies.generateSkills(result, metadata, bookPath, llmConfig, undefined, signal)
+      await dependencies.replaceEvidence(bookPath, "characters", evidence)
+
+      const resultPath = normalizePath(joinPath(bookPath, "characters"))
+      const updatedAt = dependencies.now()
+      const current = await dependencies.loadManifest(bookPath)
+      const manifest: BookAnalysisModuleManifest = {
+        version: 1,
+        bookId: task.bookId,
+        modules: {
+          ...(current?.modules ?? {}),
+          characters: {
+            ...task.modules.characters,
+            status: "completed",
+            resultPath,
+            summary: `提取 ${result.length} 个角色,覆盖第 ${task.modules.characters.range.startOrder}~${task.modules.characters.range.endOrder} 章。`,
+            updatedAt,
+          },
+        },
+        updatedAt,
+      }
+      await dependencies.saveManifest(bookPath, manifest)
+      await dependencies.rebuildContextIndex(projectPath)
+      return resultPath
+    },
+  }
+}
+
+export const characterAnalysisAdapter = createCharacterAnalysisAdapter()

+ 40 - 0
src/lib/novel/book-analysis/character-extraction-engine.spec.ts

@@ -136,6 +136,46 @@ describe("extractCharactersFromChapters 目标角色约束", () => {
   })
   })
 })
 })
 
 
+describe("extractCharactersFromChapters 角色识别失败处理", () => {
+  it("模型未返回重要度时仍按默认重要角色继续提取", async () => {
+    vi.mocked(readFile).mockResolvedValue("---\ntitle: 第一章\norder: 1\n---\n林烬推门而入。")
+    streamChatMock
+      .mockImplementationOnce(async (_cfg, _messages, handlers: any) => {
+        handlers.onToken(JSON.stringify({ characters: [{ name: "林烬", aliases: [] }] }))
+        handlers.onDone()
+      })
+      .mockImplementationOnce(async (_cfg, _messages, handlers: any) => {
+        handlers.onToken(JSON.stringify({ name: "林烬", category: "protagonist", personality: "冷静" }))
+        handlers.onDone()
+      })
+
+    const result = await extractCharactersFromChapters({
+      bookPath: "E:/Novel/book-analysis/book-1",
+      selectedChapterIds: ["chapter-1"],
+      llmConfig: fakeLlmConfig,
+      depth: "fast",
+      persistResults: false,
+    })
+
+    expect(result.characters.map((item) => item.name)).toEqual(["林烬"])
+  })
+
+  it("角色识别请求失败时向上返回明确错误", async () => {
+    vi.mocked(readFile).mockResolvedValue("---\ntitle: 第一章\norder: 1\n---\n林烬推门而入。")
+    streamChatMock.mockImplementationOnce(async (_cfg, _messages, handlers: any) => {
+      handlers.onError(new Error("模型连接失败"))
+    })
+
+    await expect(extractCharactersFromChapters({
+      bookPath: "E:/Novel/book-analysis/book-1",
+      selectedChapterIds: ["chapter-1"],
+      llmConfig: fakeLlmConfig,
+      depth: "fast",
+      persistResults: false,
+    })).rejects.toThrow("角色识别失败")
+  })
+})
+
 describe("extractSingleCharacter (fix/character-reextract-and-loading-state)", () => {
 describe("extractSingleCharacter (fix/character-reextract-and-loading-state)", () => {
   it("simple 模式内部直接调用 LLM(不再依赖外部 _llmCall 注入)", async () => {
   it("simple 模式内部直接调用 LLM(不再依赖外部 _llmCall 注入)", async () => {
     streamChatMock.mockImplementationOnce(async (_cfg, _msgs, handlers: any) => {
     streamChatMock.mockImplementationOnce(async (_cfg, _msgs, handlers: any) => {

+ 60 - 10
src/lib/novel/book-analysis/character-extraction-engine.ts

@@ -48,6 +48,8 @@ export interface CharacterExtractionInput {
     dimensions?: SixDimensionProgressItem[]
     dimensions?: SixDimensionProgressItem[]
   }) => void
   }) => void
   signal?: AbortSignal
   signal?: AbortSignal
+  /** 是否把中间结果写入正式角色目录;分析区块并发执行时必须关闭。 */
+  persistResults?: boolean
 }
 }
 
 
 export interface CharacterExtractionResult {
 export interface CharacterExtractionResult {
@@ -90,25 +92,45 @@ ${chapterContent.substring(0, 8000)} ${chapterContent.length > 8000 ? "...(内
   ]
   ]
 
 
   let response = ""
   let response = ""
+  let streamError: Error | null = null
 
 
   try {
   try {
     await streamChat(llmConfig, messages, {
     await streamChat(llmConfig, messages, {
       onToken: (text) => { response += text },
       onToken: (text) => { response += text },
       onDone: () => {},
       onDone: () => {},
-      onError: (err) => { console.error(err) },
+      onError: (err) => { streamError = err },
     }, signal)
     }, signal)
 
 
+    if (streamError) throw streamError
+
     // 解析 JSON
     // 解析 JSON
     const jsonMatch = response.match(/\{[\s\S]*\}/)
     const jsonMatch = response.match(/\{[\s\S]*\}/)
     if (jsonMatch) {
     if (jsonMatch) {
-      const data = JSON.parse(jsonMatch[0])
-      return data.characters || []
+      const data = JSON.parse(jsonMatch[0]) as { characters?: unknown }
+      if (!Array.isArray(data.characters)) throw new Error("模型返回内容缺少 characters 数组")
+      return data.characters.flatMap((item): Array<{ name: string; aliases: string[]; importance: number }> => {
+        if (!item || typeof item !== "object") return []
+        const candidate = item as Record<string, unknown>
+        const name = typeof candidate.name === "string" ? candidate.name.trim() : ""
+        if (!name) return []
+        const rawImportance = Number(candidate.importance)
+        return [{
+          name,
+          aliases: Array.isArray(candidate.aliases)
+            ? candidate.aliases.filter((alias): alias is string => typeof alias === "string")
+            : [],
+          importance: Number.isFinite(rawImportance)
+            ? Math.max(1, Math.min(10, rawImportance))
+            : 5,
+        }]
+      })
     }
     }
 
 
-    return []
+    throw new Error("模型返回内容不是有效 JSON")
   } catch (error) {
   } catch (error) {
     console.error(`Failed to identify characters in chapter ${chapterTitle}:`, error)
     console.error(`Failed to identify characters in chapter ${chapterTitle}:`, error)
-    return []
+    if (signal?.aborted) throw new Error("用户取消分析")
+    throw new Error(`角色识别失败(${chapterTitle}):${error instanceof Error ? error.message : String(error)}`)
   }
   }
 }
 }
 
 
@@ -117,13 +139,13 @@ ${chapterContent.substring(0, 8000)} ${chapterContent.length > 8000 ? "...(内
  */
  */
 async function analyzeCharacterDetails(
 async function analyzeCharacterDetails(
   characterName: string,
   characterName: string,
-  relevantChapters: Array<{ title: string; content: string; order: number }>,
+  relevantChapters: Array<{ id: string; title: string; content: string; order: number }>,
   llmConfig: LlmConfig,
   llmConfig: LlmConfig,
   signal?: AbortSignal
   signal?: AbortSignal
 ): Promise<ExtractedCharacter | null> {
 ): Promise<ExtractedCharacter | null> {
   // 收集角色相关的文本片段
   // 收集角色相关的文本片段
   const corpus = relevantChapters
   const corpus = relevantChapters
-    .map(ch => ch.content)
+    .map(ch => `【${ch.id} · 第${ch.order}章 · ${ch.title}】\n${ch.content}`)
     .join("\n\n")
     .join("\n\n")
     .substring(0, 20000) // 限制长度
     .substring(0, 20000) // 限制长度
 
 
@@ -139,6 +161,11 @@ ${corpus}
   "category": "protagonist/antagonist/supporting/minor",
   "category": "protagonist/antagonist/supporting/minor",
   "description": "角色外貌、身份、背景描述",
   "description": "角色外貌、身份、背景描述",
   "personality": "性格特征,包括优点、缺点、特质",
   "personality": "性格特征,包括优点、缺点、特质",
+  "motivation": "底层动机与核心欲望",
+  "goals": ["当前目标与长期目标"],
+  "fears": ["恐惧、禁区与不能接受的结果"],
+  "growthArc": "本批章节内的关系变化、认知变化和成长弧",
+  "behaviorPatterns": "面对压力、利益、冲突和亲密关系时的稳定行为模式",
   "speechStyle": "说话方式和语言特点",
   "speechStyle": "说话方式和语言特点",
   "relationships": [
   "relationships": [
     {
     {
@@ -152,6 +179,12 @@ ${corpus}
       "chapterId": "章节ID",
       "chapterId": "章节ID",
       "description": "关键事件描述"
       "description": "关键事件描述"
     }
     }
+  ],
+  "representativeQuotes": [
+    {
+      "chapterId": "必须使用上文【】中的章节ID",
+      "text": "80-300字的短原文片段,不得改写"
+    }
   ]
   ]
 }
 }
 
 
@@ -162,14 +195,17 @@ ${corpus}
   ]
   ]
 
 
   let response = ""
   let response = ""
+  let streamError: Error | null = null
 
 
   try {
   try {
     await streamChat(llmConfig, messages, {
     await streamChat(llmConfig, messages, {
       onToken: (text) => { response += text },
       onToken: (text) => { response += text },
       onDone: () => {},
       onDone: () => {},
-      onError: (err) => { console.error(err) },
+      onError: (err) => { streamError = err },
     }, signal)
     }, signal)
 
 
+    if (streamError) throw streamError
+
     // 解析 JSON
     // 解析 JSON
     const jsonMatch = response.match(/\{[\s\S]*\}/)
     const jsonMatch = response.match(/\{[\s\S]*\}/)
     if (jsonMatch) {
     if (jsonMatch) {
@@ -187,19 +223,32 @@ ${corpus}
         appearanceCount: relevantChapters.length,
         appearanceCount: relevantChapters.length,
         description: data.description || "",
         description: data.description || "",
         personality: data.personality || "",
         personality: data.personality || "",
+        motivation: typeof data.motivation === "string" ? data.motivation : "",
+        goals: Array.isArray(data.goals) ? data.goals.filter((item: unknown): item is string => typeof item === "string") : [],
+        fears: Array.isArray(data.fears) ? data.fears.filter((item: unknown): item is string => typeof item === "string") : [],
+        growthArc: typeof data.growthArc === "string" ? data.growthArc : "",
+        behaviorPatterns: typeof data.behaviorPatterns === "string" ? data.behaviorPatterns : "",
         speechStyle: data.speechStyle || "",
         speechStyle: data.speechStyle || "",
         relationships: data.relationships || [],
         relationships: data.relationships || [],
         keyEvents: data.keyEvents || [],
         keyEvents: data.keyEvents || [],
+        representativeQuotes: Array.isArray(data.representativeQuotes)
+          ? data.representativeQuotes.filter((item: unknown): item is { chapterId: string; text: string } => {
+              if (!item || typeof item !== "object") return false
+              const candidate = item as Record<string, unknown>
+              return typeof candidate.chapterId === "string" && typeof candidate.text === "string"
+            })
+          : [],
         corpus: corpus.substring(0, 10000), // 保留部分语料
         corpus: corpus.substring(0, 10000), // 保留部分语料
       }
       }
 
 
       return character
       return character
     }
     }
 
 
-    return null
+    throw new Error("模型返回内容不是有效 JSON")
   } catch (error) {
   } catch (error) {
     console.error(`Failed to analyze character ${characterName}:`, error)
     console.error(`Failed to analyze character ${characterName}:`, error)
-    return null
+    if (signal?.aborted) throw new Error("用户取消分析")
+    throw new Error(`角色详情提取失败(${characterName}):${error instanceof Error ? error.message : String(error)}`)
   }
   }
 }
 }
 
 
@@ -417,6 +466,7 @@ export async function extractCharactersFromChapters(
       }
       }
       // 保存更新后的角色(feature/fix-six-dim-extract:writeFile 失败不应中断整个 6 维流程)
       // 保存更新后的角色(feature/fix-six-dim-extract:writeFile 失败不应中断整个 6 维流程)
       try {
       try {
+        if (input.persistResults === false) continue
         await writeFile(
         await writeFile(
           joinPath(bookPath, "characters", `${character.id}.json`),
           joinPath(bookPath, "characters", `${character.id}.json`),
           JSON.stringify(characters[i], null, 2)
           JSON.stringify(characters[i], null, 2)

+ 7 - 0
src/lib/novel/book-analysis/library-state.ts

@@ -12,6 +12,7 @@ import type {
   ExtractedCharacter,
   ExtractedCharacter,
   RecognizedCharacter,
   RecognizedCharacter,
 } from "./types"
 } from "./types"
+import type { AnalysisEvidenceSnippet, BookAnalysisModuleManifest } from "./analysis-pipeline-types"
 
 
 export type BookStyleStatus = "missing" | "available" | "enabled"
 export type BookStyleStatus = "missing" | "available" | "enabled"
 
 
@@ -26,6 +27,8 @@ export interface BookAnalysisLibraryBook {
   styleStatus: BookStyleStatus
   styleStatus: BookStyleStatus
   boundAurasCount: number
   boundAurasCount: number
   addedAuraCharacterIds: string[]
   addedAuraCharacterIds: string[]
+  analysisManifest?: BookAnalysisModuleManifest
+  evidence: AnalysisEvidenceSnippet[]
 }
 }
 
 
 export interface BookAnalysisAuraBindingSummary {
 export interface BookAnalysisAuraBindingSummary {
@@ -174,6 +177,8 @@ export async function loadBookAnalysisLibraryState(projectPath: string): Promise
       : recognizedFromExtractedCharacters(entry.path, characters)
       : recognizedFromExtractedCharacters(entry.path, characters)
     const skills = await loadSkills(entry.path, metadata, characters)
     const skills = await loadSkills(entry.path, metadata, characters)
     const styleProfile = await readJson<BookStyleProfile>(joinPath(entry.path, "style-profile.json"))
     const styleProfile = await readJson<BookStyleProfile>(joinPath(entry.path, "style-profile.json"))
+    const analysisManifest = await readJson<BookAnalysisModuleManifest>(joinPath(entry.path, "analysis", "manifest.json"))
+    const evidenceCollection = await readJson<{ snippets?: AnalysisEvidenceSnippet[] }>(joinPath(entry.path, "analysis", "evidence.json"))
     const styleStatus: BookStyleStatus =
     const styleStatus: BookStyleStatus =
       enabledStyle?.sourceBook === metadata.title ? "enabled" : styleProfile ? "available" : "missing"
       enabledStyle?.sourceBook === metadata.title ? "enabled" : styleProfile ? "available" : "missing"
 
 
@@ -188,6 +193,8 @@ export async function loadBookAnalysisLibraryState(projectPath: string): Promise
       styleStatus,
       styleStatus,
       boundAurasCount: getBoundAurasCount(metadata.title, bindings, auraById),
       boundAurasCount: getBoundAurasCount(metadata.title, bindings, auraById),
       addedAuraCharacterIds: getAddedAuraCharacterIds(metadata.title, characters, auraStore.customAuras),
       addedAuraCharacterIds: getAddedAuraCharacterIds(metadata.title, characters, auraStore.customAuras),
+      analysisManifest: analysisManifest ?? undefined,
+      evidence: Array.isArray(evidenceCollection?.snippets) ? evidenceCollection.snippets : [],
     })
     })
   }
   }
 
 

+ 32 - 0
src/lib/novel/book-analysis/skill-generator.ts

@@ -117,6 +117,11 @@ export async function generateCharacterSkill(
 - 分类:${character.category}
 - 分类:${character.category}
 - 描述:${character.description}
 - 描述:${character.description}
 - 性格:${character.personality}
 - 性格:${character.personality}
+- 核心动机:${character.motivation || "未提取"}
+- 目标:${character.goals?.join("、") || "未提取"}
+- 恐惧与边界:${character.fears?.join("、") || "未提取"}
+- 成长弧:${character.growthArc || "未提取"}
+- 行为模式:${character.behaviorPatterns || "未提取"}
 - 说话方式:${character.speechStyle}
 - 说话方式:${character.speechStyle}
 - 关系:${character.relationships.map(r => `${r.target}(${r.relation})`).join("、")}
 - 关系:${character.relationships.map(r => `${r.target}(${r.relation})`).join("、")}
 
 
@@ -137,6 +142,11 @@ export async function generateCharacterSkill(
 5. 行为模式
 5. 行为模式
 6. 关系网络
 6. 关系网络
 7. 使用建议(如何在写作中使用这个角色)
 7. 使用建议(如何在写作中使用这个角色)
+8. 核心动机与目标
+9. 恐惧与行为边界
+10. 成长弧
+11. 语言与行为模式
+12. 代表性证据(只用于学习角色塑造,不照搬原作情节)
 
 
 请直接输出完整的 Markdown 内容,不要额外说明。`
 请直接输出完整的 Markdown 内容,不要额外说明。`
 
 
@@ -199,6 +209,28 @@ ${character.personality}
 
 
 ${character.speechStyle}
 ${character.speechStyle}
 
 
+## 核心动机与目标
+
+${character.motivation || "未提取"}
+
+${character.goals?.map((goal) => `- ${goal}`).join("\n") || "- 未提取"}
+
+## 恐惧与行为边界
+
+${character.fears?.map((fear) => `- ${fear}`).join("\n") || "- 未提取"}
+
+## 成长弧
+
+${character.growthArc || "未提取"}
+
+## 语言与行为模式
+
+${character.behaviorPatterns || "未提取"}
+
+## 代表性证据
+
+${character.representativeQuotes?.map((quote) => `- [${quote.chapterId}] ${quote.text}`).join("\n") || "- 未提取"}
+
 ## 关系网络
 ## 关系网络
 
 
 ${character.relationships.map(r => `- **${r.target}**:${r.relation}${r.description ? ` - ${r.description}` : ""}`).join("\n")}
 ${character.relationships.map(r => `- **${r.target}**:${r.relation}${r.description ? ` - ${r.description}` : ""}`).join("\n")}

+ 120 - 0
src/lib/novel/book-analysis/story-analysis-adapter.spec.ts

@@ -0,0 +1,120 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { AnalysisSkill, BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+import { createStoryAnalysisAdapter } from "./story-analysis-adapter"
+
+function task(): BookAnalysisPipelineTask {
+  const module = (skill: AnalysisSkill) => ({
+    skill,
+    status: "pending" as const,
+    range: { startOrder: 1, endOrder: 20 },
+    chunkIds: ["chunk-0001-0010", "chunk-0011-0020"],
+    completedChunkIds: [],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 1,
+  })
+  return {
+    version: 1,
+    id: "task-1",
+    batchId: null,
+    projectPath: "E:/Novel",
+    bookId: "book-1",
+    bookPath: "E:/Novel/book-analysis/book-1",
+    selectedSkills: ["story"],
+    range: { startOrder: 1, endOrder: 20 },
+    status: "running",
+    currentSkill: "story",
+    modules: { characters: module("characters"), story: module("story"), style: module("style") },
+    error: null,
+    createdAt: 1,
+    startedAt: 1,
+    completedAt: null,
+    updatedAt: 1,
+  }
+}
+
+const metadata = {
+  title: "测试作品",
+  totalChapters: 20,
+  totalWords: 20000,
+  sourceType: "file" as const,
+  createdAt: 1,
+  updatedAt: 1,
+}
+
+describe("story analysis adapter", () => {
+  it("只选故事时临时识别人物但不会发布角色结果", async () => {
+    const recognizeCharacters = vi.fn(async () => [{
+      id: "character-1",
+      name: "林远",
+      aliases: ["小远"],
+      appearances: 1,
+      chapterIndices: [0],
+      importanceScore: 90,
+      category: "主角" as const,
+      sourceBook: "测试作品",
+    }])
+    const callModel = vi.fn(async (messages) => messages[1].content)
+    const adapter = createStoryAnalysisAdapter({
+      recognizeCharacters,
+      callModel,
+      loadMetadata: vi.fn(async () => metadata),
+      loadChapters: vi.fn(async () => [{ id: "ch-0001", title: "第一章", order: 1, content: "林远推门而入。" }]),
+      now: () => 10,
+    })
+    const inputTask = task()
+    const output = await adapter.runChunk({
+      task: inputTask,
+      skill: "story",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunk: {
+        version: 1,
+        id: "chunk-0001-0001",
+        taskId: inputTask.id,
+        skill: "story",
+        chapterIds: ["ch-0001"],
+        startOrder: 1,
+        endOrder: 1,
+        wordCount: 100,
+        status: "running",
+        attempts: 1,
+        resultPath: null,
+        error: null,
+        startedAt: 1,
+        completedAt: null,
+        updatedAt: 1,
+      },
+      signal: new AbortController().signal,
+    })
+
+    expect(recognizeCharacters).toHaveBeenCalledTimes(1)
+    expect(callModel.mock.calls[0][0][1].content).toContain("临时人物线索")
+    expect(callModel.mock.calls[0][0][1].content).toContain("禁止输出角色 Skill")
+    expect(output.evidence[0].skill).toBe("story")
+  })
+
+  it("故事汇总只保留已完成区块的用户章节范围", async () => {
+    const callModel = vi.fn(async () => "汇总结果")
+    const adapter = createStoryAnalysisAdapter({ callModel })
+    const inputTask = task()
+    const result = await adapter.aggregate({
+      task: inputTask,
+      skill: "story",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunks: [
+        { markdown: "区块一", rangeChapterIds: ["ch-0001", "ch-0002"] },
+        { markdown: "区块二", rangeChapterIds: ["ch-0011", "ch-0012"] },
+      ],
+      signal: new AbortController().signal,
+    })
+
+    expect(result.rangeChapterIds).toEqual(["ch-0001", "ch-0002", "ch-0011", "ch-0012"])
+    expect(callModel).toHaveBeenCalledTimes(1)
+  })
+})

+ 205 - 0
src/lib/novel/book-analysis/story-analysis-adapter.ts

@@ -0,0 +1,205 @@
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { upsertPlotFramework } from "@/lib/novel/plot-framework-library"
+import type { PlotFramework } from "@/lib/novel/plot-framework"
+import { loadMetadata } from "./analysis-engine"
+import { llmRecognizeCharacters } from "./character-llm-recognizer"
+import { replaceAutomaticEvidence } from "./analysis-evidence-store"
+import { rebuildBookAnalysisContextIndex } from "./analysis-context-index"
+import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-storage"
+import type { AnalysisEvidenceSnippet, BookAnalysisModuleManifest } from "./analysis-pipeline-types"
+import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
+import {
+  buildBookStoryFrameworkPrompt,
+  buildPlotFrameworkDraftFromBookStoryOutput,
+  loadBookStoryFrameworkChapters,
+  type BookStoryFrameworkChapter,
+} from "./story-framework-extraction"
+
+export interface StoryAnalysisChunkResult {
+  markdown: string
+  rangeChapterIds: string[]
+}
+
+interface StoryAnalysisAdapterDependencies {
+  loadChapters: typeof loadBookStoryFrameworkChapters
+  loadMetadata: typeof loadMetadata
+  recognizeCharacters: typeof llmRecognizeCharacters
+  callModel: (messages: ChatMessage[], llmConfig: Parameters<typeof streamChat>[0], signal: AbortSignal) => Promise<string>
+  buildDraft: typeof buildPlotFrameworkDraftFromBookStoryOutput
+  upsertFramework: typeof upsertPlotFramework
+  replaceEvidence: typeof replaceAutomaticEvidence
+  loadManifest: typeof loadAnalysisManifest
+  saveManifest: typeof saveAnalysisManifest
+  rebuildContextIndex: typeof rebuildBookAnalysisContextIndex
+  now: () => number
+}
+
+async function callStoryModel(
+  messages: ChatMessage[],
+  llmConfig: Parameters<typeof streamChat>[0],
+  signal: AbortSignal,
+): Promise<string> {
+  let output = ""
+  let streamError: Error | null = null
+  await streamChat(llmConfig, messages, {
+    onToken: (token) => { output += token },
+    onDone: () => {},
+    onError: (error) => { streamError = error },
+  }, signal, { reasoning: llmConfig.reasoning })
+  if (signal.aborted) throw new Error("用户取消故事分析")
+  if (streamError) throw streamError
+  return output.trim()
+}
+
+const defaultDependencies: StoryAnalysisAdapterDependencies = {
+  loadChapters: loadBookStoryFrameworkChapters,
+  loadMetadata,
+  recognizeCharacters: llmRecognizeCharacters,
+  callModel: callStoryModel,
+  buildDraft: buildPlotFrameworkDraftFromBookStoryOutput,
+  upsertFramework: upsertPlotFramework,
+  replaceEvidence: replaceAutomaticEvidence,
+  loadManifest: loadAnalysisManifest,
+  saveManifest: saveAnalysisManifest,
+  rebuildContextIndex: rebuildBookAnalysisContextIndex,
+  now: Date.now,
+}
+
+function trimEvidenceText(value: string): string {
+  const text = value.replace(/\s+/g, " ").trim()
+  return text.length <= 320 ? text : `${text.slice(0, 320)}…`
+}
+
+function storyEvidence(
+  taskId: string,
+  bookId: string,
+  chunkId: string,
+  chapters: BookStoryFrameworkChapter[],
+  now: number,
+): AnalysisEvidenceSnippet[] {
+  const selected = chapters.length <= 1 ? chapters : [chapters[0], chapters[chapters.length - 1]]
+  return selected.map((chapter, index): AnalysisEvidenceSnippet => ({
+    version: 1,
+    id: `evidence-${taskId}-story-${chunkId}-${index}`,
+    bookId,
+    skill: "story",
+    taskId,
+    chapterId: chapter.id,
+    chapterOrder: chapter.order,
+    text: trimEvidenceText(chapter.content),
+    tags: [index === 0 ? "开局与铺垫" : "推进与钩子", "故事结构"],
+    reason: index === 0 ? "用于理解本区块如何建立期待" : "用于理解本区块如何推进并留下钩子",
+    purpose: "故事节奏与四段结构参考",
+    enabled: true,
+    userPinned: false,
+    createdAt: now,
+    updatedAt: now,
+  })).filter((item) => item.text)
+}
+
+function buildAggregatePrompt(chunks: StoryAnalysisChunkResult[]): string {
+  return [
+    "你是小说故事框架汇总助手。以下内容是同一用户所选章节范围内,各章节区块已经完成的四段框架分析。",
+    "只合并这些已完成结果,不补写范围外剧情,不输出角色档案或角色 Skill。",
+    "请去重并保留区块间的衔接,严格按原有标题输出:框架归属与衔接、开局钩子、铺垫、爽点、结尾钩子、可复用结构记忆。",
+    "",
+    ...chunks.map((chunk, index) => [
+      `# 区块 ${index + 1}(${chunk.rangeChapterIds.join("、")})`,
+      chunk.markdown,
+    ].join("\n")),
+  ].join("\n\n")
+}
+
+export function createStoryAnalysisAdapter(
+  overrides: Partial<StoryAnalysisAdapterDependencies> = {},
+): AnalysisSkillAdapter<StoryAnalysisChunkResult, StoryAnalysisChunkResult> {
+  const dependencies = { ...defaultDependencies, ...overrides }
+  return {
+    skill: "story",
+    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+      const chapters = await dependencies.loadChapters(bookPath, chunk.chapterIds)
+      if (chapters.length !== chunk.chapterIds.length) {
+        throw new Error("所选故事章节读取不完整,请检查章节文件后重试")
+      }
+      const metadata = await dependencies.loadMetadata(bookPath)
+      if (!metadata) throw new Error("未找到作品元数据,无法分析故事")
+      const temporaryCharacters = task.modules.characters.status === "completed"
+        ? undefined
+        : (await dependencies.recognizeCharacters({
+            chapters: chapters.map((chapter, index) => ({ index, content: chapter.content })),
+            llmConfig,
+            sourceBook: metadata.title,
+            signal,
+          })).map((character) => ({
+            name: character.name,
+            aliases: character.aliases,
+            category: character.category,
+          }))
+      const markdown = await dependencies.callModel([
+        { role: "system", content: "你是严谨的小说故事框架拆解助手,必须输出可复用的中文四段框架。" },
+        {
+          role: "user",
+          content: buildBookStoryFrameworkPrompt({
+            bookTitle: metadata.title,
+            chapters,
+            temporaryCharacters,
+          }),
+        },
+      ], llmConfig, signal)
+      return {
+        result: { markdown, rangeChapterIds: chunk.chapterIds },
+        evidence: storyEvidence(task.id, task.bookId, chunk.id, chapters, dependencies.now()),
+      }
+    },
+    async aggregate({ chunks, llmConfig, signal }) {
+      if (chunks.length === 0) throw new Error("没有已完成的故事区块可供汇总")
+      const rangeChapterIds = chunks.flatMap((chunk) => chunk.rangeChapterIds)
+      if (chunks.length === 1) return { ...chunks[0], rangeChapterIds }
+      const markdown = await dependencies.callModel([
+        { role: "system", content: "你只汇总已有故事分析,禁止补写未分析章节。" },
+        { role: "user", content: buildAggregatePrompt(chunks) },
+      ], llmConfig, signal)
+      return { markdown, rangeChapterIds }
+    },
+    async publish({ task, bookPath, projectPath, result, evidence }) {
+      const metadata = await dependencies.loadMetadata(bookPath)
+      if (!metadata) throw new Error("未找到作品元数据,无法发布故事分析")
+      const framework = dependencies.buildDraft({
+        bookId: task.bookId,
+        bookTitle: metadata.title,
+        markdown: result.markdown,
+        rangeChapterIds: result.rangeChapterIds,
+        createdAt: dependencies.now(),
+      })
+      if (!framework) throw new Error("故事框架提取失败:AI 输出缺少开局钩子、铺垫、爽点或结尾钩子")
+      const saved = await dependencies.upsertFramework(projectPath, framework)
+      await dependencies.replaceEvidence(bookPath, "story", evidence)
+
+      const resultPath = `plot-framework:${saved.id}`
+      const updatedAt = dependencies.now()
+      const current = await dependencies.loadManifest(bookPath)
+      const manifest: BookAnalysisModuleManifest = {
+        version: 1,
+        bookId: task.bookId,
+        modules: {
+          ...(current?.modules ?? {}),
+          story: {
+            ...task.modules.story,
+            status: "completed",
+            resultPath,
+            summary: `提取四段故事框架,覆盖第 ${task.modules.story.range.startOrder}~${task.modules.story.range.endOrder} 章。`,
+            updatedAt,
+          },
+        },
+        updatedAt,
+      }
+      await dependencies.saveManifest(bookPath, manifest)
+      await dependencies.rebuildContextIndex(projectPath)
+      return resultPath
+    },
+  }
+}
+
+export const storyAnalysisAdapter = createStoryAnalysisAdapter()
+
+export type { PlotFramework }

+ 11 - 0
src/lib/novel/book-analysis/story-framework-extraction.ts

@@ -33,10 +33,21 @@ export async function loadBookStoryFrameworkChapters(
 export function buildBookStoryFrameworkPrompt(input: {
 export function buildBookStoryFrameworkPrompt(input: {
   bookTitle: string
   bookTitle: string
   chapters: BookStoryFrameworkChapter[]
   chapters: BookStoryFrameworkChapter[]
+  temporaryCharacters?: Array<{ name: string; aliases: string[]; category: string }>
 }): string {
 }): string {
   return [
   return [
     `拆书作品:${input.bookTitle}`,
     `拆书作品:${input.bookTitle}`,
     "",
     "",
+    ...(input.temporaryCharacters?.length
+      ? [
+          "临时人物线索(只用于理解人物关系、目标和冲突):",
+          ...input.temporaryCharacters.map((character) => (
+            `- ${character.name}${character.aliases.length ? `(别名:${character.aliases.join("、")})` : ""} · ${character.category}`
+          )),
+          "禁止输出角色 Skill、角色档案或人物仿写指令。",
+          "",
+        ]
+      : []),
     ...input.chapters.map((chapter) =>
     ...input.chapters.map((chapter) =>
       [
       [
         `## 第 ${chapter.order} 章:${chapter.title}`,
         `## 第 ${chapter.order} 章:${chapter.title}`,

+ 102 - 0
src/lib/novel/book-analysis/style-analysis-adapter.spec.ts

@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { AnalysisSkill, BookAnalysisPipelineTask } from "./analysis-pipeline-types"
+import { createStyleAnalysisAdapter } from "./style-analysis-adapter"
+
+function task(): BookAnalysisPipelineTask {
+  const module = (skill: AnalysisSkill) => ({
+    skill,
+    status: "pending" as const,
+    range: { startOrder: 1, endOrder: 10 },
+    chunkIds: ["chunk-0001-0010"],
+    completedChunkIds: [],
+    failedChunkId: null,
+    resultPath: null,
+    analysisVersion: 1,
+    updatedAt: 1,
+  })
+  return {
+    version: 1,
+    id: "task-1",
+    batchId: null,
+    projectPath: "E:/Novel",
+    bookId: "book-1",
+    bookPath: "E:/Novel/book-analysis/book-1",
+    selectedSkills: ["style"],
+    range: { startOrder: 1, endOrder: 10 },
+    status: "running",
+    currentSkill: "style",
+    modules: { characters: module("characters"), story: module("story"), style: module("style") },
+    error: null,
+    createdAt: 1,
+    startedAt: 1,
+    completedAt: null,
+    updatedAt: 1,
+  }
+}
+
+describe("style analysis adapter", () => {
+  it("文风结果包含幽默、热血、视角、词汇和证据", async () => {
+    const adapter = createStyleAnalysisAdapter({
+      readFile: vi.fn(async () => "---\nid: ch-0001\n---\n这是代表性正文。"),
+      loadMetadata: vi.fn(async () => ({
+        title: "测试作品",
+        totalChapters: 1,
+        totalWords: 100,
+        sourceType: "file" as const,
+        createdAt: 1,
+        updatedAt: 1,
+      })),
+      callModel: vi.fn(async () => JSON.stringify({
+        narrativeDensity: "推进快",
+        constitution: "1. 使用短句加速",
+        humorMechanisms: ["一本正经地补刀"],
+        highEnergyMechanisms: ["目标兑现前连续抬压"],
+        pointOfView: "第三人称限知",
+        vocabularyPreferences: ["口语化动词"],
+        avoidPatterns: ["解释笑点"],
+        samples: ["这是代表性正文。"],
+        evidence: [{
+          chapterId: "ch-0001",
+          text: "这是代表性正文。",
+          tags: ["幽默对白"],
+          reason: "反差",
+          purpose: "对白节奏",
+        }],
+      })),
+      now: () => 10,
+    })
+    const inputTask = task()
+    const output = await adapter.runChunk({
+      task: inputTask,
+      skill: "style",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunk: {
+        version: 1,
+        id: "chunk-0001-0001",
+        taskId: inputTask.id,
+        skill: "style",
+        chapterIds: ["ch-0001"],
+        startOrder: 1,
+        endOrder: 1,
+        wordCount: 100,
+        status: "running",
+        attempts: 1,
+        resultPath: null,
+        error: null,
+        startedAt: 1,
+        completedAt: null,
+        updatedAt: 1,
+      },
+      signal: new AbortController().signal,
+    })
+
+    expect(output.result.profile.humorMechanisms).toEqual(["一本正经地补刀"])
+    expect(output.result.profile.highEnergyMechanisms).toEqual(["目标兑现前连续抬压"])
+    expect(output.result.profile.pointOfView).toBe("第三人称限知")
+    expect(output.result.profile.vocabularyPreferences).toEqual(["口语化动词"])
+    expect(output.evidence[0].tags).toContain("幽默对白")
+  })
+})

+ 216 - 0
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -0,0 +1,216 @@
+import { readFile, writeFile } from "@/commands/fs"
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import { upsertWritingStylePreset } from "@/lib/novel/writing-style-store"
+import { loadMetadata } from "./analysis-engine"
+import { replaceAutomaticEvidence } from "./analysis-evidence-store"
+import { rebuildBookAnalysisContextIndex } from "./analysis-context-index"
+import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-storage"
+import type { AnalysisEvidenceSnippet, BookAnalysisModuleManifest } from "./analysis-pipeline-types"
+import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
+import {
+  buildStyleExtractionPrompt,
+  parseStyleEvidenceResult,
+  parseStyleProfileResult,
+} from "./style-prompts"
+import { styleProfileToMarkdown } from "./style-extraction-engine"
+import type { BookStyleProfile } from "./types"
+
+export interface StyleAnalysisChunkResult {
+  raw: string
+  profile: BookStyleProfile
+}
+
+interface StyleAnalysisAdapterDependencies {
+  readFile: typeof readFile
+  writeFile: typeof writeFile
+  loadMetadata: typeof loadMetadata
+  callModel: (messages: ChatMessage[], llmConfig: Parameters<typeof streamChat>[0], signal: AbortSignal) => Promise<string>
+  upsertPreset: typeof upsertWritingStylePreset
+  replaceEvidence: typeof replaceAutomaticEvidence
+  loadManifest: typeof loadAnalysisManifest
+  saveManifest: typeof saveAnalysisManifest
+  rebuildContextIndex: typeof rebuildBookAnalysisContextIndex
+  now: () => number
+}
+
+async function callStyleModel(
+  messages: ChatMessage[],
+  llmConfig: Parameters<typeof streamChat>[0],
+  signal: AbortSignal,
+): Promise<string> {
+  let output = ""
+  let streamError: Error | null = null
+  await streamChat(llmConfig, messages, {
+    onToken: (token) => { output += token },
+    onDone: () => {},
+    onError: (error) => { streamError = error },
+  }, signal, { reasoning: llmConfig.reasoning })
+  if (signal.aborted) throw new Error("用户取消文风分析")
+  if (streamError) throw streamError
+  return output.trim()
+}
+
+const defaultDependencies: StyleAnalysisAdapterDependencies = {
+  readFile,
+  writeFile,
+  loadMetadata,
+  callModel: callStyleModel,
+  upsertPreset: upsertWritingStylePreset,
+  replaceEvidence: replaceAutomaticEvidence,
+  loadManifest: loadAnalysisManifest,
+  saveManifest: saveAnalysisManifest,
+  rebuildContextIndex: rebuildBookAnalysisContextIndex,
+  now: Date.now,
+}
+
+function stripFrontmatter(content: string): string {
+  return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
+}
+
+function mergeStrings(values: Array<string | undefined>): string {
+  return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))].join(";")
+}
+
+function unique(values: string[]): string[] {
+  return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
+}
+
+export function mergeStyleChunkProfiles(
+  chunks: StyleAnalysisChunkResult[],
+  evidenceIds: string[],
+  generatedAt: number,
+): BookStyleProfile {
+  const profiles = chunks.map((chunk) => chunk.profile)
+  return {
+    schemaVersion: 1,
+    generatedAt,
+    sampledChapterIds: unique(profiles.flatMap((profile) => profile.sampledChapterIds)),
+    narrativeDensity: mergeStrings(profiles.map((profile) => profile.narrativeDensity)),
+    descriptionWeight: mergeStrings(profiles.map((profile) => profile.descriptionWeight)),
+    emotionRendering: mergeStrings(profiles.map((profile) => profile.emotionRendering)),
+    sentenceStyle: mergeStrings(profiles.map((profile) => profile.sentenceStyle)),
+    rhetoricDensity: mergeStrings(profiles.map((profile) => profile.rhetoricDensity)),
+    transitionStyle: mergeStrings(profiles.map((profile) => profile.transitionStyle)),
+    narrativeVoice: mergeStrings(profiles.map((profile) => profile.narrativeVoice)),
+    dialogueStyle: mergeStrings(profiles.map((profile) => profile.dialogueStyle)),
+    thematicHabits: mergeStrings(profiles.map((profile) => profile.thematicHabits)),
+    humorMechanisms: unique(profiles.flatMap((profile) => profile.humorMechanisms ?? [])),
+    highEnergyMechanisms: unique(profiles.flatMap((profile) => profile.highEnergyMechanisms ?? [])),
+    pointOfView: mergeStrings(profiles.map((profile) => profile.pointOfView)),
+    vocabularyPreferences: unique(profiles.flatMap((profile) => profile.vocabularyPreferences ?? [])),
+    avoidPatterns: unique(profiles.flatMap((profile) => profile.avoidPatterns ?? [])),
+    evidenceIds,
+    constitution: mergeStrings(profiles.map((profile) => profile.constitution)),
+    samples: unique(profiles.flatMap((profile) => profile.samples)).slice(0, 6),
+  }
+}
+
+function aggregatePrompt(chunks: StyleAnalysisChunkResult[]): string {
+  return [
+    "你是小说文风汇总专家。以下是同一用户所选章节范围内,各区块已经完成的文风 JSON。",
+    "请去重、消除偶发特征,并输出与原格式相同的单个 JSON。必须保留幽默机制、热血机制、叙事视角、词汇偏好、禁用写法和可执行风格宪法。",
+    "不得引入未分析章节,不得复述原作剧情。evidence 可以返回空数组,因为证据由区块结果单独保存。",
+    "",
+    ...chunks.map((chunk, index) => `区块 ${index + 1}:\n${chunk.raw}`),
+  ].join("\n\n")
+}
+
+export function createStyleAnalysisAdapter(
+  overrides: Partial<StyleAnalysisAdapterDependencies> = {},
+): AnalysisSkillAdapter<StyleAnalysisChunkResult, BookStyleProfile> {
+  const dependencies = { ...defaultDependencies, ...overrides }
+  return {
+    skill: "style",
+    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+      const metadata = await dependencies.loadMetadata(bookPath)
+      if (!metadata) throw new Error("未找到作品元数据,无法分析文风")
+      const blocks: string[] = []
+      for (const chapterId of chunk.chapterIds) {
+        const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
+        const body = stripFrontmatter(raw).slice(0, 8000)
+        if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
+      }
+      if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")
+      const raw = await dependencies.callModel([
+        { role: "system", content: "你是专业的小说文风分析助手。只输出用户要求的 JSON,不要解释。" },
+        { role: "user", content: buildStyleExtractionPrompt(blocks.join("\n\n———\n\n"), metadata.title) },
+      ], llmConfig, signal)
+      const profile = parseStyleProfileResult(raw, chunk.chapterIds)
+      const evidence = parseStyleEvidenceResult(raw).flatMap((candidate, index): AnalysisEvidenceSnippet[] => {
+        if (!chunk.chapterIds.includes(candidate.chapterId)) return []
+        const chapterIndex = chunk.chapterIds.indexOf(candidate.chapterId)
+        return [{
+          version: 1,
+          id: `evidence-${task.id}-style-${chunk.id}-${index}`,
+          bookId: task.bookId,
+          skill: "style",
+          taskId: task.id,
+          chapterId: candidate.chapterId,
+          chapterOrder: chunk.startOrder + chapterIndex,
+          text: candidate.text.trim().slice(0, 500),
+          tags: candidate.tags,
+          reason: candidate.reason || "体现作品稳定的文风机制",
+          purpose: candidate.purpose || "文风仿写参考",
+          enabled: true,
+          userPinned: false,
+          createdAt: dependencies.now(),
+          updatedAt: dependencies.now(),
+        }]
+      })
+      return { result: { raw, profile }, evidence }
+    },
+    async aggregate({ chunks, llmConfig, signal }) {
+      if (chunks.length === 0) throw new Error("没有已完成的文风区块可供汇总")
+      const evidenceIds: string[] = []
+      if (chunks.length === 1) return mergeStyleChunkProfiles(chunks, evidenceIds, dependencies.now())
+      const raw = await dependencies.callModel([
+        { role: "system", content: "你只汇总已有文风分析,不分析范围外内容。" },
+        { role: "user", content: aggregatePrompt(chunks) },
+      ], llmConfig, signal)
+      const profile = parseStyleProfileResult(raw, chunks.flatMap((chunk) => chunk.profile.sampledChapterIds))
+      profile.generatedAt = dependencies.now()
+      profile.evidenceIds = evidenceIds
+      return profile
+    },
+    async publish({ task, bookPath, projectPath, result, evidence }) {
+      const metadata = await dependencies.loadMetadata(bookPath)
+      if (!metadata) throw new Error("未找到作品元数据,无法发布文风分析")
+      result.evidenceIds = evidence.map((item) => item.id)
+      const resultPath = normalizePath(joinPath(bookPath, "style-profile.json"))
+      await dependencies.writeFile(resultPath, JSON.stringify(result, null, 2))
+      await dependencies.writeFile(joinPath(bookPath, "style.md"), styleProfileToMarkdown(result, metadata.title))
+      await dependencies.replaceEvidence(bookPath, "style", evidence)
+      await dependencies.upsertPreset(projectPath, {
+        name: `${metadata.title} · 文风`,
+        sourceBook: metadata.title,
+        sourceBookId: task.bookId,
+        evidenceIds: result.evidenceIds,
+        profile: result,
+      })
+
+      const updatedAt = dependencies.now()
+      const current = await dependencies.loadManifest(bookPath)
+      const manifest: BookAnalysisModuleManifest = {
+        version: 1,
+        bookId: task.bookId,
+        modules: {
+          ...(current?.modules ?? {}),
+          style: {
+            ...task.modules.style,
+            status: "completed",
+            resultPath,
+            summary: `提取文风机制与 ${evidence.length} 条代表片段,覆盖第 ${task.modules.style.range.startOrder}~${task.modules.style.range.endOrder} 章。`,
+            updatedAt,
+          },
+        },
+        updatedAt,
+      }
+      await dependencies.saveManifest(bookPath, manifest)
+      await dependencies.rebuildContextIndex(projectPath)
+      return resultPath
+    },
+  }
+}
+
+export const styleAnalysisAdapter = createStyleAnalysisAdapter()

+ 11 - 3
src/lib/novel/book-analysis/style-extraction-engine.ts

@@ -26,6 +26,8 @@ const MIN_SAMPLE_WORD_COUNT = 800
 export interface AnalyzeWritingStyleOptions {
 export interface AnalyzeWritingStyleOptions {
   onProgress?: (message: string) => void
   onProgress?: (message: string) => void
   signal?: AbortSignal
   signal?: AbortSignal
+  /** 新分析入口必须传入用户明确选择的章节;未传时仅保留旧入口兼容抽样。 */
+  selectedChapterIds?: string[]
 }
 }
 
 
 function stripFrontmatter(content: string): string {
 function stripFrontmatter(content: string): string {
@@ -53,7 +55,7 @@ export async function analyzeWritingStyle(
   llmConfig: LlmConfig,
   llmConfig: LlmConfig,
   options: AnalyzeWritingStyleOptions = {},
   options: AnalyzeWritingStyleOptions = {},
 ): Promise<BookStyleProfile> {
 ): Promise<BookStyleProfile> {
-  const { onProgress, signal } = options
+  const { onProgress, signal, selectedChapterIds } = options
   if (signal?.aborted) throw new Error("用户取消提取")
   if (signal?.aborted) throw new Error("用户取消提取")
 
 
   onProgress?.("读取章节列表…")
   onProgress?.("读取章节列表…")
@@ -64,9 +66,15 @@ export async function analyzeWritingStyle(
   const bookTitle = metadata?.title || "未命名作品"
   const bookTitle = metadata?.title || "未命名作品"
 
 
   // 优先取有一定篇幅的章节,再在其中首/中/尾均匀抽样
   // 优先取有一定篇幅的章节,再在其中首/中/尾均匀抽样
+  const selected = selectedChapterIds?.length
+    ? chapters.filter((chapter) => selectedChapterIds.includes(chapter.chapterId))
+    : null
+  if (selectedChapterIds?.length && selected?.length !== selectedChapterIds.length) {
+    throw new Error("所选文风章节读取不完整,请检查章节后重试")
+  }
   const meaningful = chapters.filter((c) => c.wordCount >= MIN_SAMPLE_WORD_COUNT)
   const meaningful = chapters.filter((c) => c.wordCount >= MIN_SAMPLE_WORD_COUNT)
   const pool = meaningful.length >= 3 ? meaningful : chapters
   const pool = meaningful.length >= 3 ? meaningful : chapters
-  const sampled = pickEvenlySpread(pool, MAX_SAMPLE_CHAPTERS)
+  const sampled = selected ?? pickEvenlySpread(pool, MAX_SAMPLE_CHAPTERS)
 
 
   onProgress?.(`读取 ${sampled.length} 章样本正文…`)
   onProgress?.(`读取 ${sampled.length} 章样本正文…`)
   const sampleBlocks: string[] = []
   const sampleBlocks: string[] = []
@@ -75,7 +83,7 @@ export async function analyzeWritingStyle(
     try {
     try {
       const raw = await readFile(joinPath(bookPath, "chapters", `${chapter.chapterId}.md`))
       const raw = await readFile(joinPath(bookPath, "chapters", `${chapter.chapterId}.md`))
       const body = stripFrontmatter(raw).slice(0, PER_CHAPTER_CHAR_LIMIT)
       const body = stripFrontmatter(raw).slice(0, PER_CHAPTER_CHAR_LIMIT)
-      if (body) sampleBlocks.push(`【${chapter.title}】\n${body}`)
+      if (body) sampleBlocks.push(`【章节ID:${chapter.chapterId} · ${chapter.title}】\n${body}`)
     } catch {
     } catch {
       // 跳过读不到的章节
       // 跳过读不到的章节
     }
     }

+ 46 - 0
src/lib/novel/book-analysis/style-prompts.ts

@@ -22,6 +22,14 @@ export const STYLE_DIMENSIONS = [
 
 
 export type StyleDimensionKey = typeof STYLE_DIMENSIONS[number]["key"]
 export type StyleDimensionKey = typeof STYLE_DIMENSIONS[number]["key"]
 
 
+export interface StyleEvidenceCandidate {
+  chapterId: string
+  text: string
+  tags: string[]
+  reason: string
+  purpose: string
+}
+
 const SAMPLE_TEXT_LIMIT = 24000
 const SAMPLE_TEXT_LIMIT = 24000
 
 
 function truncate(value: string, limit: number): string {
 function truncate(value: string, limit: number): string {
@@ -61,12 +69,19 @@ export function buildStyleExtractionPrompt(sampleText: string, bookTitle: string
     `   绝不要泛泛而谈(如\u201C节奏适中\u201D\u201C描写生动\u201D),必须具体到可指导写作的程度。`,
     `   绝不要泛泛而谈(如\u201C节奏适中\u201D\u201C描写生动\u201D),必须具体到可指导写作的程度。`,
     `2. \`constitution\`:把以上维度合成为 8~12 条**可执行的硬约束**(编号列表,写进同一个字符串,用换行分隔),用于约束 AI 之后按这种文风写作。每条约束必须具体到\u201C怎么写/不怎么写\u201D,并附带简短的理由,例如\u201C环境描写控制在2句以内,因为作者偏好用动作带出场景而非静态铺陈\u201D\u201C比喻只用日常喻体,因为作者的比喻总是接地气的而非文学化的\u201D。`,
     `2. \`constitution\`:把以上维度合成为 8~12 条**可执行的硬约束**(编号列表,写进同一个字符串,用换行分隔),用于约束 AI 之后按这种文风写作。每条约束必须具体到\u201C怎么写/不怎么写\u201D,并附带简短的理由,例如\u201C环境描写控制在2句以内,因为作者偏好用动作带出场景而非静态铺陈\u201D\u201C比喻只用日常喻体,因为作者的比喻总是接地气的而非文学化的\u201D。`,
     "3. `samples`:从上面提供的原文样本中,**原样摘抄** 4~6 段最能代表这种文风的片段(每段 80~300 字),放进字符串数组。必须是原文照抄,不要改写、不要自己编。优先选择能同时体现多个维度特征的段落。",
     "3. `samples`:从上面提供的原文样本中,**原样摘抄** 4~6 段最能代表这种文风的片段(每段 80~300 字),放进字符串数组。必须是原文照抄,不要改写、不要自己编。优先选择能同时体现多个维度特征的段落。",
+    "4. `humorMechanisms`:具体说明幽默如何产生,例如反差、一本正经地补刀、误解递进;没有明显幽默时返回空数组。",
+    "5. `highEnergyMechanisms`:具体说明热血或高燃段落如何抬压、加速和兑现;没有明显特征时返回空数组。",
+    "6. `pointOfView`:明确叙事人称与限知范围。",
+    "7. `vocabularyPreferences`:列出稳定的词汇、动词和口语偏好。",
+    "8. `avoidPatterns`:列出仿写时应避免的写法。",
+    "9. `evidence`:返回 3~8 个代表片段,每项包含 chapterId、text、tags、reason、purpose。chapterId 必须来自样本标题中的章节 ID,text 必须是 80~300 字原文短片段,不得改写。",
     "",
     "",
     "硬性要求:",
     "硬性要求:",
     "- 只分析文风,不要分析人物性格或剧情走向。",
     "- 只分析文风,不要分析人物性格或剧情走向。",
     "- 每个维度的描述必须包含具体的写作手法和用词习惯,不能只给笼统评价。",
     "- 每个维度的描述必须包含具体的写作手法和用词习惯,不能只给笼统评价。",
     "- constitution 中的每条约束都必须是可操作的写作指令,不能是空泛的原则。",
     "- constitution 中的每条约束都必须是可操作的写作指令,不能是空泛的原则。",
     "- samples 必须来自提供的原文,不得虚构。",
     "- samples 必须来自提供的原文,不得虚构。",
+    "- evidence 的 tags 要标明幽默对白、热血节奏、叙事视角、词汇习惯等可检索特征。",
     "- JSON 之外不要输出任何内容。",
     "- JSON 之外不要输出任何内容。",
     "",
     "",
     `作品:${bookTitle}`,
     `作品:${bookTitle}`,
@@ -85,6 +100,32 @@ function asStringArray(value: unknown): string[] {
   return value.map((item) => asString(item)).filter(Boolean)
   return value.map((item) => asString(item)).filter(Boolean)
 }
 }
 
 
+export function parseStyleEvidenceResult(raw: string): StyleEvidenceCandidate[] {
+  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
+  if (!objectText) return []
+  try {
+    const parsed = JSON.parse(objectText) as Record<string, unknown>
+    if (!Array.isArray(parsed.evidence)) return []
+    return parsed.evidence.flatMap((item): StyleEvidenceCandidate[] => {
+      if (!item || typeof item !== "object") return []
+      const candidate = item as Record<string, unknown>
+      const chapterId = asString(candidate.chapterId)
+      const text = asString(candidate.text)
+      if (!chapterId || !text) return []
+      return [{
+        chapterId,
+        text,
+        tags: asStringArray(candidate.tags),
+        reason: asString(candidate.reason),
+        purpose: asString(candidate.purpose),
+      }]
+    }).slice(0, 8)
+  } catch {
+    return []
+  }
+}
+
 /**
 /**
  * 解析模型返回的文风 JSON。容错:剥代码围栏 + 取最外层 {...}。
  * 解析模型返回的文风 JSON。容错:剥代码围栏 + 取最外层 {...}。
  * 解析失败或缺 constitution 时,返回带兜底宪法的最小 profile(不抛错)。
  * 解析失败或缺 constitution 时,返回带兜底宪法的最小 profile(不抛错)。
@@ -130,6 +171,11 @@ export function parseStyleProfileResult(raw: string, sampledChapterIds: string[]
     narrativeVoice: asString(parsed.narrativeVoice),
     narrativeVoice: asString(parsed.narrativeVoice),
     dialogueStyle: asString(parsed.dialogueStyle),
     dialogueStyle: asString(parsed.dialogueStyle),
     thematicHabits: asString(parsed.thematicHabits),
     thematicHabits: asString(parsed.thematicHabits),
+    humorMechanisms: asStringArray(parsed.humorMechanisms),
+    highEnergyMechanisms: asStringArray(parsed.highEnergyMechanisms),
+    pointOfView: asString(parsed.pointOfView),
+    vocabularyPreferences: asStringArray(parsed.vocabularyPreferences),
+    avoidPatterns: asStringArray(parsed.avoidPatterns),
     constitution: constitution || FALLBACK_STYLE_CONSTITUTION,
     constitution: constitution || FALLBACK_STYLE_CONSTITUTION,
     samples: asStringArray(parsed.samples).slice(0, 6),
     samples: asStringArray(parsed.samples).slice(0, 6),
   }
   }

+ 15 - 0
src/lib/novel/book-analysis/types.ts

@@ -138,6 +138,15 @@ export interface ExtractedCharacter {
   description: string
   description: string
   personality: string
   personality: string
   speechStyle: string
   speechStyle: string
+  motivation?: string
+  goals?: string[]
+  fears?: string[]
+  growthArc?: string
+  behaviorPatterns?: string
+  representativeQuotes?: Array<{
+    chapterId: string
+    text: string
+  }>
   relationships: Array<{
   relationships: Array<{
     target: string
     target: string
     relation: string
     relation: string
@@ -258,6 +267,12 @@ export interface BookStyleProfile {
   narrativeVoice: string
   narrativeVoice: string
   dialogueStyle: string
   dialogueStyle: string
   thematicHabits: string
   thematicHabits: string
+  humorMechanisms?: string[]
+  highEnergyMechanisms?: string[]
+  pointOfView?: string
+  vocabularyPreferences?: string[]
+  avoidPatterns?: string[]
+  evidenceIds?: string[]
   /** 5~8 条注入用"风格宪法"硬约束(由上面维度合成)。 */
   /** 5~8 条注入用"风格宪法"硬约束(由上面维度合成)。 */
   constitution: string
   constitution: string
   /** 3~6 段代表原文片段,作为模仿锚点(few-shot)。 */
   /** 3~6 段代表原文片段,作为模仿锚点(few-shot)。 */

+ 1 - 0
src/lib/novel/context-data-source.ts

@@ -154,6 +154,7 @@ export class DataSourceRegistry {
       relatedSettings: "",
       relatedSettings: "",
       canonRules: "",
       canonRules: "",
       writingStyle: "",
       writingStyle: "",
+      bookAnalysisReferences: "",
       searchResults: "",
       searchResults: "",
       graphSearchResults: "",
       graphSearchResults: "",
       revisionFeedback: [],
       revisionFeedback: [],

+ 1 - 0
src/lib/novel/context-data-sources.spec.ts

@@ -10,6 +10,7 @@ describe("getDataSourceNamesForCategories", () => {
     expect(names).toContain("canonRules")
     expect(names).toContain("canonRules")
     expect(names).toContain("outline")
     expect(names).toContain("outline")
     expect(names).toContain("chapterOutline")
     expect(names).toContain("chapterOutline")
+    expect(names).toContain("bookAnalysisReferences")
     expect(names).not.toContain("recentChapterContents")
     expect(names).not.toContain("recentChapterContents")
     expect(names).not.toContain("searchResults")
     expect(names).not.toContain("searchResults")
     expect(names).not.toContain("graphSearchResults")
     expect(names).not.toContain("graphSearchResults")

+ 11 - 0
src/lib/novel/context-data-sources.ts

@@ -51,6 +51,7 @@ const DATA_SOURCE_CATEGORY_MAP: Record<string, DataSourceCategory[]> = {
   relatedSettings: ["settings"],
   relatedSettings: ["settings"],
   canonRules: ["settings"],
   canonRules: ["settings"],
   writingStyle: ["settings"],
   writingStyle: ["settings"],
+  bookAnalysisReferences: ["memory", "plot_tools", "settings", "character_states"],
   searchResults: ["memory", "plot_tools"],
   searchResults: ["memory", "plot_tools"],
   graphSearchResults: ["graph"],
   graphSearchResults: ["graph"],
   revisionFeedback: ["revision"],
   revisionFeedback: ["revision"],
@@ -398,6 +399,15 @@ export const writingStyleDataSource: DataSource<string> = {
   },
   },
 }
 }
 
 
+export const bookAnalysisReferencesDataSource: DataSource<string> = {
+  name: "bookAnalysisReferences",
+  priority: 19,
+  async load(context: ContextLoadContext): Promise<string> {
+    const { searchBookAnalysisContext } = await import("./book-analysis/analysis-context-index")
+    return searchBookAnalysisContext(context.projectPath, context.task)
+  },
+}
+
 /**
 /**
  * 搜索结果数据源
  * 搜索结果数据源
  */
  */
@@ -636,6 +646,7 @@ export function getAllDataSources(): DataSource<any>[] {
     relatedSettingsDataSource,
     relatedSettingsDataSource,
     canonRulesDataSource,
     canonRulesDataSource,
     writingStyleDataSource,
     writingStyleDataSource,
+    bookAnalysisReferencesDataSource,
     searchResultsDataSource,
     searchResultsDataSource,
     graphSearchResultsDataSource,
     graphSearchResultsDataSource,
     revisionFeedbackDataSource,
     revisionFeedbackDataSource,

+ 7 - 3
src/lib/novel/context-engine.ts

@@ -151,6 +151,10 @@ async function buildContextPackFromRawData(
   rawData: Record<string, any>,
   rawData: Record<string, any>,
   context: ContextLoadContext,
   context: ContextLoadContext,
 ): Promise<ContextPack> {
 ): Promise<ContextPack> {
+  const searchResults = joinNonEmpty([
+    rawData.searchResults || "",
+    rawData.bookAnalysisReferences || "",
+  ], "\n\n")
   // 合并快照数据和降级数据,优先使用 retrieval 索引
   // 合并快照数据和降级数据,优先使用 retrieval 索引
   const retrievalRecentSummaries = Array.isArray(rawData.retrieval?.recentSummaries)
   const retrievalRecentSummaries = Array.isArray(rawData.retrieval?.recentSummaries)
     ? rawData.retrieval.recentSummaries
     ? rawData.retrieval.recentSummaries
@@ -199,7 +203,7 @@ async function buildContextPackFromRawData(
     foreshadowingSignals.length > 0 
     foreshadowingSignals.length > 0 
       ? foreshadowingSignals 
       ? foreshadowingSignals 
       : [rawData.fallbackForeshadowingStates].filter(Boolean),
       : [rawData.fallbackForeshadowingStates].filter(Boolean),
-    rawData.searchResults,
+    searchResults,
   )
   )
   
   
   // 构建章节目标
   // 构建章节目标
@@ -248,7 +252,7 @@ async function buildContextPackFromRawData(
     relatedSettings: rawData.relatedSettings,
     relatedSettings: rawData.relatedSettings,
     canonRules: rawData.canonRules,
     canonRules: rawData.canonRules,
     writingStyle: rawData.writingStyle,
     writingStyle: rawData.writingStyle,
-    searchResults: rawData.searchResults,
+    searchResults,
     graphSearchResults: rawData.graphSearchResults,
     graphSearchResults: rawData.graphSearchResults,
     mustDo: buildMustDo(chapterGoal, previousChapterEnding, foreshadowingStates),
     mustDo: buildMustDo(chapterGoal, previousChapterEnding, foreshadowingStates),
     mustAvoid: buildMustAvoid(rawData.canonRules, timeline, characterStates),
     mustAvoid: buildMustAvoid(rawData.canonRules, timeline, characterStates),
@@ -258,7 +262,7 @@ async function buildContextPackFromRawData(
       previousChapterEnding,
       previousChapterEnding,
       foreshadowingStates,
       foreshadowingStates,
       timeline,
       timeline,
-      searchResults: rawData.searchResults,
+      searchResults,
     }),
     }),
     revisionDirectives,
     revisionDirectives,
   }
   }

+ 27 - 0
src/lib/novel/writing-style-store.spec.ts

@@ -4,6 +4,7 @@ import type { BookStyleProfile } from "./book-analysis/types"
 const mem = new Map<string, string>()
 const mem = new Map<string, string>()
 
 
 vi.mock("@/commands/fs", () => ({
 vi.mock("@/commands/fs", () => ({
+  fileExists: vi.fn(async (path: string) => mem.has(path)),
   readFile: vi.fn(async (path: string) => {
   readFile: vi.fn(async (path: string) => {
     if (!mem.has(path)) throw new Error("ENOENT")
     if (!mem.has(path)) throw new Error("ENOENT")
     return mem.get(path)!
     return mem.get(path)!
@@ -115,4 +116,30 @@ describe("writing-style-store", () => {
     expect(ctx).toContain("…")
     expect(ctx).toContain("…")
     expect(ctx.length).toBeLessThan(800)
     expect(ctx.length).toBeLessThan(800)
   })
   })
+
+  it("新文风只注入证据仓库中当前启用的片段", async () => {
+    const evidencePath = `${PROJECT}/book-analysis/book-1/analysis/evidence.json`
+    mem.set(evidencePath, JSON.stringify({
+      version: 1,
+      bookId: "book-1",
+      updatedAt: 1,
+      snippets: [
+        { id: "enabled", skill: "style", enabled: true, text: "幽默对白片段" },
+        { id: "disabled", skill: "style", enabled: false, text: "已禁用片段" },
+      ],
+    }))
+    const preset = await upsertWritingStylePreset(PROJECT, {
+      name: "测试文风",
+      sourceBook: "测试作品",
+      sourceBookId: "book-1",
+      evidenceIds: ["enabled", "disabled"],
+      profile: makeProfile(),
+    })
+    await setEnabledWritingStyle(PROJECT, preset.id)
+
+    const context = await buildWritingStyleContext(PROJECT)
+    expect(context).toContain("幽默对白片段")
+    expect(context).not.toContain("已禁用片段")
+    expect(context).not.toContain("原文片段一")
+  })
 })
 })

+ 31 - 4
src/lib/novel/writing-style-store.ts

@@ -17,6 +17,8 @@ export interface WritingStylePreset {
   name: string
   name: string
   sourceBook: string
   sourceBook: string
   profile: BookStyleProfile
   profile: BookStyleProfile
+  sourceBookId?: string
+  evidenceIds?: string[]
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
 }
 }
@@ -65,14 +67,27 @@ export async function saveWritingStyleStore(projectPath: string, store: WritingS
  */
  */
 export async function upsertWritingStylePreset(
 export async function upsertWritingStylePreset(
   projectPath: string,
   projectPath: string,
-  input: { name: string; sourceBook: string; profile: BookStyleProfile },
+  input: {
+    name: string
+    sourceBook: string
+    profile: BookStyleProfile
+    sourceBookId?: string
+    evidenceIds?: string[]
+  },
 ): Promise<WritingStylePreset> {
 ): Promise<WritingStylePreset> {
   const store = await loadWritingStyleStore(projectPath)
   const store = await loadWritingStyleStore(projectPath)
   const now = Date.now()
   const now = Date.now()
   const existingIndex = store.styles.findIndex((s) => s.sourceBook === input.sourceBook)
   const existingIndex = store.styles.findIndex((s) => s.sourceBook === input.sourceBook)
   let preset: WritingStylePreset
   let preset: WritingStylePreset
   if (existingIndex >= 0) {
   if (existingIndex >= 0) {
-    preset = { ...store.styles[existingIndex], name: input.name, profile: input.profile, updatedAt: now }
+    preset = {
+      ...store.styles[existingIndex],
+      name: input.name,
+      profile: input.profile,
+      sourceBookId: input.sourceBookId,
+      evidenceIds: input.evidenceIds,
+      updatedAt: now,
+    }
     store.styles[existingIndex] = preset
     store.styles[existingIndex] = preset
   } else {
   } else {
     preset = {
     preset = {
@@ -80,6 +95,8 @@ export async function upsertWritingStylePreset(
       name: input.name,
       name: input.name,
       sourceBook: input.sourceBook,
       sourceBook: input.sourceBook,
       profile: input.profile,
       profile: input.profile,
+      sourceBookId: input.sourceBookId,
+      evidenceIds: input.evidenceIds,
       createdAt: now,
       createdAt: now,
       updatedAt: now,
       updatedAt: now,
     }
     }
@@ -135,10 +152,20 @@ export async function buildWritingStyleContext(
     clip(preset.profile.constitution, constitutionCharLimit),
     clip(preset.profile.constitution, constitutionCharLimit),
   ]
   ]
 
 
-  if (includeSamples && preset.profile.samples.length > 0) {
+  let samples = preset.profile.samples
+  if (preset.sourceBookId && preset.evidenceIds?.length) {
+    const { loadEvidence } = await import("./book-analysis/analysis-evidence-store")
+    const collection = await loadEvidence(`${normalizePath(projectPath)}/book-analysis/${preset.sourceBookId}`)
+    const selectedIds = new Set(preset.evidenceIds)
+    samples = collection.snippets
+      .filter((item) => item.skill === "style" && item.enabled && selectedIds.has(item.id))
+      .map((item) => item.text)
+  }
+
+  if (includeSamples && samples.length > 0) {
     const sampleLines: string[] = []
     const sampleLines: string[] = []
     let used = 0
     let used = 0
-    for (const sample of preset.profile.samples) {
+    for (const sample of samples) {
       const text = sample.trim()
       const text = sample.trim()
       if (!text) continue
       if (!text) continue
       if (used + text.length > samplesCharLimit) break
       if (used + text.length > samplesCharLimit) break

+ 16 - 3
src/stores/book-analysis-import-store.ts

@@ -20,11 +20,13 @@ import type {
   BatchImportCandidate,
   BatchImportCandidate,
   BatchImportTask,
   BatchImportTask,
 } from "@/lib/novel/book-analysis/batch-import-types"
 } from "@/lib/novel/book-analysis/batch-import-types"
+import type { AnalysisSkill } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import {
 import {
   findBookLibraryEntryBySha256,
   findBookLibraryEntryBySha256,
   renameBookLibraryEntry,
   renameBookLibraryEntry,
   reconcileBookLibrary,
   reconcileBookLibrary,
 } from "@/lib/novel/book-analysis/library-store"
 } from "@/lib/novel/book-analysis/library-store"
+import { hasActiveAnalysisForBook } from "@/lib/novel/book-analysis/analysis-active-registry"
 
 
 const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
 const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
 const DELETE_BLOCKING_STATUSES = new Set(["queued", "copying", "splitting"])
 const DELETE_BLOCKING_STATUSES = new Set(["queued", "copying", "splitting"])
@@ -81,7 +83,7 @@ export interface BookAnalysisImportState {
   panelTouched: boolean
   panelTouched: boolean
   revision: number
   revision: number
   initializeProject(projectPath: string): Promise<void>
   initializeProject(projectPath: string): Promise<void>
-  createBatch(candidates: BatchImportCandidate[]): Promise<void>
+  createBatch(candidates: BatchImportCandidate[], analysisSkills?: AnalysisSkill[]): Promise<void>
   deletePublishedBook(bookId: string): Promise<void>
   deletePublishedBook(bookId: string): Promise<void>
   continueTask(taskId: string): Promise<void>
   continueTask(taskId: string): Promise<void>
   regenerateTask(taskId: string): Promise<void>
   regenerateTask(taskId: string): Promise<void>
@@ -135,6 +137,7 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
 
 
     async function runCreateBatch(
     async function runCreateBatch(
       candidates: BatchImportCandidate[],
       candidates: BatchImportCandidate[],
+      analysisSkills: AnalysisSkill[],
       projectPath: string,
       projectPath: string,
       token: number,
       token: number,
       expectedScheduler: BatchImportScheduler,
       expectedScheduler: BatchImportScheduler,
@@ -163,6 +166,7 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         id: batchId,
         id: batchId,
         projectPath,
         projectPath,
         taskIds: tasks.map((task) => task.id),
         taskIds: tasks.map((task) => task.id),
+        ...(analysisSkills.length > 0 ? { analysisSkills } : {}),
         createdAt,
         createdAt,
         updatedAt: createdAt,
         updatedAt: createdAt,
       }
       }
@@ -377,12 +381,18 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         schedulerLifecycleChain = operation.catch(() => undefined)
         schedulerLifecycleChain = operation.catch(() => undefined)
         return operation
         return operation
       },
       },
-      createBatch: (candidates) => {
+      createBatch: (candidates, analysisSkills = []) => {
         const projectPath = get().projectPath
         const projectPath = get().projectPath
         const expectedScheduler = scheduler
         const expectedScheduler = scheduler
         const token = generation
         const token = generation
         if (!projectPath || !expectedScheduler) return Promise.reject(new Error("请先初始化拆书项目"))
         if (!projectPath || !expectedScheduler) return Promise.reject(new Error("请先初始化拆书项目"))
-        const operation = createBatchChain.then(() => runCreateBatch(candidates, projectPath, token, expectedScheduler))
+        const operation = createBatchChain.then(() => runCreateBatch(
+          candidates,
+          analysisSkills,
+          projectPath,
+          token,
+          expectedScheduler,
+        ))
         createBatchChain = operation.catch(() => undefined)
         createBatchChain = operation.catch(() => undefined)
         return operation
         return operation
       },
       },
@@ -398,6 +408,9 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         ))) {
         ))) {
           throw new Error("作品正在导入或重新生成,请先取消任务后再删除。")
           throw new Error("作品正在导入或重新生成,请先取消任务后再删除。")
         }
         }
+        if (hasActiveAnalysisForBook(projectPath, bookId)) {
+          throw new Error("作品正在进行章节分析,请先暂停或取消分析后再删除。")
+        }
 
 
         const history = await deleteBookAnalysisBook(projectPath, bookId)
         const history = await deleteBookAnalysisBook(projectPath, bookId)
         if (!isCurrent(token, projectPath, currentScheduler)) return
         if (!isCurrent(token, projectPath, currentScheduler)) return

+ 261 - 0
src/stores/book-analysis-pipeline-store.ts

@@ -0,0 +1,261 @@
+import { create } from "zustand"
+import { normalizePath } from "@/lib/path-utils"
+import { useWikiStore } from "./wiki-store"
+import { loadChapterList } from "@/lib/novel/book-analysis/analysis-engine"
+import {
+  buildAnalysisChunkPlan,
+  computeAnalysisChunkCharLimit,
+} from "@/lib/novel/book-analysis/analysis-chunk-planner"
+import {
+  loadAndRecoverAnalysisTasks,
+  saveAnalysisChunk,
+  saveAnalysisTask,
+} from "@/lib/novel/book-analysis/analysis-pipeline-storage"
+import {
+  ANALYSIS_SKILL_ORDER,
+  normalizeSelectedSkills,
+  type AnalysisChapterRange,
+  type AnalysisChunkRecord,
+  type AnalysisSkill,
+  type BookAnalysisPipelineTask,
+} from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import { createAnalysisScheduler, type AnalysisScheduler } from "@/lib/novel/book-analysis/analysis-scheduler"
+import { characterAnalysisAdapter } from "@/lib/novel/book-analysis/character-analysis-adapter"
+import { storyAnalysisAdapter } from "@/lib/novel/book-analysis/story-analysis-adapter"
+import { styleAnalysisAdapter } from "@/lib/novel/book-analysis/style-analysis-adapter"
+import { clearActiveAnalysisSnapshot, setActiveAnalysisSnapshot } from "@/lib/novel/book-analysis/analysis-active-registry"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+
+let taskCounter = 0
+
+function safeTaskId(batchId: string | null, bookId: string, forceNew: boolean): string {
+  const base = `analysis-${batchId ?? "manual"}-${bookId}`.replace(/[^A-Za-z0-9_-]/g, "-")
+  if (!forceNew) return base
+  taskCounter += 1
+  return `${base}-${Date.now().toString(36)}-${taskCounter.toString(36)}`
+}
+
+function initialTask(input: {
+  projectPath: string
+  bookId: string
+  bookPath: string
+  batchId: string | null
+  selectedSkills: AnalysisSkill[]
+  forceNew: boolean
+}): BookAnalysisPipelineTask {
+  const now = Date.now()
+  const selectedSkills = normalizeSelectedSkills(input.selectedSkills)
+  const placeholder = { startOrder: 1, endOrder: 1 }
+  return {
+    version: 1,
+    id: safeTaskId(input.batchId, input.bookId, input.forceNew),
+    batchId: input.batchId,
+    projectPath: input.projectPath,
+    bookId: input.bookId,
+    bookPath: input.bookPath,
+    selectedSkills,
+    range: null,
+    status: "awaiting-range",
+    currentSkill: null,
+    modules: Object.fromEntries(ANALYSIS_SKILL_ORDER.map((skill) => [skill, {
+      skill,
+      status: selectedSkills.includes(skill) ? "pending" : "skipped",
+      range: placeholder,
+      chunkIds: [],
+      completedChunkIds: [],
+      failedChunkId: null,
+      resultPath: null,
+      analysisVersion: 1,
+      updatedAt: now,
+    }])) as unknown as BookAnalysisPipelineTask["modules"],
+    error: null,
+    createdAt: now,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: now,
+  }
+}
+
+export interface BookAnalysisPipelineState {
+  projectPath: string | null
+  tasks: BookAnalysisPipelineTask[]
+  chunks: AnalysisChunkRecord[]
+  dismissedBatchIds: string[]
+  initializeProject(projectPath: string): Promise<void>
+  createAwaitingRangeTask(input: {
+    batchId?: string | null
+    bookId: string
+    bookPath: string
+    selectedSkills: AnalysisSkill[]
+    forceNew?: boolean
+  }): Promise<BookAnalysisPipelineTask | null>
+  configureTaskRange(taskId: string, range: AnalysisChapterRange, selectedSkills?: AnalysisSkill[]): Promise<void>
+  startTask(taskId: string): Promise<void>
+  pauseTask(taskId: string): Promise<void>
+  continueTask(taskId: string): Promise<void>
+  retryFailedChunk(taskId: string, skill: AnalysisSkill, chunkId: string): Promise<void>
+  cancelTask(taskId: string): Promise<void>
+  dismissBatch(batchId: string): void
+  dispose(): Promise<void>
+}
+
+export function createBookAnalysisPipelineStore() {
+  let scheduler: AnalysisScheduler | null = null
+  let unsubscribe: (() => void) | null = null
+  let generation = 0
+
+  return create<BookAnalysisPipelineState>((set, get) => ({
+    projectPath: null,
+    tasks: [],
+    chunks: [],
+    dismissedBatchIds: [],
+    async initializeProject(rawPath) {
+      generation += 1
+      const token = generation
+      unsubscribe?.()
+      unsubscribe = null
+      await scheduler?.dispose()
+      scheduler = null
+      const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
+      if (!projectPath) throw new Error("项目路径不能为空")
+      set({ projectPath, tasks: [], chunks: [], dismissedBatchIds: [] })
+      const recovered = await loadAndRecoverAnalysisTasks(projectPath)
+      if (token !== generation || get().projectPath !== projectPath) return
+      const nextScheduler = createAnalysisScheduler({
+        adapters: {
+          characters: characterAnalysisAdapter,
+          story: storyAnalysisAdapter,
+          style: styleAnalysisAdapter,
+        },
+        llmConfig: () => resolveDefaultModel(useWikiStore.getState().llmConfig),
+      })
+      scheduler = nextScheduler
+      nextScheduler.initialize(recovered.tasks, recovered.chunks)
+      setActiveAnalysisSnapshot(projectPath, recovered.tasks)
+      unsubscribe = nextScheduler.subscribe((snapshot) => {
+        if (token !== generation || scheduler !== nextScheduler) return
+        set({ tasks: snapshot.tasks, chunks: snapshot.chunks })
+        setActiveAnalysisSnapshot(projectPath, snapshot.tasks)
+      })
+    },
+    async createAwaitingRangeTask(input) {
+      const projectPath = get().projectPath
+      if (!projectPath) throw new Error("请先初始化拆书分析项目")
+      const selectedSkills = normalizeSelectedSkills(input.selectedSkills)
+      if (selectedSkills.length === 0) return null
+      if (!input.forceNew) {
+        const existing = get().tasks.find((task) => task.batchId === (input.batchId ?? null) && task.bookId === input.bookId)
+        if (existing) return existing
+      }
+      const task = initialTask({
+        projectPath,
+        bookId: input.bookId,
+        bookPath: normalizePath(input.bookPath),
+        batchId: input.batchId ?? null,
+        selectedSkills,
+        forceNew: input.forceNew ?? false,
+      })
+      await saveAnalysisTask(task)
+      set((state) => ({ tasks: [...state.tasks, task] }))
+      setActiveAnalysisSnapshot(projectPath, [...get().tasks])
+      return task
+    },
+    async configureTaskRange(taskId, range, nextSkills) {
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!task) throw new Error("未找到分析任务")
+      const selectedSkills = normalizeSelectedSkills(nextSkills ?? task.selectedSkills)
+      if (selectedSkills.length === 0) throw new Error("请至少选择一个提取项目")
+      const chapters = await loadChapterList(task.bookPath)
+      const llmConfig = resolveDefaultModel(useWikiStore.getState().llmConfig)
+      const plan = buildAnalysisChunkPlan(
+        chapters.map((chapter) => ({ id: chapter.chapterId, order: chapter.order, wordCount: chapter.wordCount })),
+        range,
+        { maxChunkChars: computeAnalysisChunkCharLimit(llmConfig.maxContextSize) },
+      )
+      const now = Date.now()
+      const configured: BookAnalysisPipelineTask = {
+        ...task,
+        selectedSkills,
+        range,
+        status: "queued",
+        currentSkill: null,
+        error: null,
+        modules: Object.fromEntries(ANALYSIS_SKILL_ORDER.map((skill) => [skill, {
+          ...task.modules[skill],
+          status: selectedSkills.includes(skill) ? "pending" : "skipped",
+          range,
+          chunkIds: selectedSkills.includes(skill) ? plan.map((chunk) => chunk.id) : [],
+          completedChunkIds: [],
+          failedChunkId: null,
+          resultPath: selectedSkills.includes(skill) ? null : task.modules[skill].resultPath,
+          analysisVersion: task.modules[skill].analysisVersion + (selectedSkills.includes(skill) ? 1 : 0),
+          updatedAt: now,
+        }])) as unknown as BookAnalysisPipelineTask["modules"],
+        updatedAt: now,
+      }
+      const chunks = selectedSkills.flatMap((skill) => plan.map((chunk): AnalysisChunkRecord => ({
+        ...chunk,
+        version: 1,
+        taskId,
+        skill,
+        status: "pending",
+        attempts: 0,
+        resultPath: null,
+        error: null,
+        startedAt: null,
+        completedAt: null,
+        updatedAt: now,
+      })))
+      await saveAnalysisTask(configured)
+      for (const chunk of chunks) await saveAnalysisChunk(task.bookPath, chunk)
+      set((state) => ({
+        tasks: state.tasks.map((item) => item.id === taskId ? configured : item),
+        chunks: [...state.chunks.filter((chunk) => chunk.taskId !== taskId), ...chunks],
+      }))
+      setActiveAnalysisSnapshot(task.projectPath, get().tasks)
+    },
+    async startTask(taskId) {
+      const current = scheduler
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!current || !task) throw new Error("分析任务尚未初始化")
+      if (!task.range || task.status === "awaiting-range") throw new Error("请先选择章节范围")
+      const wikiState = useWikiStore.getState()
+      if (!hasUsableLlm(resolveDefaultModel(wikiState.llmConfig), wikiState.providerConfigs)) {
+        throw new Error("未配置可用模型,请先在设置中配置默认模型")
+      }
+      await current.enqueue(task, get().chunks.filter((chunk) => chunk.taskId === taskId))
+    },
+    async pauseTask(taskId) {
+      if (!scheduler) throw new Error("分析任务尚未初始化")
+      await scheduler.pauseTask(taskId)
+    },
+    async continueTask(taskId) {
+      if (!scheduler) throw new Error("分析任务尚未初始化")
+      await scheduler.continueTask(taskId)
+    },
+    async retryFailedChunk(taskId, skill, chunkId) {
+      if (!scheduler) throw new Error("分析任务尚未初始化")
+      await scheduler.retryFailedChunk(taskId, skill, chunkId)
+    },
+    async cancelTask(taskId) {
+      if (!scheduler) throw new Error("分析任务尚未初始化")
+      await scheduler.cancelTask(taskId)
+    },
+    dismissBatch(batchId) {
+      set((state) => ({ dismissedBatchIds: [...new Set([...state.dismissedBatchIds, batchId])] }))
+    },
+    async dispose() {
+      generation += 1
+      const projectPath = get().projectPath
+      unsubscribe?.()
+      unsubscribe = null
+      await scheduler?.dispose()
+      scheduler = null
+      if (projectPath) clearActiveAnalysisSnapshot(projectPath)
+      set({ projectPath: null, tasks: [], chunks: [], dismissedBatchIds: [] })
+    },
+  }))
+}
+
+export const useBookAnalysisPipelineStore = createBookAnalysisPipelineStore()