Procházet zdrojové kódy

merge: 合并拆书库功能分支 chaishugushidaotu 到 main

Mochocyang před 4 týdny
rodič
revize
c118dc9f98
51 změnil soubory, kde provedl 3434 přidání a 1618 odebrání
  1. 0 17
      package-lock.json
  2. 3 3
      src/components/chat/chat-panel.spec.tsx
  3. 1 74
      src/components/chat/chat-panel.tsx
  4. 115 1
      src/components/editor/file-preview.tsx
  5. 0 88
      src/components/layout/book-analysis-sidebar-panel.spec.tsx
  6. 0 39
      src/components/layout/book-analysis-sidebar-panel.tsx
  7. 4 0
      src/components/layout/preview-panel.tsx
  8. 143 0
      src/components/novel/book-analysis-character-skill-dialog.spec.tsx
  9. 36 3
      src/components/novel/book-analysis-character-skill-dialog.tsx
  10. 0 401
      src/components/novel/book-analysis-import-task-panel.spec.tsx
  11. 0 364
      src/components/novel/book-analysis-import-task-panel.tsx
  12. 28 0
      src/components/novel/book-analysis-library-layout.spec.tsx
  13. 190 39
      src/components/novel/book-analysis-library-layout.tsx
  14. 90 1
      src/components/novel/book-analysis-module-view.spec.tsx
  15. 73 49
      src/components/novel/book-analysis-module-view.tsx
  16. 36 0
      src/components/novel/book-analysis-view-fixes.spec.ts
  17. 1 71
      src/components/novel/book-analysis-view.spec.ts
  18. 111 107
      src/components/novel/book-analysis-view.tsx
  19. 166 0
      src/components/novel/story-map-content.spec.tsx
  20. 216 0
      src/components/settings/sections/default-model-settings-panel.tsx
  21. 76 0
      src/components/settings/sections/model-settings-section.tsx
  22. 10 239
      src/components/settings/sections/novel-section.tsx
  23. 12 20
      src/components/settings/settings-model-categories.spec.ts
  24. 22 0
      src/components/settings/settings-model-section.spec.ts
  25. 8 21
      src/components/settings/settings-view.tsx
  26. 9 4
      src/i18n/en.json
  27. 9 5
      src/i18n/zh.json
  28. 40 5
      src/lib/novel/book-analysis/analysis-scheduler.spec.ts
  29. 3 1
      src/lib/novel/book-analysis/analysis-scheduler.ts
  30. 6 1
      src/lib/novel/book-analysis/character-analysis-adapter.ts
  31. 46 2
      src/lib/novel/book-analysis/character-candidate-selection.spec.ts
  32. 32 3
      src/lib/novel/book-analysis/character-candidate-selection.ts
  33. 47 0
      src/lib/novel/book-analysis/library-state.spec.ts
  34. 5 9
      src/lib/novel/book-analysis/library-state.ts
  35. 3 3
      src/lib/novel/book-analysis/result-loader.ts
  36. 64 0
      src/lib/novel/book-analysis/skill-character-match.spec.ts
  37. 47 0
      src/lib/novel/book-analysis/skill-character-match.ts
  38. 56 4
      src/lib/novel/book-analysis/story-analysis-adapter.spec.ts
  39. 137 40
      src/lib/novel/book-analysis/story-analysis-adapter.ts
  40. 99 0
      src/lib/novel/book-analysis/story-map-history.spec.ts
  41. 102 0
      src/lib/novel/book-analysis/story-map-history.ts
  42. 118 0
      src/lib/novel/book-analysis/story-map-prompts.ts
  43. 111 0
      src/lib/novel/book-analysis/story-map-renderer.spec.ts
  44. 157 0
      src/lib/novel/book-analysis/story-map-renderer.ts
  45. 152 0
      src/lib/novel/book-analysis/story-map-types.ts
  46. 6 1
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  47. 191 0
      src/lib/novel/book-analysis/verification-engine.spec.ts
  48. 376 0
      src/lib/novel/book-analysis/verification-engine.ts
  49. 193 0
      src/lib/novel/book-analysis/verification-prompts.ts
  50. 83 0
      src/lib/novel/book-analysis/verification-types.ts
  51. 1 3
      src/stores/wiki-store.ts

+ 0 - 17
package-lock.json

@@ -11570,23 +11570,6 @@
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "license": "ISC"
     },
-    "node_modules/yaml": {
-      "version": "2.9.0",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
-      "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
-      "license": "ISC",
-      "optional": true,
-      "peer": true,
-      "bin": {
-        "yaml": "bin.mjs"
-      },
-      "engines": {
-        "node": ">= 14.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/eemeli"
-      }
-    },
     "node_modules/yocto-spinner": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz",

+ 3 - 3
src/components/chat/chat-panel.spec.tsx

@@ -14,9 +14,9 @@ describe("chat-panel de-AI skill handling", () => {
     expect(source).not.toContain("setChapterSaveStatus(deAiSkillWarning)")
   })
 
-  it("uses a skill library trigger instead of the old de-AI-only label in the chat input toolbar", () => {
-    expect(source).toContain("<DeAiSkillPicker")
-    expect(source).toContain('buttonLabel="技能库"')
+  it("chat input toolbar no longer exposes the skill library trigger", () => {
+    expect(source).not.toContain("DeAiSkillPicker")
+    expect(source).not.toContain('buttonLabel="技能库"')
     expect(source).not.toContain('title="当前启用的 Skill"')
     expect(source).not.toContain('aria-label="当前启用的 Skill"')
   })

+ 1 - 74
src/components/chat/chat-panel.tsx

@@ -1,7 +1,7 @@
 import { useRef, useEffect, useCallback, useState, useMemo, useDeferredValue, type CSSProperties } from "react"
 import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, ChevronDown, Check, History, ArrowDown } from "lucide-react"
+import { BookOpen, Plus, Trash2, MessageSquare, ListChecks, ChevronDown, Check, History, ArrowDown } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { ChatMessage, StreamingMessage } from "./chat-message"
@@ -20,7 +20,6 @@ import { useShallow } from "zustand/react/shallow"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
-import { DeAiSkillPicker } from "@/components/skill-library/de-ai-skill-picker"
 import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon"
@@ -876,7 +875,6 @@ export function ChatPanel() {
   const isConversationStreaming = useChatStore((s) => s.isConversationStreaming)
   const conversations = useChatStore((s) => s.conversations)
   const setConversationInputDraft = useChatStore((s) => s.setConversationInputDraft)
-  const setConversationDeAiSkillId = useChatStore((s) => s.setConversationDeAiSkillId)
   const pendingReferenceTokens = useChatStore((s) => s.pendingReferenceTokens)
   const consumePendingReferenceTokens = useChatStore((s) => s.consumePendingReferenceTokens)
   const outlineConversations = useOutlineChatStore((s) => s.conversations)
@@ -916,7 +914,6 @@ export function ChatPanel() {
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const setAiChatModel = useWikiStore((s) => s.setAiChatModel)
   const chatEditModeEnabled = useWikiStore((s) => s.chatEditModeEnabled)
-  const setChatEditModeEnabled = useWikiStore((s) => s.setChatEditModeEnabled)
   const selectedFile = useWikiStore((s) => s.selectedFile)
 
   const streamSessionGuardRef = useRef(createStreamSessionGuard())
@@ -2680,15 +2677,6 @@ export function ChatPanel() {
             <div className="mb-2 flex items-center justify-between gap-2">
               <TooltipProvider delay={200}>
                 <div className="flex min-w-0 items-center gap-2 overflow-x-auto">
-                  <DeAiSkillPicker
-                    value={activeConversation?.selectedDeAiSkillId}
-                    buttonLabel="技能库"
-                    showLibraryShortcut
-                    onChange={(skillId) => {
-                      const convId = useChatStore.getState().activeConversationId
-                      if (convId) setConversationDeAiSkillId(convId, skillId)
-                    }}
-                  />
                   {novelMode && (
                     <>
                       <div className="relative">
@@ -2791,67 +2779,6 @@ export function ChatPanel() {
                             : "开启后,本次写作会先创建计划,等待确认后再执行;可与标准、严格模式组合使用。"}
                         </TooltipContent>
                       </Tooltip>
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={(
-                            <Button
-                              type="button"
-                              variant="ghost"
-                              size="icon"
-                              aria-pressed={chatEditModeEnabled}
-                              className={chatEditModeEnabled ? "border-amber-500 bg-amber-50 text-amber-900 hover:bg-amber-100" : ""}
-                              onClick={() => setChatEditModeEnabled(!chatEditModeEnabled)}
-                              title="编辑章节"
-                              aria-label="编辑章节"
-                            />
-                          )}
-                        >
-                          <FileEdit className="h-4 w-4" />
-                        </TooltipTrigger>
-                        <TooltipContent side="top" className="max-w-xs leading-5">
-                          开启后,AI会话会读取当前章节或识别到的章节范围进行修改,并在写回前自动备份原内容。
-                        </TooltipContent>
-                      </Tooltip>
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={(
-                            <button
-                              type="button"
-                              onClick={() => setActiveView("storySimulation")}
-                              className={`flex h-8 shrink-0 items-center gap-1 rounded-full border px-2.5 text-xs transition-colors ${
-                                activeBinding
-                                  ? "border-purple-300 bg-purple-50 text-purple-700 hover:bg-purple-100"
-                                  : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
-                              }`}
-                            >
-                              <Drama className="h-3.5 w-3.5" />
-                              <span className="max-w-[100px] truncate">
-                                {activeBinding
-                                  ? activeBinding.framework.shortTitle || activeBinding.framework.title
-                                  : "故事框架"}
-                              </span>
-                            </button>
-                          )}
-                        />
-                        <TooltipContent side="top" className="max-w-xs leading-5">
-                          {activeBinding ? (
-                            <>
-                              <div className="font-medium">已绑定故事框架</div>
-                              <div className="mt-1 text-xs opacity-80">
-                                {activeBinding.framework.title}
-                              </div>
-                              <div className="mt-1 text-xs opacity-70">
-                                目标章节数:{activeBinding.binding.targetChapterCount}章
-                              </div>
-                              <div className="mt-1 text-xs opacity-70">
-                                点击可进入剧情推演室管理
-                              </div>
-                            </>
-                          ) : (
-                            <>未绑定故事框架,点击进入剧情推演室创建</>
-                          )}
-                        </TooltipContent>
-                      </Tooltip>
                     </>
                   )}
                 </div>

+ 115 - 1
src/components/editor/file-preview.tsx

@@ -11,6 +11,9 @@ import {
   Music,
   FileSpreadsheet,
   FileQuestion,
+  Eye,
+  Code2,
+  Save,
 } from "lucide-react"
 import { getFileCategory, getCodeLanguage } from "@/lib/file-types"
 import type { FileCategory } from "@/lib/file-types"
@@ -27,12 +30,23 @@ import { isTauri } from "@/lib/platform"
 interface FilePreviewProps {
   filePath: string
   textContent: string
+  /** 可选:非只读文件(当前为 HTML)编辑后的保存回调 */
+  onSave?: (content: string) => Promise<void> | void
 }
 
-export function FilePreview({ filePath, textContent }: FilePreviewProps) {
+function isHtmlPath(filePath: string): boolean {
+  const ext = filePath.split(".").pop()?.toLowerCase() ?? ""
+  return ext === "html" || ext === "htm"
+}
+
+export function FilePreview({ filePath, textContent, onSave }: FilePreviewProps) {
   const category = getFileCategory(filePath)
   const fileName = getFileName(filePath)
 
+  if ((category === "code" || category === "data") && isHtmlPath(filePath)) {
+    return <HtmlFilePreview filePath={filePath} content={textContent} onSave={onSave} />
+  }
+
   switch (category) {
     case "image":
       return <ImagePreview filePath={filePath} fileName={fileName} />
@@ -284,3 +298,103 @@ function BinaryPlaceholder({
     </div>
   )
 }
+
+interface HtmlFilePreviewProps {
+  filePath: string
+  content: string
+  onSave?: (content: string) => Promise<void> | void
+}
+
+/** HTML 文件预览:预览(iframe 沙箱渲染)/ 源码(可编辑保存)切换 */
+function HtmlFilePreview({ filePath, content, onSave }: HtmlFilePreviewProps) {
+  const [mode, setMode] = useState<"preview" | "source">("preview")
+  const [draft, setDraft] = useState(content)
+  const [dirty, setDirty] = useState(false)
+  const [saving, setSaving] = useState(false)
+  const [message, setMessage] = useState("")
+
+  useEffect(() => {
+    setDraft(content)
+    setDirty(false)
+    setMessage("")
+  }, [content, filePath])
+
+  const handleSave = async () => {
+    if (!onSave || saving) return
+    setSaving(true)
+    setMessage("")
+    try {
+      await onSave(draft)
+      setDirty(false)
+      setMessage("已保存")
+    } catch {
+      setMessage("保存失败,请重试")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return (
+    <div className="flex h-full min-h-0 flex-col">
+      <div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-xs text-muted-foreground">
+        <span className="truncate" title={filePath}>{filePath}</span>
+        <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase">HTML</span>
+        <div className="ml-auto flex shrink-0 items-center gap-1">
+          <button
+            type="button"
+            onClick={() => setMode("preview")}
+            className={`rounded border px-2 py-1 flex items-center gap-1 ${mode === "preview" ? "bg-accent text-accent-foreground" : "hover:bg-accent"}`}
+            title="渲染预览"
+          >
+            <Eye className="h-3.5 w-3.5" />
+            预览
+          </button>
+          <button
+            type="button"
+            onClick={() => setMode("source")}
+            className={`rounded border px-2 py-1 flex items-center gap-1 ${mode === "source" ? "bg-accent text-accent-foreground" : "hover:bg-accent"}`}
+            title="查看并编辑源码"
+          >
+            <Code2 className="h-3.5 w-3.5" />
+            源码
+          </button>
+          {onSave && (
+            <button
+              type="button"
+              onClick={handleSave}
+              disabled={!dirty || saving}
+              className="rounded border px-2 py-1 flex items-center gap-1 disabled:opacity-50 hover:bg-accent"
+              title={dirty ? "保存修改到文件" : "无修改"}
+            >
+              <Save className="h-3.5 w-3.5" />
+              {saving ? "保存中…" : "保存"}
+            </button>
+          )}
+          {message && <span className="ml-1">{message}</span>}
+        </div>
+      </div>
+      <div className="min-h-0 flex-1">
+        {mode === "preview" ? (
+          <iframe
+            key={draft.length}
+            title="HTML 预览"
+            srcDoc={draft}
+            sandbox="allow-same-origin"
+            className="h-full w-full border-0 bg-white"
+          />
+        ) : (
+          <textarea
+            value={draft}
+            onChange={(event) => {
+              setDraft(event.target.value)
+              setDirty(event.target.value !== content)
+            }}
+            spellCheck={false}
+            className="h-full w-full resize-none bg-muted/20 p-4 font-mono text-sm leading-relaxed outline-none"
+            dir="ltr"
+          />
+        )}
+      </div>
+    </div>
+  )
+}

+ 0 - 88
src/components/layout/book-analysis-sidebar-panel.spec.tsx

@@ -143,94 +143,6 @@ beforeEach(async () => {
 })
 
 describe("BookAnalysisSidebarPanel", () => {
-  it("单本导入进行中时侧栏显示导入任务面板", async () => {
-    mockImportState.batches = [{
-      version: 1,
-      id: "batch-single",
-      projectPath: "/proj",
-      taskIds: ["import-1"],
-      panelDismissedAt: null,
-      createdAt: 1,
-      updatedAt: 1,
-    }]
-    mockImportState.tasks = [{
-      version: 1,
-      id: "import-1",
-      batchId: "batch-single",
-      projectPath: "/proj",
-      originalPath: "/books/a.txt",
-      originalFileName: "a.txt",
-      cachedSourcePath: "/proj/cache/a.txt",
-      sourceSha256: null,
-      requestedTitle: "单本小说",
-      finalTitle: null,
-      bookId: "book-import-1",
-      status: "copying",
-      completed: 1,
-      total: 10,
-      error: null,
-      skipReason: null,
-      createdAt: 1,
-      startedAt: 1,
-      completedAt: null,
-      updatedAt: 1,
-    }]
-    vi.mocked(listDirectory).mockResolvedValue([])
-
-    const { cleanup } = renderPanel()
-    await flushAsync(50)
-
-    expect(document.querySelector('section[aria-label="批量导入任务"]')).not.toBeNull()
-    expect(document.body.textContent).toContain("单本小说")
-    expect(document.body.textContent).toContain("复制中")
-    cleanup()
-    await flushAsync(20)
-  })
-
-  it("单本导入失败后侧栏仍显示面板以便删除/重试", async () => {
-    mockImportState.batches = [{
-      version: 1,
-      id: "batch-failed",
-      projectPath: "/proj",
-      taskIds: ["import-failed"],
-      panelDismissedAt: null,
-      createdAt: 1,
-      updatedAt: 1,
-    }]
-    mockImportState.tasks = [{
-      version: 1,
-      id: "import-failed",
-      batchId: "batch-failed",
-      projectPath: "/proj",
-      originalPath: "/books/b.txt",
-      originalFileName: "b.txt",
-      cachedSourcePath: "/proj/cache/b.txt",
-      sourceSha256: null,
-      requestedTitle: "失败小说",
-      finalTitle: null,
-      bookId: "book-import-failed",
-      status: "failed",
-      completed: 0,
-      total: 10,
-      error: "复制失败",
-      skipReason: null,
-      createdAt: 1,
-      startedAt: 1,
-      completedAt: 2,
-      updatedAt: 2,
-    }]
-    vi.mocked(listDirectory).mockResolvedValue([])
-
-    const { cleanup } = renderPanel()
-    await flushAsync(50)
-
-    expect(document.querySelector('section[aria-label="批量导入任务"]')).not.toBeNull()
-    expect(document.body.textContent).toContain("失败小说")
-    expect(document.body.textContent).toContain("失败")
-    cleanup()
-    await flushAsync(20)
-  })
-
   it("reopens character processing for a recognition-done running task", async () => {
     mockBookAnalysisState.tasks = [{
       id: "task-recognition-done",

+ 0 - 39
src/components/layout/book-analysis-sidebar-panel.tsx

@@ -17,7 +17,6 @@ import { listCharacterAuras } from "@/lib/novel/character-aura"
 import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
 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 {
   id: string
@@ -40,33 +39,12 @@ export function BookAnalysisSidebarPanel() {
   const cancelTask = useBookAnalysisStore((s) => s.cancelTask)
   const requestReopenChapterSelection = useBookAnalysisStore((s) => s.requestReopenChapterSelection)
   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 [loading, setLoading] = useState(false)
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [bookAuraCount, setBookAuraCount] = useState<Record<string, number>>({})
   const [inputOpen, setInputOpen] = useState(false)
-  // 未 dismiss 且仍有任务的 batch 都显示(含失败/取消/完成),以便继续、删除失败、重生成、重命名
-  const visibleBatchIds = new Set(
-    importBatches
-      .filter((batch) => {
-        if (batch.panelDismissedAt) return false
-        return importTasks.some((task) => task.batchId === batch.id)
-      })
-      .map((batch) => batch.id),
-  )
-  const visibleBatches = importBatches.filter((batch) => visibleBatchIds.has(batch.id))
-  const visibleImportTasks = importTasks.filter((task) => visibleBatchIds.has(task.batchId))
 
   // 正在运行的任务:识别已完成的任务退出"运行中",不再显示 Loader2 转圈
   const runningTasks = tasks.filter((t) => t.status === "running" && t.progress.recognitionStatus !== "done")
@@ -264,23 +242,6 @@ export function BookAnalysisSidebarPanel() {
         </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">
         {loading ? (

+ 4 - 0
src/components/layout/preview-panel.tsx

@@ -1629,6 +1629,10 @@ export function PreviewPanel() {
             key={selectedFile}
             filePath={selectedFile}
             textContent={fileContent}
+            onSave={async (content) => {
+              await writeFileAtomic(selectedFile, content)
+              setFileContent(content)
+            }}
           />
         )}
       </div>

+ 143 - 0
src/components/novel/book-analysis-character-skill-dialog.spec.tsx

@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
+import { BookAnalysisCharacterSkillDialog } from "./book-analysis-character-skill-dialog"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const book = (overrides: Partial<BookAnalysisLibraryBook>): BookAnalysisLibraryBook => ({
+  id: "book-1",
+  path: "E:/Novel/book-analysis/book-1",
+  metadata: {
+    title: "测试作品",
+    totalChapters: 20,
+    totalWords: 20000,
+    sourceType: "file",
+    createdAt: 1,
+    updatedAt: 1,
+  },
+  recognizedCharacters: [],
+  characters: [],
+  skills: [],
+  styleStatus: "missing",
+  boundAurasCount: 0,
+  addedAuraCharacterIds: [],
+  evidence: [],
+  ...overrides,
+})
+
+const recognized = (overrides: Partial<BookAnalysisLibraryBook["recognizedCharacters"][number]>) => ({
+  id: "char-hanli",
+  name: "韩立",
+  aliases: [],
+  appearances: 30,
+  chapterIndices: [0, 9],
+  importanceScore: 92,
+  category: "主角" as const,
+  sourceBook: "E:/Novel/book-analysis/book-1",
+  ...overrides,
+})
+
+let host: HTMLDivElement
+let root: Root
+
+function renderDialog(target: BookAnalysisLibraryBook) {
+  act(() => {
+    root.render(
+      <BookAnalysisCharacterSkillDialog
+        book={target}
+        open
+        onOpenChange={vi.fn()}
+        onSubmit={vi.fn()}
+      />,
+    )
+  })
+  // Base UI Dialog 渲染到 body portal,需从 document.body 断言
+  return document.body.textContent ?? ""
+}
+
+describe("BookAnalysisCharacterSkillDialog 角色保存机制", () => {
+  beforeEach(() => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(() => {
+    act(() => root.unmount())
+    host.remove()
+  })
+
+  it("识别角色存在时始终展示识别角色(即使已有深度分析结果)", () => {
+    const text = renderDialog(book({
+      characters: [{
+        id: "char-hanli-deep",
+        name: "韩立",
+        aliases: [],
+        importance: 9,
+        category: "protagonist",
+        firstAppearance: 1,
+        lastAppearance: 20,
+        appearanceCount: 30,
+        description: "谨慎的凡人",
+        personality: "隐忍",
+        speechStyle: "少承诺",
+        relationships: [],
+        keyEvents: [],
+      }],
+      recognizedCharacters: [
+        recognized({ id: "char-hanli", name: "韩立", importanceScore: 92, category: "主角" }),
+        recognized({ id: "char-nanli", name: "南宫婉", importanceScore: 70, category: "配角" }),
+      ],
+    }))
+    // 用户点「选择角色生成 Skill」应看到所有识别角色
+    expect(text).toContain("韩立")
+    expect(text).toContain("南宫婉")
+    expect(text).toContain("已识别并保存的角色")
+  })
+
+  it("无识别记录时从深度分析角色推导候选", () => {
+    const text = renderDialog(book({
+      characters: [{
+        id: "char-hanli",
+        name: "韩立",
+        aliases: [],
+        importance: 9,
+        category: "protagonist",
+        firstAppearance: 1,
+        lastAppearance: 20,
+        appearanceCount: 30,
+        description: "谨慎的凡人",
+        personality: "隐忍",
+        speechStyle: "少承诺",
+        relationships: [],
+        keyEvents: [],
+      }],
+      recognizedCharacters: [],
+    }))
+    expect(text).toContain("韩立")
+    expect(text).toContain("谨慎的凡人")
+  })
+
+  it("识别角色按重要度降序展示", () => {
+    const text = renderDialog(book({
+      characters: [],
+      recognizedCharacters: [
+        recognized({ id: "char-a", name: "低分角色", importanceScore: 30 }),
+        recognized({ id: "char-b", name: "高分角色", importanceScore: 90 }),
+      ],
+    }))
+    const lowIndex = text.indexOf("低分角色")
+    const highIndex = text.indexOf("高分角色")
+    expect(highIndex).toBeGreaterThan(-1)
+    expect(lowIndex).toBeGreaterThan(highIndex)
+  })
+
+  it("既无深度分析也无识别角色时显示空提示", () => {
+    const text = renderDialog(book({}))
+    expect(text).toContain("暂无可提取角色信息")
+  })
+})

+ 36 - 3
src/components/novel/book-analysis-character-skill-dialog.tsx

@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState } from "react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
-import { selectCharacterCandidates } from "@/lib/novel/book-analysis/character-candidate-selection"
+import {
+  recognizedCharacterToExtracted,
+  selectCharacterCandidates,
+  type CharacterCandidate,
+} from "@/lib/novel/book-analysis/character-candidate-selection"
 
 interface BookAnalysisCharacterSkillDialogProps {
   book: BookAnalysisLibraryBook
@@ -14,8 +18,30 @@ interface BookAnalysisCharacterSkillDialogProps {
 
 const CATEGORY_LABELS = { protagonist: "主角", supporting: "配角", minor: "次要配角" } as const
 
+/** 已识别角色 → 弹窗候选项(深度分析未完成时的回退展示) */
+function recognizedToCandidate(character: BookAnalysisLibraryBook["recognizedCharacters"][number]): CharacterCandidate {
+  const extracted = recognizedCharacterToExtracted(character)
+  return {
+    ...extracted,
+    candidateCategory: extracted.category === "protagonist" ? "protagonist" : extracted.category === "supporting" ? "supporting" : "minor",
+    candidateScore: character.importanceScore,
+  }
+}
+
 export function BookAnalysisCharacterSkillDialog({ book, open, generating = false, onOpenChange, onSubmit }: BookAnalysisCharacterSkillDialogProps) {
-  const candidates = useMemo(() => selectCharacterCandidates(book.characters), [book.characters])
+  // fix/recognized-in-dialog:弹窗始终展示「已识别出的全部角色」,
+  // 用户点「选择角色生成 Skill」即可看到所有曾经识别并保存的角色,
+  // 勾选后点击生成会走后台自动识别/生成 Skill。
+  const candidates = useMemo(() => {
+    if ((book.recognizedCharacters?.length ?? 0) > 0) {
+      return [...book.recognizedCharacters]
+        .sort((left, right) => right.importanceScore - left.importanceScore)
+        .map(recognizedToCandidate)
+    }
+    // 兼容旧数据:无识别记录时从深度分析角色推导
+    return selectCharacterCandidates(book.characters)
+  }, [book.recognizedCharacters, book.characters])
+  const usingRecognized = (book.recognizedCharacters?.length ?? 0) > 0
   const [selectedIds, setSelectedIds] = useState<string[]>([])
 
   useEffect(() => {
@@ -32,6 +58,11 @@ export function BookAnalysisCharacterSkillDialog({ book, open, generating = fals
           <DialogTitle>选择角色生成 Skill</DialogTitle>
         </DialogHeader>
         <div className="min-h-0 flex-1 space-y-2 overflow-y-auto py-3">
+          {usingRecognized && candidates.length > 0 && (
+            <p className="text-xs text-muted-foreground">
+              以下为该作品已识别并保存的角色,可直接勾选生成 Skill;生成会在后台自动完成。
+            </p>
+          )}
           {candidates.length === 0 ? <p className="text-sm text-muted-foreground">暂无可提取角色信息。</p> : candidates.map((character) => {
             const hasSkill = book.skills.some((skill) => skill.characterId === character.id || skill.characterName === character.name)
             return (
@@ -42,7 +73,9 @@ export function BookAnalysisCharacterSkillDialog({ book, open, generating = fals
                     <span className="font-medium">{character.name}</span>
                     <span className="text-xs text-muted-foreground">{CATEGORY_LABELS[character.candidateCategory]} · 出场 {character.appearanceCount} 次</span>
                   </span>
-                  <span className="mt-1 block text-xs text-muted-foreground">{hasSkill ? "已生成 Skill,可重新生成" : character.description || "暂无角色简介"}</span>
+                  <span className="mt-1 block text-xs text-muted-foreground">
+                    {hasSkill ? "已生成 Skill,可重新生成" : character.description || (usingRecognized ? "已识别角色 · 可直接生成 Skill" : "暂无角色简介")}
+                  </span>
                 </span>
               </label>
             )

+ 0 - 401
src/components/novel/book-analysis-import-task-panel.spec.tsx

@@ -1,401 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { renderToStaticMarkup } from "react-dom/server"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { BatchImportBatch, BatchImportTask, BatchImportTaskStatus } from "@/lib/novel/book-analysis/batch-import-types"
-import { BookAnalysisImportTaskPanel } from "./book-analysis-import-task-panel"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
-
-const batch: BatchImportBatch = {
-  version: 1,
-  id: "batch-1",
-  projectPath: "E:/Novel",
-  taskIds: [],
-  createdAt: 1,
-  updatedAt: 1,
-}
-
-function createTask(status: BatchImportTaskStatus, id = `task-${status}`): BatchImportTask {
-  return {
-    version: 1,
-    id,
-    batchId: batch.id,
-    projectPath: "E:/Novel",
-    originalPath: `E:/Sources/${id}.txt`,
-    originalFileName: `${id}.txt`,
-    cachedSourcePath: `E:/Cache/${id}.txt`,
-    sourceSha256: "hash",
-    requestedTitle: `作品-${id}`,
-    finalTitle: status === "completed" ? `完成-${id}` : null,
-    bookId: `book-${id}`,
-    status,
-    completed: status === "splitting" ? 3 : 0,
-    total: status === "splitting" ? 10 : 0,
-    error: status === "failed" ? "导入失败" : null,
-    skipReason: status === "skipped" ? "作品已存在" : null,
-    createdAt: 1,
-    startedAt: status === "queued" ? null : 2,
-    completedAt: ["completed", "failed", "cancelled", "skipped"].includes(status) ? 3 : null,
-    updatedAt: 3,
-  }
-}
-
-const callbacks = () => ({
-  onCollapsedChange: vi.fn(),
-  onContinue: vi.fn(),
-  onRegenerate: vi.fn(),
-  onCancel: vi.fn(),
-  onCancelAllQueued: vi.fn(),
-  onDeleteFailed: vi.fn(),
-  onRenameCompleted: vi.fn(),
-  onOpenBook: vi.fn(),
-})
-
-let host: HTMLDivElement
-let root: Root
-
-beforeEach(() => {
-  host = document.createElement("div")
-  document.body.appendChild(host)
-  root = createRoot(host)
-})
-
-afterEach(() => {
-  act(() => root.unmount())
-  host.remove()
-  vi.restoreAllMocks()
-})
-
-function renderPanel(tasks: BatchImportTask[], props = callbacks(), collapsed = false) {
-  act(() => {
-    root.render(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
-        tasks={tasks}
-        collapsed={collapsed}
-        {...props}
-      />,
-    )
-  })
-  return props
-}
-
-function clickButton(label: string) {
-  const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
-  expect(button, `找不到按钮:${label}`).toBeTruthy()
-  act(() => button?.click())
-}
-
-describe("BookAnalysisImportTaskPanel", () => {
-  it("没有任务时不渲染", () => {
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[]}
-        tasks={[]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).toBe("")
-  })
-
-  it("统计运行、等待和失败任务,并显示各状态允许的按钮", () => {
-    const tasks = [
-      createTask("queued"),
-      createTask("copying"),
-      createTask("splitting"),
-      createTask("interrupted"),
-      createTask("failed"),
-      createTask("cancelled"),
-      createTask("skipped"),
-      createTask("completed"),
-    ]
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
-        tasks={tasks}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).toContain("批量导入任务")
-    expect(html).toContain("进行中 2")
-    expect(html).toContain("等待 1")
-    expect(html).toContain("失败 1")
-    expect(html).toContain("中断 1")
-    expect(html).toContain("完成 1")
-    expect(html).toContain("跳过 1")
-    expect(html).toContain("取消 1")
-    const document = new DOMParser().parseFromString(html, "text/html")
-    const panel = document.querySelector('section[aria-label="批量导入任务"]')
-    expect(panel?.classList.contains("min-h-0")).toBe(true)
-    expect(panel?.classList.contains("max-h-[45%]")).toBe(true)
-    expect(panel?.classList.contains("overflow-hidden")).toBe(true)
-    expect(panel?.classList.contains("shrink-0")).toBe(false)
-    const statusSummary = panel?.querySelector('[aria-live="polite"]')
-    expect(statusSummary).not.toBeNull()
-    const taskList = document.querySelector("#book-analysis-import-task-list")
-    expect(taskList?.classList.contains("min-h-0")).toBe(true)
-    expect(taskList?.classList.contains("flex-1")).toBe(true)
-    expect(taskList?.classList.contains("overflow-y-auto")).toBe(true)
-    expect((html.match(/>取消</g) ?? []).length).toBe(3)
-    expect((html.match(/>继续</g) ?? []).length).toBe(2)
-    expect((html.match(/>重新生成</g) ?? []).length).toBe(3)
-    expect((html.match(/>打开作品</g) ?? []).length).toBe(1)
-    expect(html).not.toContain("作品-task-skipped</span><button")
-  })
-
-  it("按任务 batchId 与批次 taskIds 双重校验并去重渲染", () => {
-    const validTask = createTask("queued", "valid-task")
-    const duplicateTask = { ...validTask, requestedTitle: "重复记录不应覆盖 Map 最终值" }
-    const mismatchedTask = { ...createTask("queued", "mismatched-task"), batchId: "batch-other" }
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: [validTask.id, validTask.id, mismatchedTask.id] }]}
-        tasks={[validTask, duplicateTask, mismatchedTask]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    const document = new DOMParser().parseFromString(html, "text/html")
-    const renderedTitles = Array.from(document.querySelectorAll("article .font-medium")).map(
-      (element) => element.textContent,
-    )
-    expect(renderedTitles.filter((title) => title === "重复记录不应覆盖 Map 最终值")).toHaveLength(1)
-    expect(renderedTitles.filter((title) => title === "作品-mismatched-task")).toHaveLength(1)
-    expect((html.match(/取消全部等待任务/g) ?? []).length).toBe(1)
-  })
-
-  it("未归组的等待任务不显示批量取消", () => {
-    const orphanTask = { ...createTask("queued", "orphan-task"), batchId: "missing-batch" }
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[]}
-        tasks={[orphanTask]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).toContain("作品-orphan-task")
-    expect(html).not.toContain("取消全部等待任务")
-  })
-
-  it("批次引用缺失任务时显示去重后的缺失数量且不显示完成汇总", () => {
-    const completedTask = createTask("completed", "completed-present")
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: [completedTask.id, "missing-1", "missing-1", "missing-2"] }]}
-        tasks={[completedTask]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).toContain("批次记录缺少 2 个任务")
-    expect(html).not.toContain("批次完成")
-  })
-  it("批次结束后汇总成功、跳过、失败和取消数量", () => {
-    const tasks = [
-      createTask("completed", "completed-1"),
-      createTask("completed", "completed-2"),
-      createTask("skipped", "skipped-1"),
-      createTask("failed", "failed-1"),
-      createTask("cancelled", "cancelled-1"),
-    ]
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
-        tasks={tasks}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).toContain("批次完成")
-    expect(html).toContain("成功 2")
-    expect(html).toContain("跳过 1")
-    expect(html).toContain("失败 1")
-    expect(html).toContain("取消 1")
-  })
-
-  it("批次存在中断任务时不显示完成汇总", () => {
-    const interruptedTask = createTask("interrupted")
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: [interruptedTask.id] }]}
-        tasks={[interruptedTask]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    expect(html).not.toContain("批次完成")
-  })
-  it("截断错误显示并通过 title 保留完整错误", () => {
-    const longError = "读取源文件失败:" + "路径或文件内容异常".repeat(20)
-    const failedTask = { ...createTask("failed"), error: longError }
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: [failedTask.id] }]}
-        tasks={[failedTask]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-
-    const document = new DOMParser().parseFromString(html, "text/html")
-    const errorElement = document.querySelector(`[title="${longError}"]`)
-    expect(errorElement).not.toBeNull()
-    expect(errorElement?.getAttribute("title")).toBe(longError)
-    expect(errorElement?.classList.contains("truncate")).toBe(true)
-    const taskElement = errorElement?.closest("article")
-    const descriptionId = taskElement?.getAttribute("aria-describedby")
-    expect(descriptionId).toBeTruthy()
-    const fullErrorElement = descriptionId ? document.getElementById(descriptionId) : null
-    expect(fullErrorElement?.classList.contains("sr-only")).toBe(true)
-    expect(fullErrorElement?.textContent).toBe(longError)
-  })
-
-  it.each([
-    ["queued", "取消", "onCancel"],
-    ["copying", "取消", "onCancel"],
-    ["splitting", "取消", "onCancel"],
-    ["interrupted", "继续", "onContinue"],
-    ["interrupted", "重新生成", "onRegenerate"],
-    ["failed", "继续", "onContinue"],
-    ["failed", "重新生成", "onRegenerate"],
-    ["cancelled", "重新生成", "onRegenerate"],
-  ] as const)("%s 状态点击%s调用%s", (status, label, callbackName) => {
-    const task = createTask(status)
-    const props = renderPanel([task])
-    const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
-    expect(button?.getAttribute("aria-label")).toContain(task.finalTitle || task.requestedTitle)
-
-    clickButton(label)
-
-    expect(props[callbackName]).toHaveBeenCalledWith(`task-${status}`)
-  })
-
-  it("拆分进度提供 progressbar 语义和值", () => {
-    const task = createTask("splitting")
-    const html = renderToStaticMarkup(
-      <BookAnalysisImportTaskPanel
-        batches={[{ ...batch, taskIds: [task.id] }]}
-        tasks={[task]}
-        collapsed={false}
-        {...callbacks()}
-      />,
-    )
-    const document = new DOMParser().parseFromString(html, "text/html")
-    const progress = document.querySelector('[role="progressbar"]')
-
-    expect(progress?.getAttribute("aria-valuemin")).toBe("0")
-    expect(progress?.getAttribute("aria-valuemax")).toBe("10")
-    expect(progress?.getAttribute("aria-valuenow")).toBe("3")
-    expect(progress?.getAttribute("aria-label")).toContain(task.requestedTitle)
-  })
-  it("completed 状态打开作品,skipped 状态没有操作按钮", () => {
-    const completedTask = createTask("completed")
-    const props = renderPanel([completedTask])
-    const openButton = Array.from(host.querySelectorAll("button")).find(
-      (button) => button.textContent?.trim() === "打开作品",
-    )
-    expect(openButton?.getAttribute("aria-label")).toContain(completedTask.finalTitle)
-    clickButton("打开作品")
-    expect(props.onOpenBook).toHaveBeenCalledWith("book-task-completed")
-
-    renderPanel([createTask("skipped")], props)
-    expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual([
-      "收起",
-    ])
-  })
-
-  it("确认后删除失败任务", () => {
-    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
-    const task = createTask("failed")
-    const props = renderPanel([task])
-
-    clickButton("删除")
-
-    expect(confirm).toHaveBeenCalledWith("确定删除导入失败的任务《作品-task-failed》吗?")
-    expect(props.onDeleteFailed).toHaveBeenCalledWith(task.id)
-  })
-
-  it("已完成任务通过行内输入触发重命名", () => {
-    const task = createTask("completed")
-    const props = renderPanel([task])
-
-    clickButton("重命名")
-    const input = host.querySelector(`input[aria-label="输入作品《${task.finalTitle}》的新名称"]`) as HTMLInputElement
-    expect(input).not.toBeNull()
-    expect(input.value).toBe(task.finalTitle)
-    act(() => {
-      Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "  新名字  ")
-      input.dispatchEvent(new Event("input", { bubbles: true }))
-    })
-    clickButton("保存")
-
-    expect(props.onRenameCompleted).toHaveBeenCalledWith(task.id, "新名字")
-  })
-
-  it("行内重命名可以取消且不会提交", () => {
-    const task = createTask("completed")
-    const props = renderPanel([task])
-
-    clickButton("重命名")
-    clickButton("取消")
-
-    expect(host.querySelector("input")).toBeNull()
-    expect(props.onRenameCompleted).not.toHaveBeenCalled()
-  })
-
-  it("折叠状态完全受控,任务进度更新不会自行展开", () => {
-    const props = renderPanel([createTask("splitting")])
-    expect(host.textContent).toContain("作品-task-splitting")
-    const collapseButton = Array.from(host.querySelectorAll("button")).find(
-      (button) => button.getAttribute("aria-label") === "收起导入任务",
-    )
-    expect(collapseButton?.getAttribute("aria-controls")).toBe("book-analysis-import-task-list")
-    expect(host.querySelector("#book-analysis-import-task-list")).not.toBeNull()
-
-    clickButton("收起")
-    expect(props.onCollapsedChange).toHaveBeenCalledWith(true)
-
-    renderPanel([{ ...createTask("splitting"), completed: 8 }], props, true)
-    expect(host.textContent).not.toContain("作品-task-splitting")
-    expect(props.onCollapsedChange).toHaveBeenCalledTimes(1)
-
-    clickButton("展开")
-    expect(props.onCollapsedChange).toHaveBeenLastCalledWith(false)
-  })
-
-  it("确认后取消当前批次的全部等待任务", () => {
-    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
-    const props = renderPanel([createTask("queued")])
-
-    const cancelAllButton = Array.from(host.querySelectorAll("button")).find(
-      (button) => button.textContent?.trim() === "取消全部等待任务",
-    )
-    expect(cancelAllButton?.getAttribute("aria-label")).toContain("作品-task-queued")
-    clickButton("取消全部等待任务")
-
-    expect(confirm).toHaveBeenCalledWith("确定取消当前批次的全部等待任务吗?")
-    expect(props.onCancelAllQueued).toHaveBeenCalledWith("batch-1")
-  })
-
-  it("拒绝确认时不取消全部等待任务", () => {
-    vi.spyOn(window, "confirm").mockReturnValue(false)
-    const props = renderPanel([createTask("queued")])
-
-    clickButton("取消全部等待任务")
-
-    expect(props.onCancelAllQueued).not.toHaveBeenCalled()
-  })
-})

+ 0 - 364
src/components/novel/book-analysis-import-task-panel.tsx

@@ -1,364 +0,0 @@
-import { useState } from "react"
-import { ChevronDown, ChevronRight } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import type {
-  BatchImportBatch,
-  BatchImportTask,
-  BatchImportTaskStatus,
-} from "@/lib/novel/book-analysis/batch-import-types"
-
-interface BookAnalysisImportTaskPanelProps {
-  batches: BatchImportBatch[]
-  tasks: BatchImportTask[]
-  collapsed: boolean
-  onCollapsedChange: (value: boolean) => void
-  onContinue: (taskId: string) => void
-  onRegenerate: (taskId: string) => void
-  onCancel: (taskId: string) => void
-  onCancelAllQueued: (batchId: string) => void
-  onDeleteFailed: (taskId: string) => void
-  onRenameCompleted: (taskId: string, title: string) => void
-  onOpenBook: (bookId: string) => void
-}
-interface TaskGroup {
-  batchId?: string
-  tasks: BatchImportTask[]
-  missingCount: number
-}
-
-const taskListId = "book-analysis-import-task-list"
-
-const statusLabels: Record<BatchImportTaskStatus, string> = {
-  queued: "等待中",
-  copying: "复制中",
-  splitting: "拆分中",
-  interrupted: "已中断",
-  failed: "失败",
-  cancelled: "已取消",
-  skipped: "已跳过",
-  completed: "已完成",
-}
-
-const terminalStatuses = new Set<BatchImportTaskStatus>(["failed", "cancelled", "skipped", "completed"])
-
-function getTaskTitle(task: BatchImportTask) {
-  return task.finalTitle || task.requestedTitle
-}
-
-function getErrorDescriptionId(taskId: string) {
-  return `book-analysis-import-task-error-${taskId.replace(/[^a-zA-Z0-9_-]/g, "-")}`
-}
-
-function TaskActions({
-  task,
-  onContinue,
-  onRegenerate,
-  onCancel,
-  onOpenBook,
-  onDeleteFailed,
-  onRenameCompleted,
-}: Pick<BookAnalysisImportTaskPanelProps, "onContinue" | "onRegenerate" | "onCancel" | "onOpenBook" | "onDeleteFailed" | "onRenameCompleted"> & {
-  task: BatchImportTask
-}) {
-  const title = getTaskTitle(task)
-  const [renaming, setRenaming] = useState(false)
-  const [renameValue, setRenameValue] = useState("")
-
-  const submitRename = () => {
-    const nextTitle = renameValue.trim()
-    if (!nextTitle || nextTitle === title) return
-    onRenameCompleted(task.id, nextTitle)
-    setRenaming(false)
-  }
-
-  if (["queued", "copying", "splitting"].includes(task.status)) {
-    return (
-      <Button size="xs" variant="outline" aria-label={`取消作品《${title}》`} onClick={() => onCancel(task.id)}>
-        取消
-      </Button>
-    )
-  }
-
-  if (task.status === "interrupted" || task.status === "failed") {
-    return (
-      <div className="flex shrink-0 items-center gap-1">
-        <Button size="xs" variant="outline" aria-label={`继续作品《${title}》`} onClick={() => onContinue(task.id)}>
-          继续
-        </Button>
-        <Button
-          size="xs"
-          variant="outline"
-          aria-label={`重新生成作品《${title}》`}
-          onClick={() => onRegenerate(task.id)}
-        >
-          重新生成
-        </Button>
-        {task.status === "failed" && (
-          <Button
-            size="xs"
-            variant="outline"
-            aria-label={`删除失败任务《${title}》`}
-            onClick={() => {
-              if (window.confirm(`确定删除导入失败的任务《${title}》吗?`)) onDeleteFailed(task.id)
-            }}
-          >
-            删除
-          </Button>
-        )}
-      </div>
-    )
-  }
-
-  if (task.status === "cancelled") {
-    return (
-      <Button
-        size="xs"
-        variant="outline"
-        aria-label={`重新生成作品《${title}》`}
-        onClick={() => onRegenerate(task.id)}
-      >
-        重新生成
-      </Button>
-    )
-  }
-
-  if (task.status === "completed") {
-    if (renaming) {
-      return (
-        <form
-          className="flex min-w-0 shrink items-center gap-1"
-          onSubmit={(event) => {
-            event.preventDefault()
-            submitRename()
-          }}
-        >
-          <Input
-            autoFocus
-            className="h-7 w-40"
-            value={renameValue}
-            aria-label={`输入作品《${title}》的新名称`}
-            onChange={(event) => setRenameValue(event.target.value)}
-            onKeyDown={(event) => {
-              if (event.key === "Escape") {
-                event.preventDefault()
-                setRenaming(false)
-              }
-            }}
-          />
-          <Button size="xs" type="submit" disabled={!renameValue.trim() || renameValue.trim() === title}>
-            保存
-          </Button>
-          <Button size="xs" type="button" variant="ghost" onClick={() => setRenaming(false)}>
-            取消
-          </Button>
-        </form>
-      )
-    }
-    return (
-      <div className="flex shrink-0 items-center gap-1">
-        <Button
-          size="xs"
-          variant="outline"
-          aria-label={`重命名作品《${title}》`}
-          onClick={() => {
-            setRenameValue(title)
-            setRenaming(true)
-          }}
-        >
-          重命名
-        </Button>
-        <Button size="xs" variant="outline" aria-label={`打开作品《${title}》`} onClick={() => onOpenBook(task.bookId)}>
-          打开作品
-        </Button>
-      </div>
-    )
-  }
-
-  return null
-}
-
-function BatchSummary({ tasks, missingCount }: { tasks: BatchImportTask[]; missingCount: number }) {
-  if (missingCount > 0 || tasks.length === 0 || tasks.some((task) => !terminalStatuses.has(task.status))) return null
-
-  const count = (status: BatchImportTaskStatus) => tasks.filter((task) => task.status === status).length
-  return (
-    <p className="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
-      批次完成 · 成功 {count("completed")} · 跳过 {count("skipped")} · 失败 {count("failed")} · 取消 {count("cancelled")}
-    </p>
-  )
-}
-function buildTaskGroups(batches: BatchImportBatch[], tasks: BatchImportTask[]) {
-  const taskMap = new Map(tasks.map((task) => [task.id, task]))
-  const batchMap = new Map(batches.map((batch) => [batch.id, batch]))
-  const groupedTaskIds = new Set<string>()
-  const groups: TaskGroup[] = []
-
-  for (const batch of batchMap.values()) {
-    const taskIds = [...new Set(batch.taskIds)]
-    const groupTasks: BatchImportTask[] = []
-    let missingCount = 0
-
-    for (const taskId of taskIds) {
-      const task = taskMap.get(taskId)
-      if (!task || task.batchId !== batch.id) {
-        missingCount += 1
-        continue
-      }
-      if (!groupedTaskIds.has(task.id)) {
-        groupedTaskIds.add(task.id)
-        groupTasks.push(task)
-      }
-    }
-
-    if (groupTasks.length > 0 || missingCount > 0) {
-      groups.push({ batchId: batch.id, tasks: groupTasks, missingCount })
-    }
-  }
-
-  const ungroupedTasks = [...taskMap.values()].filter((task) => !groupedTaskIds.has(task.id))
-  if (ungroupedTasks.length > 0) {
-    groups.push({ tasks: ungroupedTasks, missingCount: 0 })
-  }
-
-  return { groups, uniqueTasks: [...taskMap.values()] }
-}
-
-export function BookAnalysisImportTaskPanel({
-  batches,
-  tasks,
-  collapsed,
-  onCollapsedChange,
-  onContinue,
-  onRegenerate,
-  onCancel,
-  onCancelAllQueued,
-  onDeleteFailed,
-  onRenameCompleted,
-  onOpenBook,
-}: BookAnalysisImportTaskPanelProps) {
-  if (tasks.length === 0) return null
-
-  const { groups, uniqueTasks } = buildTaskGroups(batches, tasks)
-  const count = (statuses: BatchImportTaskStatus[]) => uniqueTasks.filter((task) => statuses.includes(task.status)).length
-
-  return (
-    <section
-      className="flex min-h-0 max-h-[45%] flex-col overflow-hidden border-b bg-background px-3 py-3"
-      aria-label="批量导入任务"
-    >
-      <div className="flex shrink-0 flex-col gap-2">
-        <div className="flex items-center justify-between gap-2">
-          <h3 className="shrink-0 whitespace-nowrap text-sm font-semibold">批量导入任务</h3>
-          <Button
-            size="sm"
-            variant="ghost"
-            className="h-7 shrink-0 px-2"
-            aria-label={collapsed ? "展开导入任务" : "收起导入任务"}
-            aria-controls={taskListId}
-            aria-expanded={!collapsed}
-            onClick={() => onCollapsedChange(!collapsed)}
-          >
-            {collapsed ? <ChevronRight /> : <ChevronDown />}
-            {collapsed ? "展开" : "收起"}
-          </Button>
-        </div>
-        <p className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground" aria-live="polite">
-          <span>进行中 {count(["copying", "splitting"])}</span>
-          <span>等待 {count(["queued"])}</span>
-          <span>失败 {count(["failed"])}</span>
-          <span>中断 {count(["interrupted"])}</span>
-          <span>完成 {count(["completed"])}</span>
-          <span>跳过 {count(["skipped"])}</span>
-          <span>取消 {count(["cancelled"])}</span>
-        </p>
-      </div>
-
-      {!collapsed && (
-        <div id={taskListId} className="mt-3 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
-          {groups.map((group, groupIndex) => {
-            const batchId = group.batchId
-            const queuedTasks = group.tasks.filter((task) => task.status === "queued")
-            const queuedCount = queuedTasks.length
-            return (
-              <div key={batchId ?? `ungrouped-${groupIndex}`} className="space-y-2">
-                {batchId && queuedCount > 0 && (
-                  <div className="flex justify-end">
-                    <Button
-                      size="xs"
-                      variant="outline"
-                      aria-label={`取消全部等待作品:${queuedTasks.map(getTaskTitle).join("、")}`}
-                      onClick={() => {
-                        if (window.confirm("确定取消当前批次的全部等待任务吗?")) {
-                          onCancelAllQueued(batchId)
-                        }
-                      }}
-                    >
-                      取消全部等待任务
-                    </Button>
-                  </div>
-                )}
-                {group.missingCount > 0 && (
-                  <p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
-                    批次记录缺少 {group.missingCount} 个任务
-                  </p>
-                )}
-                {group.tasks.map((task) => {
-                  const title = getTaskTitle(task)
-                  const errorDescriptionId = task.error ? getErrorDescriptionId(task.id) : undefined
-                  return (
-                    <article
-                      key={task.id}
-                      className="flex items-center gap-3 rounded-md border px-3 py-2"
-                      aria-describedby={errorDescriptionId}
-                    >
-                      <div className="min-w-0 flex-1">
-                        <div className="flex min-w-0 items-center gap-2">
-                          <span className="truncate text-sm font-medium">{title}</span>
-                          <span className="shrink-0 text-xs text-muted-foreground">{statusLabels[task.status]}</span>
-                          {(task.status === "copying" || task.status === "splitting") && task.total > 0 && (
-                            <span
-                              className="shrink-0 text-xs text-muted-foreground"
-                              role="progressbar"
-                              aria-label={`${title}导入进度`}
-                              aria-valuemin={0}
-                              aria-valuemax={task.total}
-                              aria-valuenow={task.completed}
-                            >
-                              {task.completed}/{task.total}
-                            </span>
-                          )}
-                        </div>
-                        {task.error && (
-                          <>
-                            <p className="mt-1 truncate text-xs text-destructive" title={task.error} aria-hidden="true">
-                              {task.error}
-                            </p>
-                            <span id={errorDescriptionId} className="sr-only">{task.error}</span>
-                          </>
-                        )}
-                        {!task.error && task.skipReason && (
-                          <p className="mt-1 truncate text-xs text-muted-foreground" title={task.skipReason}>{task.skipReason}</p>
-                        )}
-                      </div>
-                      <TaskActions
-                        task={task}
-                        onContinue={onContinue}
-                        onRegenerate={onRegenerate}
-                        onCancel={onCancel}
-                        onOpenBook={onOpenBook}
-                        onDeleteFailed={onDeleteFailed}
-                        onRenameCompleted={onRenameCompleted}
-                      />
-                    </article>
-                  )
-                })}
-                {batchId && <BatchSummary tasks={group.tasks} missingCount={group.missingCount} />}
-              </div>
-            )
-          })}
-        </div>
-      )}
-    </section>
-  )
-}

+ 28 - 0
src/components/novel/book-analysis-library-layout.spec.tsx

@@ -153,4 +153,32 @@ describe("BookAnalysisLibraryLayout", () => {
     expect(html).toContain("韩立")
     expect(html).toContain("重新提取")
   })
+
+  it("提供 onOpenStoryMap 时渲染「打开故事导图」入口", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisLibraryLayout
+        state={state}
+        selectedBookId="book-1"
+        selectedCharacterId="char-hanli"
+        extractingStyle={false}
+        extractingCharacters={false}
+        addingToSoul={false}
+        importTaskPanel={<div>批量导入任务</div>}
+        storyFrameworks={storyFrameworks}
+        onSelectBook={vi.fn()}
+        onSelectCharacter={vi.fn()}
+        onImportNovel={vi.fn()}
+        onExtractStyle={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextractCharacters={vi.fn()}
+        extractingStoryFramework={false}
+        onExtractStoryFramework={vi.fn()}
+        onCreateOutlineFromFramework={vi.fn()}
+        onOpenStoryMap={vi.fn()}
+        onDeleteBook={vi.fn()}
+      />,
+    )
+    expect(html).toContain("打开故事导图")
+  })
 })

+ 190 - 39
src/components/novel/book-analysis-library-layout.tsx

@@ -1,5 +1,9 @@
 import type { ReactNode } from "react"
+import { useEffect, useState } from "react"
 import { Button } from "@/components/ui/button"
+import { readFile } from "@/commands/fs"
+import { joinPath } from "@/lib/path-utils"
+import { listStoryMapHistory } from "@/lib/novel/book-analysis/story-map-history"
 import type { BookAnalysisLibraryState } from "@/lib/novel/book-analysis/library-state"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type {
@@ -9,7 +13,149 @@ import type {
   BookAnalysisPipelineTask,
 } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { BookAnalysisActiveContext } from "./book-analysis-active-context"
-import { BookAnalysisModuleView } from "./book-analysis-module-view"
+import { BookAnalysisModuleView, type BookAnalysisModuleTab } from "./book-analysis-module-view"
+
+interface StoryMapCardData {
+  id: string
+  title: string
+  mainline: string
+  createdAt: number
+  startOrder: number
+  endOrder: number
+  chapterCount: number
+  mainEventCount: number
+  branchCount: number
+  html: string | null
+}
+
+/**
+ * 拆书故事导图展示:读取 story-maps/ 下全部历史导图,按最新在前逐一渲染卡片;
+ * 每张卡片含大标题 + 预览,点击「查看全部」展开到完整高度阅读。
+ * 兼容旧数据:若只有根目录 story-map.html(无历史目录),按单张方式展示。
+ */
+export function StoryMapContent({ bookPath, refreshKey = 0 }: { bookPath: string; refreshKey?: number }) {
+  const [cards, setCards] = useState<StoryMapCardData[] | null>(null)
+  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
+
+  useEffect(() => {
+    let cancelled = false
+    setCards(null)
+    setExpandedIds(new Set())
+    void (async () => {
+      try {
+        const history = await listStoryMapHistory(bookPath)
+        const loaded: StoryMapCardData[] = []
+        for (const entry of history) {
+          const html = await readFile(entry.htmlPath).catch(() => null)
+          const map = entry.map
+          const orders = map.chapters.map((chapter) => chapter.order)
+          loaded.push({
+            id: entry.dirName,
+            title: map.bookTitle,
+            mainline: map.mainLineLabel,
+            createdAt: map.createdAt,
+            startOrder: orders.length ? Math.min(...orders) : 0,
+            endOrder: orders.length ? Math.max(...orders) : 0,
+            chapterCount: map.chapters.length,
+            mainEventCount: map.chapters.reduce((sum, chapter) => sum + chapter.mainEvents.length, 0),
+            branchCount: map.chapters.reduce((sum, chapter) => sum + chapter.branches.length, 0),
+            html,
+          })
+        }
+        if (cancelled) return
+        if (loaded.length > 0) {
+          // 新的在前
+          setCards(loaded.reverse())
+          return
+        }
+        // 旧格式兼容:仅根目录 story-map.html(未走历史目录生成的老数据)
+        const legacyHtml = await readFile(joinPath(bookPath, "story-map.html")).catch(() => null)
+        if (cancelled) return
+        if (legacyHtml) {
+          setCards([{
+            id: "legacy",
+            title: "",
+            mainline: "",
+            createdAt: 0,
+            startOrder: 0,
+            endOrder: 0,
+            chapterCount: 0,
+            mainEventCount: 0,
+            branchCount: 0,
+            html: legacyHtml,
+          }])
+        } else {
+          setCards([])
+        }
+      } catch {
+        if (!cancelled) setCards([])
+      }
+    })()
+    return () => { cancelled = true }
+  }, [bookPath, refreshKey])
+
+  const toggleExpand = (id: string) => {
+    setExpandedIds((prev) => {
+      const next = new Set(prev)
+      if (next.has(id)) next.delete(id)
+      else next.add(id)
+      return next
+    })
+  }
+
+  if (cards === null) {
+    return <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">正在加载故事导图…</div>
+  }
+  if (cards.length === 0) {
+    return (
+      <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
+        尚未提取故事导图。请先分析「故事」技能生成导图。
+      </div>
+    )
+  }
+  return (
+    <div className="space-y-4" aria-label="故事导图历史列表">
+      {cards.map((card) => {
+        const expanded = expandedIds.has(card.id)
+        return (
+          <div key={card.id} className="overflow-hidden rounded-md border bg-white">
+            <div className="flex items-start justify-between gap-3 border-b bg-background px-4 py-3">
+              <div className="min-w-0">
+                <div className="truncate text-base font-semibold">
+                  {card.title ? `《${card.title}》故事导图` : "故事导图"}
+                  {card.mainline ? (
+                    <span className="ml-2 text-sm font-medium text-primary">主线:{card.mainline}</span>
+                  ) : null}
+                </div>
+                {card.createdAt > 0 && (
+                  <div className="mt-1 text-xs text-muted-foreground">
+                    生成于 {new Date(card.createdAt).toLocaleString("zh-CN")}
+                    {card.chapterCount > 0
+                      ? ` | 覆盖第 ${card.startOrder}~${card.endOrder} 章 | 主线 ${card.mainEventCount} 条 · 分支 ${card.branchCount} 条`
+                      : ""}
+                  </div>
+                )}
+              </div>
+              <Button size="sm" variant="outline" onClick={() => toggleExpand(card.id)}>
+                {expanded ? "收起" : "查看全部"}
+              </Button>
+            </div>
+            {card.html ? (
+              <iframe
+                title={`故事导图预览-${card.id}`}
+                srcDoc={card.html}
+                sandbox="allow-same-origin"
+                className={`w-full border-0 ${expanded ? "h-[70vh]" : "h-[300px]"}`}
+              />
+            ) : (
+              <div className="p-4 text-sm text-muted-foreground">该历史导图的 HTML 缺失,无法预览。</div>
+            )}
+          </div>
+        )
+      })}
+    </div>
+  )
+}
 
 interface BookAnalysisLibraryLayoutProps {
   state: BookAnalysisLibraryState
@@ -24,12 +170,21 @@ interface BookAnalysisLibraryLayoutProps {
   analysisTask?: BookAnalysisPipelineTask | null
   analysisChunks?: AnalysisChunkRecord[]
   analysisProgresses?: Record<string, AnalysisRuntimeProgress>
+  /** 同书全部分析任务(含并行运行的多个);用于分别显示各自的并行进度 */
+  analysisTasks?: BookAnalysisPipelineTask[]
+  /** 并行任务各自的控制:暂停/继续/重试/取消(按任务 id 定位) */
+  onParallelPauseTask?: (taskId: string) => void
+  onParallelContinueTask?: (taskId: string) => void
+  onParallelRetryTask?: (taskId: string) => void
+  onParallelCancelTask?: (taskId: string) => void
   onSelectBook: (bookId: string) => void
   onSelectCharacter: (characterId: string) => void
   onImportNovel: () => void
   onExtractStyle: () => void
   onExtractStoryFramework: () => void
   onCreateOutlineFromFramework?: (frameworkId: string) => void
+  /** 打开该作品生成的故事导图(story-map.html);未提取时传 undefined 隐藏入口 */
+  onOpenStoryMap?: (bookPath: string) => void
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
   onOpenSkillSelection?: () => void
@@ -37,12 +192,13 @@ interface BookAnalysisLibraryLayoutProps {
   onReextractSkill?: (skill: AnalysisSkill) => void
   onConfigureAnalysisTask?: () => void
   onSelectAnalysisCharacters?: () => void
-  onPauseAnalysisTask?: () => void
-  onContinueAnalysisTask?: () => void
-  onRetryAnalysisTask?: () => void
   onRetryAnalysisChunk?: (skill: AnalysisSkill, chunkId: string) => void
-  onCancelAnalysisTask?: () => void
   onDeleteBook: (bookId: string) => void
+  /** 受控页签:任务完成「查看结果」时可从外部切换到对应 Skill 页签 */
+  analysisActiveTab?: BookAnalysisModuleTab
+  onAnalysisActiveTabChange?: (tab: BookAnalysisModuleTab) => void
+  /** 故事导图刷新键:故事任务完成后递增,触发 StoryMapContent 重新读取历史导图 */
+  storyMapRefreshKey?: number
 }
 
 export function BookAnalysisLibraryLayout({
@@ -51,12 +207,16 @@ export function BookAnalysisLibraryLayout({
   selectedCharacterId,
   extractingStyle,
   addingToSoul,
-  storyFrameworks = [],
   analysisTask,
   analysisChunks = [],
   analysisProgresses = {},
+  analysisTasks = [],
+  onParallelPauseTask,
+  onParallelContinueTask,
+  onParallelRetryTask,
+  onParallelCancelTask,
   onSelectCharacter,
-  onCreateOutlineFromFramework,
+  onOpenStoryMap,
   onToggleStyle,
   onAddSelectedSkillsToSoul,
   onOpenSkillSelection,
@@ -66,11 +226,10 @@ export function BookAnalysisLibraryLayout({
   onReextractSkill,
   onConfigureAnalysisTask,
   onSelectAnalysisCharacters,
-  onPauseAnalysisTask,
-  onContinueAnalysisTask,
-  onRetryAnalysisTask,
   onRetryAnalysisChunk,
-  onCancelAnalysisTask,
+  analysisActiveTab,
+  onAnalysisActiveTabChange,
+  storyMapRefreshKey,
 }: BookAnalysisLibraryLayoutProps) {
   const selectedBook = state.books.find((book) => book.id === selectedBookId) ?? state.books[0] ?? null
 
@@ -83,40 +242,30 @@ export function BookAnalysisLibraryLayout({
   }
 
   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>
-              <h3 className="font-medium">{framework.title}</h3>
-              <p className="mt-1 text-xs text-muted-foreground">{framework.line === "main" ? "主线" : "支线"} · 覆盖 {framework.rangeChapterIds.length} 章</p>
-            </div>
-            {onCreateOutlineFromFramework && <Button variant="outline" size="sm" onClick={() => onCreateOutlineFromFramework(framework.id)}>基于此框架创建章纲</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>
-        </article>
-      ))}
-    </section>
+    <StoryMapContent bookPath={selectedBook.path} refreshKey={storyMapRefreshKey} />
   )
 
   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>
+          <div className="flex items-start justify-between gap-3">
+            <div>
+              <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>
+            </div>
+            {onOpenStoryMap ? (
+              <Button variant="outline" size="sm" onClick={() => onOpenStoryMap(selectedBook.path)}>
+                打开故事导图
+              </Button>
+            ) : null}
+          </div>
         </header>
         <BookAnalysisModuleView
         book={selectedBook}
         task={analysisTask}
+        tasks={analysisTasks}
         chunks={analysisChunks}
         progresses={analysisProgresses}
         selectedCharacterId={selectedCharacterId}
@@ -135,11 +284,13 @@ export function BookAnalysisLibraryLayout({
         }}
         onConfigureTask={onConfigureAnalysisTask}
         onSelectCharacters={onSelectAnalysisCharacters}
-        onPauseTask={onPauseAnalysisTask}
-        onContinueTask={onContinueAnalysisTask}
-        onRetryTask={onRetryAnalysisTask}
+        onPauseTask={onParallelPauseTask}
+        onContinueTask={onParallelContinueTask}
+        onRetryTask={onParallelRetryTask}
         onRetryChunk={onRetryAnalysisChunk}
-        onCancelTask={onCancelAnalysisTask}
+        onCancelTask={onParallelCancelTask}
+        activeTab={analysisActiveTab}
+        onActiveTabChange={onAnalysisActiveTabChange}
         />
       </main>
       <BookAnalysisActiveContext enabledStyle={state.enabledStyle} bindings={state.bindings} />

+ 90 - 1
src/components/novel/book-analysis-module-view.spec.tsx

@@ -2,7 +2,7 @@ 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"
+import { analysisTabForSkills, BookAnalysisModuleView } from "./book-analysis-module-view"
 
 function moduleState(skill: AnalysisModuleState["skill"], status: AnalysisModuleState["status"]): AnalysisModuleState {
   return {
@@ -68,6 +68,34 @@ const book: BookAnalysisLibraryBook = {
 }
 
 describe("BookAnalysisModuleView 分析进度", () => {
+  it("analysisTabForSkills 按任务技能路由结果页签(故事优先,其次文风,最后角色)", () => {
+    expect(analysisTabForSkills(["story"])).toBe("story")
+    expect(analysisTabForSkills(["characters", "story"])).toBe("story")
+    expect(analysisTabForSkills(["style"])).toBe("style")
+    expect(analysisTabForSkills(["characters", "style"])).toBe("style")
+    expect(analysisTabForSkills(["characters"])).toBe("characters")
+    expect(analysisTabForSkills([])).toBe("characters")
+  })
+
+  it("受控页签:activeTab 由外部控制时渲染对应页签内容", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={null}
+        activeTab="story"
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+        storyContent={<div data-testid="story-content">故事导图内容</div>}
+      />,
+    )
+    expect(html).toContain("故事导图内容")
+  })
+
   it("当前任务状态优先于旧 manifest,并显示区块与下一步", () => {
     const html = renderToStaticMarkup(
       <BookAnalysisModuleView
@@ -307,6 +335,67 @@ describe("BookAnalysisModuleView 分析进度", () => {
     expect(html).toContain("取消任务")
   })
 
+  it("并行任务中,角色等待选择的入口仍显示(即使 props.task 不是该任务)", () => {
+    const awaitingCharactersTask: BookAnalysisPipelineTask = {
+      ...task,
+      id: "task-awaiting",
+      selectedSkills: ["characters"],
+      status: "awaiting-character-selection",
+      currentSkill: null,
+      recognizedCharacters: [{
+        id: "char-1",
+        name: "林远",
+        aliases: [],
+        appearances: 2,
+        chapterIndices: [0],
+        importanceScore: 90,
+        category: "主角",
+        sourceBook: "book-1",
+      }],
+      modules: {
+        characters: moduleState("characters", "pending"),
+        story: moduleState("story", "skipped"),
+        style: moduleState("style", "skipped"),
+      },
+      // 旧任务(更早更新时间),故不作为 selectedPipelineTask
+      updatedAt: 1,
+    }
+    const runningStoryTask: BookAnalysisPipelineTask = {
+      ...task,
+      id: "task-story",
+      selectedSkills: ["story"],
+      status: "running",
+      currentSkill: "story",
+      modules: {
+        characters: moduleState("characters", "skipped"),
+        story: moduleState("story", "running"),
+        style: moduleState("style", "skipped"),
+      },
+      // 更近更新时间,被选中为 props.task
+      updatedAt: 99,
+    }
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={runningStoryTask}
+        tasks={[awaitingCharactersTask, runningStoryTask]}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+        onSelectCharacters={vi.fn()}
+        onCancelTask={vi.fn()}
+      />,
+    )
+
+    expect(html).toContain("待选择角色")
+    expect(html).toContain("已识别 1 个角色")
+    expect(html).toContain("选择角色")
+  })
+
   it("角色识别中展示进度条", () => {
     const awaitingTask: BookAnalysisPipelineTask = {
       ...task,

+ 73 - 49
src/components/novel/book-analysis-module-view.tsx

@@ -12,11 +12,20 @@ import {
 import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
 import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 
-type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
+export type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
+
+/** 任务完成后「查看结果」路由到对应 Skill 页签:故事优先,其次文风,最后角色。 */
+export function analysisTabForSkills(skills: Array<string>): BookAnalysisModuleTab {
+  if (skills.includes("story")) return "story"
+  if (skills.includes("style")) return "style"
+  return "characters"
+}
 
 interface BookAnalysisModuleViewProps {
   book: BookAnalysisLibraryBook
   task?: BookAnalysisPipelineTask | null
+  /** 同书全部分析任务(含并行运行的不同 Skill 任务);用于按当前 Skill 标签选中对应任务 */
+  tasks?: BookAnalysisPipelineTask[]
   chunks?: AnalysisChunkRecord[]
   progresses?: Record<string, AnalysisRuntimeProgress>
   selectedCharacterId: string | null
@@ -32,11 +41,11 @@ interface BookAnalysisModuleViewProps {
   onReextract: (skill: AnalysisSkill) => void
   onConfigureTask?: () => void
   onSelectCharacters?: () => void
-  onPauseTask?: () => void
-  onContinueTask?: () => void
-  onRetryTask?: () => void
+  onPauseTask?: (taskId: string) => void
+  onContinueTask?: (taskId: string) => void
+  onRetryTask?: (taskId: string) => void
   onRetryChunk?: (skill: AnalysisSkill, chunkId: string) => void
-  onCancelTask?: () => void
+  onCancelTask?: (taskId: string) => void
 }
 
 function RuntimeProgressBar({ progress, label }: { progress: AnalysisRuntimeProgress; label?: string }) {
@@ -138,13 +147,25 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
   }
 
   const skill = active === "evidence" ? null : active
-  const currentTaskModule = skill && props.task?.selectedSkills.includes(skill)
-    ? props.task.modules[skill]
+  // 当前 Skill 标签对应的分析任务:优先取同书中「包含该 Skill 且仍在进行」的最新任务,
+  // 使角色/故事/文风并行任务在各目的标签页内分别显示,而非集中在最上方。
+  const activeTask = (() => {
+    if (!skill) return props.task
+    const matching = (props.tasks ?? []).filter(
+      (t) => t.selectedSkills.includes(skill) && ["queued", "running", "paused", "failed", "awaiting-character-selection"].includes(t.status),
+    )
+    if (matching.length > 0) {
+      return matching.sort((a, b) => b.updatedAt - a.updatedAt)[0]
+    }
+    return props.task
+  })()
+  const currentTaskModule = skill && activeTask?.selectedSkills.includes(skill)
+    ? activeTask.modules[skill]
     : null
   const moduleState = skill ? currentTaskModule ?? props.book.analysisManifest?.modules[skill] : null
-  const skillChunks = skill && props.task
+  const skillChunks = skill && activeTask
     ? (props.chunks ?? [])
-        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === skill)
+        .filter((chunk) => chunk.taskId === activeTask?.id && chunk.skill === skill)
         .sort((left, right) => left.startOrder - right.startOrder)
     : []
   const completed = skillChunks.filter((chunk) => chunk.status === "completed").length
@@ -154,37 +175,40 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
   const tabActiveChunkIndex = tabActiveChunk
     ? skillChunks.findIndex((chunk) => chunk.id === tabActiveChunk.id) + 1
     : 0
-  const currentSkill = props.task?.currentSkill
-  const taskBusy = props.task && ["queued", "running", "paused", "failed"].includes(props.task.status)
+  const currentSkill = activeTask?.currentSkill
+  const taskBusy = activeTask && ["queued", "running", "paused", "failed"].includes(activeTask.status)
   const progresses = props.progresses ?? {}
-  const currentSkillChunks = props.task && currentSkill
+  // 以「当前 Skill 标签」为基准计算进度(而非任务的 currentSkill),
+  // 这样点哪个 Skill,那个标签页内就显示该 Skill 自己的任务进度。
+  const focusSkill = skill ?? currentSkill
+  const currentSkillChunks = activeTask && focusSkill
     ? (props.chunks ?? [])
-        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === currentSkill)
+        .filter((chunk) => chunk.taskId === activeTask?.id && chunk.skill === focusSkill)
         .sort((left, right) => left.startOrder - right.startOrder)
     : []
   const currentSkillCompleted = currentSkillChunks.filter((chunk) => chunk.status === "completed").length
-  const currentSkillTotal = props.task && currentSkill
-    ? (props.task.modules[currentSkill].chunkIds.length || currentSkillChunks.length)
+  const currentSkillTotal = activeTask && focusSkill
+    ? (activeTask.modules[focusSkill].chunkIds.length || currentSkillChunks.length)
     : 0
   const phaseRuntimeProgress = (() => {
-    if (!props.task || !currentSkill) return null
+    if (!activeTask || !focusSkill) return null
     for (const phase of ["aggregate", "publish"] as const) {
-      const phaseProgress = progresses[analysisProgressKey(props.task.id, currentSkill, phase)]
+      const phaseProgress = progresses[analysisProgressKey(activeTask.id, focusSkill, phase)]
       if (phaseProgress) return phaseProgress
     }
     return null
   })()
-  const runningChunkProgresses = props.task && currentSkill
+  const runningChunkProgresses = activeTask && focusSkill
     ? currentSkillChunks
         .filter((chunk) => chunk.status === "running")
-        .map((chunk) => progresses[analysisProgressKey(props.task!.id, currentSkill, chunk.id)])
+        .map((chunk) => progresses[analysisProgressKey(activeTask!.id, focusSkill, chunk.id)])
         .filter((progress): progress is NonNullable<typeof progress> => Boolean(progress))
     : []
   const activeRuntimeProgress = phaseRuntimeProgress
     ?? runningChunkProgresses[0]
     ?? (() => {
-      if (!props.task || !currentSkill) return null
-      const prefix = `${props.task.id}:${currentSkill}:`
+      if (!activeTask || !focusSkill) return null
+      const prefix = `${activeTask.id}:${focusSkill}:`
       return Object.entries(progresses).find(([key]) => key.startsWith(prefix))?.[1] ?? null
     })()
   // chunk 阶段占 0–90%;aggregate/publish 直接使用 phase 百分比(适配器报 92–100)
@@ -223,17 +247,17 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
         ))}
       </div>
       <div className="min-h-0 flex-1 overflow-y-auto p-5">
-        {taskBusy && props.task && (
-          <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"}`}>
+        {taskBusy && activeTask && (
+          <section className={`mb-4 rounded-md border p-3 ${activeTask.status === "failed" ? "border-destructive/40 bg-destructive/5" : "border-primary/30 bg-primary/5"}`}>
             <div className="space-y-1 text-sm">
               <div className="font-medium">
-                分析任务{taskStatusLabel(props.task.status)}
+                分析任务{taskStatusLabel(activeTask.status)}
                 {currentSkill ? ` · 当前:${SKILL_LABELS[currentSkill]}` : ""}
               </div>
-              {props.task.selectedSkills.length > 0 && (
+              {activeTask.selectedSkills.length > 0 && (
                 <div className="flex flex-wrap gap-1.5 pt-1" aria-label="各 Skill 状态">
-                  {props.task.selectedSkills.map((item) => {
-                    const module = props.task!.modules[item]
+                  {activeTask.selectedSkills.map((item) => {
+                    const module = activeTask!.modules[item]
                     const isCurrent = currentSkill === item
                     return (
                       <button
@@ -255,12 +279,12 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                 </div>
               )}
             </div>
-            {props.task.error && (
+            {activeTask.error && (
               <div role="alert" className="mt-2 break-words text-sm text-destructive">
-                失败原因:{props.task.error}
+                失败原因:{activeTask.error}
               </div>
             )}
-            {props.task.status === "running" && (
+            {activeTask.status === "running" && (
               <div className="mt-3 space-y-1.5">
                 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
                   <span>
@@ -288,37 +312,37 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
               </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>
+              {activeTask.status === "running" && props.onPauseTask && (
+                <Button size="sm" variant="outline" onClick={() => props.onPauseTask!(activeTask.id)}>暂停</Button>
               )}
-              {props.task.status === "paused" && props.onContinueTask && (
-                <Button size="sm" onClick={props.onContinueTask}>
+              {activeTask.status === "paused" && props.onContinueTask && (
+                <Button size="sm" onClick={() => props.onContinueTask!(activeTask.id)}>
                   从断点继续(已完成 {currentSkillCompleted}/{currentSkillTotal || "?"} 区块)
                 </Button>
               )}
-              {props.task.status === "failed" && props.onRetryTask && (
-                <Button size="sm" onClick={props.onRetryTask}>重试当前步骤</Button>
+              {activeTask.status === "failed" && props.onRetryTask && (
+                <Button size="sm" onClick={() => props.onRetryTask!(activeTask.id)}>重试当前步骤</Button>
               )}
-              {["awaiting-character-selection", "queued", "running", "paused", "failed"].includes(props.task.status) && props.onCancelTask && (
-                <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
+              {["awaiting-character-selection", "queued", "running", "paused", "failed"].includes(activeTask.status) && props.onCancelTask && (
+                <Button size="sm" variant="outline" onClick={() => props.onCancelTask!(activeTask.id)}>取消任务</Button>
               )}
             </div>
           </section>
         )}
 
-        {props.task?.status === "awaiting-range" && (
+        {activeTask?.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 className="mt-1 text-xs text-muted-foreground">已选择 {activeTask.selectedSkills.length} 个提取项目</div>
             </div>
             <Button size="sm" onClick={props.onConfigureTask}>选择章节</Button>
           </div>
         )}
 
-        {props.task?.status === "awaiting-character-selection" && (() => {
-          const recognitionProgress = progresses[analysisProgressKey(props.task.id, "characters", "recognition")]
-          const hasRecognized = (props.task.recognizedCharacters?.length ?? 0) > 0
+        {activeTask?.status === "awaiting-character-selection" && (() => {
+          const recognitionProgress = progresses[analysisProgressKey(activeTask.id, "characters", "recognition")]
+          const hasRecognized = (activeTask.recognizedCharacters?.length ?? 0) > 0
           return (
             <div className="mb-4 rounded-md border border-primary/30 bg-primary/5 p-3">
               <div className="flex items-start justify-between gap-3">
@@ -328,7 +352,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                   </div>
                   <div className="mt-1 text-xs text-muted-foreground">
                     {hasRecognized
-                      ? `已识别 ${props.task.recognizedCharacters!.length} 个角色,请勾选后开始深度分析`
+                      ? `已识别 ${activeTask!.recognizedCharacters!.length} 个角色,请勾选后开始深度分析`
                       : recognitionProgress
                         ? (recognitionProgress.currentItem
                           ? `范围:${recognitionProgress.currentItem}`
@@ -343,8 +367,8 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                   {props.onSelectCharacters && hasRecognized && (
                     <Button size="sm" onClick={props.onSelectCharacters}>选择角色</Button>
                   )}
-                  {props.onCancelTask && (
-                    <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
+                  {props.onCancelTask && activeTask && (
+                    <Button size="sm" variant="outline" onClick={() => props.onCancelTask!(activeTask.id)}>取消任务</Button>
                   )}
                 </div>
               </div>
@@ -374,7 +398,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                     当前区块:第 {tabActiveChunkIndex}/{skillChunks.length} 个(第 {tabActiveChunk.startOrder}~{tabActiveChunk.endOrder} 章)
                   </div>
                 )}
-                {skill && currentSkill === skill && activeRuntimeProgress && (
+                {skill && activeTask && activeRuntimeProgress && (
                   <RuntimeProgressBar progress={activeRuntimeProgress} label={`${SKILL_LABELS[skill]} 当前阶段`} />
                 )}
               </div>
@@ -389,8 +413,8 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                 </div>
                 <div className="space-y-1.5">
                   {skillChunks.map((chunk) => {
-                    const chunkProgress = props.task
-                      ? progresses[analysisProgressKey(props.task.id, skill, chunk.id)]
+                    const chunkProgress = activeTask
+                      ? progresses[analysisProgressKey(activeTask.id, skill, chunk.id)]
                       : undefined
                     return (
                       <div

+ 36 - 0
src/components/novel/book-analysis-view-fixes.spec.ts

@@ -0,0 +1,36 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "book-analysis-view.tsx"), "utf8")
+
+describe("book-analysis-view 三个修复点的接线(fix/view-result-routing / fix/recognized-persist)", () => {
+  it("问题1:识别角色生成 Skill 前先持久化为角色档案", () => {
+    expect(source).toContain("recognizedCharacterToExtracted")
+    expect(source).toContain("persistCharacterToDisk")
+    expect(source).toContain("selectedLibraryBook.recognizedCharacters")
+  })
+
+  it("问题3:任务完成 Toast「查看结果」按技能路由到对应页签", () => {
+    expect(source).toContain("analysisTabForSkills(task.selectedSkills)")
+    expect(source).toContain("setModuleActiveTab(tab)")
+    // Toast 点击时先定位任务所属作品
+    expect(source).toContain("handleSelectBook(task.bookId)")
+  })
+
+  it("问题3:角色任务完成 Toast 定位作品后打开角色弹窗(不再打开旧查看器)", () => {
+    expect(source).toContain('label: "打开角色信息"')
+    expect(source).toContain("setCharacterSkillDialogOpen(true)")
+  })
+
+  it("问题3:故事任务完成递增刷新键,故事页签自动重读历史导图", () => {
+    expect(source).toContain("setStoryMapRefreshKey((key) => key + 1)")
+    expect(source).toContain("storyMapRefreshKey={storyMapRefreshKey}")
+  })
+
+  it("受控页签状态接入布局", () => {
+    expect(source).toContain('useState<BookAnalysisModuleTab>("characters")')
+    expect(source).toContain("analysisActiveTab={moduleActiveTab}")
+    expect(source).toContain("onAnalysisActiveTabChange={setModuleActiveTab}")
+  })
+})

+ 1 - 71
src/components/novel/book-analysis-view.spec.ts

@@ -39,14 +39,7 @@ const mocks = vi.hoisted(() => {
 
   const initializeProject = vi.fn()
   const createBatch = vi.fn()
-  const continueTask = vi.fn()
-  const regenerateTask = vi.fn()
-  const cancelImportTask = vi.fn()
-  const cancelAllQueued = vi.fn()
-  const deleteFailedTask = vi.fn()
-  const renameCompletedTask = vi.fn()
   const dispose = vi.fn()
-  const setPanelCollapsed = vi.fn()
   const triggerSidebarRefresh = vi.fn()
   const setSelectedLibraryBookId = vi.fn()
   const setCurrentResult = vi.fn()
@@ -99,25 +92,11 @@ const mocks = vi.hoisted(() => {
       revision: 0,
       initializeProject,
       createBatch,
-      continueTask,
-      regenerateTask,
-      cancelTask: cancelImportTask,
-      cancelAllQueued,
-      deleteFailedTask,
-      renameCompletedTask,
-      setPanelCollapsed,
       dispose,
     },
     initializeProject,
     createBatch,
-    continueTask,
-    regenerateTask,
-    cancelImportTask,
-    cancelAllQueued,
-    deleteFailedTask,
-    renameCompletedTask,
     dispose,
-    setPanelCollapsed,
     triggerSidebarRefresh,
     setSelectedLibraryBookId,
     setCurrentResult,
@@ -130,7 +109,6 @@ const mocks = vi.hoisted(() => {
     reloadedLibraryState: libraryState,
     libraryParams: null as any,
     inputDialogProps: null as any,
-    taskPanelProps: null as any,
     layoutProps: null as any,
     chapterPanelProps: null as any,
   }
@@ -211,25 +189,6 @@ vi.mock("./book-analysis-input-dialog", async () => {
   }
 })
 
-vi.mock("./book-analysis-import-task-panel", async () => {
-  const React = await import("react")
-  return {
-    BookAnalysisImportTaskPanel: (props: any) => {
-      mocks.taskPanelProps = props
-      return React.createElement("div", { "data-testid": "批量任务面板" },
-        React.createElement("button", { type: "button", onClick: () => props.onCollapsedChange(true) }, "收起任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onContinue("task-continue") }, "继续任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onRegenerate("task-regenerate") }, "重新生成任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onCancel("task-cancel") }, "取消任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onCancelAllQueued("batch-1") }, "取消批次等待"),
-        React.createElement("button", { type: "button", onClick: () => props.onDeleteFailed("task-failed") }, "删除失败任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onRenameCompleted("task-completed", "新名字") }, "重命名完成任务"),
-        React.createElement("button", { type: "button", onClick: () => props.onOpenBook("book-1") }, "打开导入作品"),
-      )
-    },
-  }
-})
-
 vi.mock("./book-analysis-library-layout", async () => {
   const React = await import("react")
   return {
@@ -359,20 +318,12 @@ beforeEach(() => {
   mocks.reloadedLibraryState = mocks.libraryState
   mocks.libraryParams = null
   mocks.inputDialogProps = null
-  mocks.taskPanelProps = null
   mocks.layoutProps = null
   mocks.chapterPanelProps = null
 
   mocks.initializeProject.mockReset().mockResolvedValue(undefined)
   mocks.createBatch.mockReset().mockResolvedValue(undefined)
-  mocks.continueTask.mockReset().mockResolvedValue(undefined)
-  mocks.regenerateTask.mockReset().mockResolvedValue(undefined)
-  mocks.cancelImportTask.mockReset().mockResolvedValue(undefined)
-  mocks.cancelAllQueued.mockReset().mockResolvedValue(undefined)
-  mocks.deleteFailedTask.mockReset().mockResolvedValue(undefined)
-  mocks.renameCompletedTask.mockReset().mockResolvedValue(undefined)
   mocks.dispose.mockReset().mockResolvedValue(undefined)
-  mocks.setPanelCollapsed.mockReset()
   mocks.triggerSidebarRefresh.mockReset().mockImplementation(() => { mocks.oldState.sidebarRefreshCounter += 1 })
   mocks.setSelectedLibraryBookId.mockReset().mockImplementation((id: string | null) => { mocks.oldState.selectedLibraryBookId = id })
   mocks.setCurrentResult.mockReset()
@@ -517,29 +468,8 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
     expect(mocks.inputDialogProps.open).toBe(true)
   })
 
-  it("任务面板把每个动作的任务或批次 ID 传给批量 Store", async () => {
-    await renderView()
-
-    await clickButton("收起任务")
-    await clickButton("继续任务")
-    await clickButton("重新生成任务")
-    await clickButton("取消任务")
-    await clickButton("取消批次等待")
-    await clickButton("删除失败任务")
-    await clickButton("重命名完成任务")
-
-    expect(mocks.setPanelCollapsed).toHaveBeenCalledWith(true)
-    expect(mocks.continueTask).toHaveBeenCalledWith("task-continue")
-    expect(mocks.regenerateTask).toHaveBeenCalledWith("task-regenerate")
-    expect(mocks.cancelImportTask).toHaveBeenCalledWith("task-cancel")
-    expect(mocks.cancelAllQueued).toHaveBeenCalledWith("batch-1")
-    expect(mocks.deleteFailedTask).toHaveBeenCalledWith("task-failed")
-    expect(mocks.renameCompletedTask).toHaveBeenCalledWith("task-completed", "新名字")
-  })
-
   it("打开 A 后普通选择 B 不回跳并同步状态", async () => {
     await renderView()
-    await clickButton("打开导入作品")
     await clickButton("选择角色")
     await clickButton("选择普通作品B")
     await rerenderView()
@@ -547,7 +477,7 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
     expect(mocks.oldState.selectedLibraryBookId).toBe("book-2")
     expect(mocks.setCurrentResult).toHaveBeenLastCalledWith(expect.objectContaining({ bookId: "book-2" }))
     expect(mocks.layoutProps.selectedCharacterId).toBeNull()
-    expect(mocks.clearRecognition).toHaveBeenCalledTimes(2)
+    expect(mocks.clearRecognition).toHaveBeenCalledTimes(1)
   })
 
   it("旧角色、文风、框架和历史结果入口仍可触发", async () => {

+ 111 - 107
src/components/novel/book-analysis-view.tsx

@@ -1,7 +1,6 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { Button } from "@/components/ui/button"
 import { BookAnalysisInputDialog } from "./book-analysis-input-dialog"
-import { BookAnalysisImportTaskPanel } from "./book-analysis-import-task-panel"
 import { BookAnalysisLibraryLayout } from "./book-analysis-library-layout"
 import { BookAnalysisResultViewer } from "./book-analysis-result-viewer"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
@@ -21,6 +20,7 @@ import { toast } from "@/lib/toast"
 import { BookOpen, Check, Loader2, Plus, X } from "lucide-react"
 import type {
   AnalysisDepth,
+  ExtractedCharacter,
   SixDimensionProgressItem,
   SixDimensionStatus,
   RecognizedCharacter,
@@ -48,8 +48,11 @@ import { BookAnalysisCharacterSkillDialog } from "./book-analysis-character-skil
 import { CharacterSelectionPanel } from "./character-selection-panel"
 import { OutlineCreatorDialog } from "./outline-editor"
 import { generateSkillsForCharacters } from "@/lib/novel/book-analysis/skill-generator"
-import { selectCharacterCandidates } from "@/lib/novel/book-analysis/character-candidate-selection"
-import { loadBookAnalysisResult } from "@/lib/novel/book-analysis/result-loader"
+import {
+  recognizedCharacterToExtracted,
+  selectCharacterCandidates,
+} from "@/lib/novel/book-analysis/character-candidate-selection"
+import { analysisTabForSkills, type BookAnalysisModuleTab } from "./book-analysis-module-view"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { saveRecognizedCharacters } from "@/lib/novel/book-analysis/recognized-character-store"
@@ -114,8 +117,14 @@ export function BookAnalysisView() {
     selectedIds: string[]
   } | null>(null)
   const [pipelineCharacterConfirming, setPipelineCharacterConfirming] = useState(false)
+  // fix/view-result-routing:任务完成「查看结果」路由到对应 Skill 页签(受控页签)
+  const [moduleActiveTab, setModuleActiveTab] = useState<BookAnalysisModuleTab>("characters")
+  // fix/view-result-routing:故事任务完成后递增,触发故事页签重新读取历史导图
+  const [storyMapRefreshKey, setStoryMapRefreshKey] = useState(0)
 
   const currentProject = useWikiStore((s) => s.project)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const storeSelectedBookId = useBookAnalysisStore((s) => s.selectedLibraryBookId)
   const sidebarRefreshCounter = useBookAnalysisStore((s) => s.sidebarRefreshCounter)
   const pendingRecognitionTaskId = useBookAnalysisStore((s) => s.pendingRecognitionTaskId)
@@ -127,17 +136,9 @@ export function BookAnalysisView() {
   const setShowResultViewer = useBookAnalysisStore((s) => s.setShowResultViewer)
   const importBatches = useBookAnalysisImportStore((s) => s.batches)
   const importTasks = useBookAnalysisImportStore((s) => s.tasks)
-  const importPanelCollapsed = useBookAnalysisImportStore((s) => s.panelCollapsed)
   const importRevision = useBookAnalysisImportStore((s) => s.revision)
   const initializeImportProject = useBookAnalysisImportStore((s) => s.initializeProject)
   const createImportBatch = useBookAnalysisImportStore((s) => s.createBatch)
-  const continueImportTask = useBookAnalysisImportStore((s) => s.continueTask)
-  const regenerateImportTask = useBookAnalysisImportStore((s) => s.regenerateTask)
-  const cancelImportTask = useBookAnalysisImportStore((s) => s.cancelTask)
-  const cancelAllQueuedImportTasks = useBookAnalysisImportStore((s) => s.cancelAllQueued)
-  const deleteFailedImportTask = useBookAnalysisImportStore((s) => s.deleteFailedTask)
-  const renameCompletedImportTask = useBookAnalysisImportStore((s) => s.renameCompletedTask)
-  const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const pipelineTasks = useBookAnalysisPipelineStore((s) => s.tasks)
   const pipelineChunks = useBookAnalysisPipelineStore((s) => s.chunks)
   const pipelineProgresses = useBookAnalysisPipelineStore((s) => s.progresses)
@@ -225,13 +226,39 @@ export function BookAnalysisView() {
 
   const handleGenerateSelectedCharacterSkills = useCallback(async (characterIds: string[]) => {
     if (!selectedLibraryBook || characterIds.length === 0) return
-    const selected = selectCharacterCandidates(selectedLibraryBook.characters).filter((character) => characterIds.includes(character.id))
-    if (selected.length === 0) return
+    // fix/generate-in-background:立即关闭弹窗,生成转入后台执行,
+    // 避免多个角色逐个 LLM 生成时长长地卡在「正在生成」弹窗里。
     setCharacterSkillGenerating(true)
+    setCharacterSkillDialogOpen(false)
     try {
+      const extractedCandidates = selectCharacterCandidates(selectedLibraryBook.characters)
+      let selected: ExtractedCharacter[]
+      if ((selectedLibraryBook.recognizedCharacters?.length ?? 0) > 0) {
+        // fix/recognized-in-dialog:弹窗展示的是「已识别角色」,按 id 选出;
+        // 优先复用该角色的深度分析结果(数据更全),没有则用识别角色兜底。
+        selected = selectedLibraryBook.recognizedCharacters
+          .filter((character) => characterIds.includes(character.id))
+          .map((recognized) =>
+            extractedCandidates.find((candidate) => candidate.name === recognized.name)
+              ?? recognizedCharacterToExtracted(recognized),
+          )
+        // 持久化为角色档案(characters/{id}.json),之后弹窗/角色面板直接可读
+        if (selected.length > 0) {
+          const { persistCharacterToDisk } = await import("@/lib/novel/book-analysis/character-disk-store")
+          for (const character of selected) {
+            await persistCharacterToDisk(selectedLibraryBook.path, character)
+          }
+        }
+      } else {
+        // 兼容旧数据:无识别记录时从深度分析角色推导
+        selected = extractedCandidates.filter((character) => characterIds.includes(character.id))
+      }
+      if (selected.length === 0) {
+        toast.info("没有匹配到可生成的角色。")
+        return
+      }
       await generateSkillsForCharacters(selected, selectedLibraryBook.metadata, selectedLibraryBook.path, llmConfig)
       await reloadLibraryState()
-      setCharacterSkillDialogOpen(false)
       toast.success(`已生成 ${selected.length} 个角色 Skill,可在角色面板中查看并加入自定义灵魂库。`)
     } catch (error) {
       toast.error(`角色 Skill 生成失败:${error instanceof Error ? error.message : String(error)}`)
@@ -271,6 +298,16 @@ export function BookAnalysisView() {
     reloadLibraryState,
   })
 
+  const handleSelectBook = useCallback((bookId: string) => {
+    const book = libraryState.books.find((item) => item.id === bookId)
+    setSelectedBookId(bookId)
+    setSelectedCharacterId(null)
+    clearRecognition()
+    const analysisStore = useBookAnalysisStore.getState()
+    analysisStore.setSelectedLibraryBookId(bookId)
+    if (book) analysisStore.setCurrentResult(toBookAnalysisResult(book))
+  }, [clearRecognition, libraryState.books])
+
   useEffect(() => {
     if (!currentProject?.path) return
 
@@ -321,24 +358,42 @@ export function BookAnalysisView() {
       if (task.status === "completed" && !notifiedPipelineTaskIdsRef.current.has(`${task.id}:completed`)) {
         notifiedPipelineTaskIdsRef.current.add(`${task.id}:completed`)
         void reloadLibraryState()
+        // 故事任务完成:递增刷新键,故事页签的历史导图自动重新读取
+        if (task.selectedSkills.includes("story")) {
+          setStoryMapRefreshKey((key) => key + 1)
+        }
         if (task.batchId) continue
         if (task.selectedSkills.includes("characters") && task.selectedSkills.length === 1) {
           toast.success("角色信息已提取完成,可选择角色生成 Skill。", {
             persistent: true,
-            action: { label: "打开角色信息", onClick: handleOpenCharacterSkillSelection },
+            action: {
+              label: "打开角色信息",
+              onClick: () => {
+                // fix/view-result-routing:定位到任务所属作品后再打开弹窗
+                if (libraryState.books.some((book) => book.id === task.bookId)) {
+                  handleSelectBook(task.bookId)
+                } else {
+                  setSelectedBookId(task.bookId)
+                }
+                setCharacterSkillDialogOpen(true)
+              },
+            },
           })
         } else {
+          // fix/view-result-routing:按任务技能路由到对应 Skill 页签,
+          // 直接展示角色/故事/文风的生成结果(旧版查看器不再使用)
+          const tab = analysisTabForSkills(task.selectedSkills)
           toast.success("拆书任务已完成,结果已更新。", {
             persistent: true,
             action: {
               label: "查看结果",
-              onClick: async () => {
-                const loadedResult = await loadBookAnalysisResult(task.projectPath, task.bookId)
-                if (loadedResult) {
-                  useBookAnalysisStore.getState().setCurrentResult(loadedResult)
+              onClick: () => {
+                if (libraryState.books.some((book) => book.id === task.bookId)) {
+                  handleSelectBook(task.bookId)
+                } else {
+                  setSelectedBookId(task.bookId)
                 }
-                setViewingResultPath(task.projectPath)
-                setShowResultViewer(true)
+                setModuleActiveTab(tab)
               },
             },
           })
@@ -351,7 +406,7 @@ export function BookAnalysisView() {
         }
       }
     }
-  }, [currentProject?.path, pipelineTasks, reloadLibraryState, handleOpenCharacterSkillSelection])
+  }, [currentProject?.path, pipelineTasks, reloadLibraryState, handleOpenCharacterSkillSelection, handleSelectBook, libraryState.books])
 
   useEffect(() => {
     const projectPath = currentProject?.path ?? null
@@ -572,6 +627,9 @@ export function BookAnalysisView() {
     .filter((task) => task.bookId === selectedLibraryBook?.id)
     .sort((left, right) => right.updatedAt - left.updatedAt)[0] ?? null
 
+  // 同书所有分析任务(含并行运行的多个);默认聚焦最新任务,其余在「并行分析任务」区各自显示进度
+  const bookAnalysisTasks = pipelineTasks.filter((task) => task.bookId === selectedLibraryBook?.id)
+
   const openPipelineDialog = useCallback(async (skill: AnalysisSkill) => {
     if (!selectedLibraryBook) return
     const existingModule = selectedLibraryBook.analysisManifest?.modules[skill]
@@ -702,55 +760,6 @@ export function BookAnalysisView() {
     }
   }, [failPipelineTask, llmConfig, openPipelineCharacterPicker, providerConfigs, setRuntimeProgress, setTaskRecognizedCharacters])
 
-  const runImportTaskAction = useCallback((action: () => Promise<void>, failureMessage: string) => {
-    void action().catch((error) => {
-      console.error(failureMessage, error)
-      toast.error(`${failureMessage}:${error instanceof Error ? error.message : String(error)}`)
-    })
-  }, [])
-
-  const handleContinueImportTask = useCallback((taskId: string) => {
-    runImportTaskAction(() => continueImportTask(taskId), "继续导入失败")
-  }, [continueImportTask, runImportTaskAction])
-
-  const handleRegenerateImportTask = useCallback((taskId: string) => {
-    runImportTaskAction(() => regenerateImportTask(taskId), "重新生成失败")
-  }, [regenerateImportTask, runImportTaskAction])
-
-  const handleCancelImportTask = useCallback((taskId: string) => {
-    runImportTaskAction(() => cancelImportTask(taskId), "取消导入失败")
-  }, [cancelImportTask, runImportTaskAction])
-
-  const handleCancelAllQueuedImportTasks = useCallback((batchId: string) => {
-    runImportTaskAction(() => cancelAllQueuedImportTasks(batchId), "取消等待任务失败")
-  }, [cancelAllQueuedImportTasks, runImportTaskAction])
-
-  const handleDeleteFailedImportTask = useCallback((taskId: string) => {
-    runImportTaskAction(() => deleteFailedImportTask(taskId), "删除失败任务失败")
-  }, [deleteFailedImportTask, runImportTaskAction])
-
-  const handleRenameCompletedImportTask = useCallback((taskId: string, title: string) => {
-    runImportTaskAction(() => renameCompletedImportTask(taskId, title), "重命名作品失败")
-  }, [renameCompletedImportTask, runImportTaskAction])
-
-  const handleSelectBook = useCallback((bookId: string) => {
-    const book = libraryState.books.find((item) => item.id === bookId)
-    setSelectedBookId(bookId)
-    setSelectedCharacterId(null)
-    clearRecognition()
-    const analysisStore = useBookAnalysisStore.getState()
-    analysisStore.setSelectedLibraryBookId(bookId)
-    if (book) analysisStore.setCurrentResult(toBookAnalysisResult(book))
-  }, [clearRecognition, libraryState.books])
-
-  const handleOpenImportedBook = useCallback((bookId: string) => {
-    if (!libraryState.books.some((item) => item.id === bookId)) {
-      toast.error("未找到已导入作品,请刷新后重试。")
-      return
-    }
-    handleSelectBook(bookId)
-  }, [handleSelectBook, libraryState.books])
-
   const libraryLayout = (
     <>
     <BookAnalysisLibraryLayout
@@ -762,30 +771,39 @@ export function BookAnalysisView() {
       extractingStoryFramework={storyFrameworkExtracting}
       addingToSoul={addingToSoul}
       storyFrameworks={storyFrameworks}
-      importTaskPanel={
-        <BookAnalysisImportTaskPanel
-          batches={importBatches}
-          tasks={importTasks}
-          collapsed={importPanelCollapsed}
-          onCollapsedChange={setImportPanelCollapsed}
-          onContinue={handleContinueImportTask}
-          onRegenerate={handleRegenerateImportTask}
-          onCancel={handleCancelImportTask}
-          onCancelAllQueued={handleCancelAllQueuedImportTasks}
-          onDeleteFailed={handleDeleteFailedImportTask}
-          onRenameCompleted={handleRenameCompletedImportTask}
-          onOpenBook={handleOpenImportedBook}
-        />
-      }
       analysisTask={selectedPipelineTask}
-      analysisChunks={pipelineChunks.filter((chunk) => chunk.taskId === selectedPipelineTask?.id)}
+      analysisChunks={pipelineChunks}
       analysisProgresses={pipelineProgresses}
+      analysisTasks={bookAnalysisTasks}
+      analysisActiveTab={moduleActiveTab}
+      onAnalysisActiveTabChange={setModuleActiveTab}
+      storyMapRefreshKey={storyMapRefreshKey}
+      onParallelPauseTask={(taskId) => {
+        void pausePipelineTask(taskId).catch((error) => {
+          toast.error(`暂停失败:${error instanceof Error ? error.message : String(error)}`)
+        })
+      }}
+      onParallelContinueTask={(taskId) => {
+        void continuePipelineTask(taskId).catch((error) => {
+          toast.error(`继续失败:${error instanceof Error ? error.message : String(error)}`)
+        })
+      }}
+      onParallelCancelTask={(taskId) => {
+        void cancelPipelineTask(taskId).catch((error) => {
+          toast.error(`取消失败:${error instanceof Error ? error.message : String(error)}`)
+        })
+      }}
       onSelectBook={handleSelectBook}
       onSelectCharacter={setSelectedCharacterId}
       onImportNovel={() => setInputDialogOpen(true)}
       onExtractStyle={handleLibraryExtractStyle}
       onExtractStoryFramework={handleOpenStoryFrameworkSelection}
       onCreateOutlineFromFramework={(frameworkId) => setOutlineCreatorFrameworkId(frameworkId)}
+      onOpenStoryMap={(bookPath) => {
+        setSelectedFile(null)
+        setActiveView("wiki")
+        setSelectedFile(`${bookPath}/story-map.html`)
+      }}
       onToggleStyle={handleLibraryToggleStyle}
       onAddSelectedSkillsToSoul={handleLibraryAddSkillsToSoul}
       onOpenSkillSelection={handleOpenCharacterSkillSelection}
@@ -801,22 +819,13 @@ export function BookAnalysisView() {
             )
           : undefined
       }
-      onPauseAnalysisTask={selectedPipelineTask ? () => {
-        void pausePipelineTask(selectedPipelineTask.id).catch((error) => {
-          toast.error(`暂停失败:${error instanceof Error ? error.message : String(error)}`)
-        })
-      } : undefined}
-      onContinueAnalysisTask={selectedPipelineTask ? () => {
-        void continuePipelineTask(selectedPipelineTask.id).catch((error) => {
-          toast.error(`继续失败:${error instanceof Error ? error.message : String(error)}`)
-        })
-      } : undefined}
-      onRetryAnalysisTask={selectedPipelineTask ? () => {
-        const skill = selectedPipelineTask.currentSkill
-        const failedChunkId = skill ? selectedPipelineTask.modules[skill].failedChunkId : null
+      onParallelRetryTask={selectedPipelineTask ? (taskId) => {
+        const target = bookAnalysisTasks.find((t) => t.id === taskId) ?? selectedPipelineTask
+        const skill = target.currentSkill
+        const failedChunkId = skill ? target.modules[skill].failedChunkId : null
         const retry = skill && failedChunkId
-          ? retryPipelineChunk(selectedPipelineTask.id, skill, failedChunkId)
-          : continuePipelineTask(selectedPipelineTask.id)
+          ? retryPipelineChunk(target.id, skill, failedChunkId)
+          : continuePipelineTask(target.id)
         void retry.catch((error) => toast.error(`重试失败:${error instanceof Error ? error.message : String(error)}`))
       } : undefined}
       onRetryAnalysisChunk={selectedPipelineTask ? (skill, chunkId) => {
@@ -824,11 +833,6 @@ export function BookAnalysisView() {
           toast.error(`重试区块失败:${error instanceof Error ? error.message : String(error)}`)
         })
       } : undefined}
-      onCancelAnalysisTask={selectedPipelineTask ? () => {
-        void cancelPipelineTask(selectedPipelineTask.id).catch((error) => {
-          toast.error(`取消失败:${error instanceof Error ? error.message : String(error)}`)
-        })
-      } : undefined}
       onDeleteBook={(bookId) => handleLibraryDeleteBook(bookId, selectedBookId)}
     />
     {pipelineDialog && (

+ 166 - 0
src/components/novel/story-map-content.spec.tsx

@@ -0,0 +1,166 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { describe, expect, it, vi } from "vitest"
+import type { StoryMap } from "@/lib/novel/book-analysis/story-map-types"
+import { StoryMapContent } from "./book-analysis-library-layout"
+
+const listStoryMapHistory = vi.hoisted(() => vi.fn())
+const readFile = vi.hoisted(() => vi.fn())
+
+vi.mock("@/commands/fs", () => ({ readFile }))
+vi.mock("@/lib/novel/book-analysis/story-map-history", () => ({ listStoryMapHistory }))
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+function makeMap(createdAt: number, mainLineLabel: string, orders: number[]): StoryMap {
+  return {
+    schemaVersion: 1,
+    bookId: "book-1",
+    bookTitle: "测试作品",
+    mainLineLabel,
+    mainSummary: "",
+    createdAt,
+    chapters: orders.map((order) => ({
+      id: `ch-${order}`,
+      order,
+      title: `第${order}章`,
+      summary: "摘要",
+      mainEvents: [{ label: `主线事件${order}`, beats: [], characters: [] }],
+      branches: [],
+    })),
+  }
+}
+
+function renderContent(bookPath: string) {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  act(() => {
+    root.render(<StoryMapContent bookPath={bookPath} />)
+  })
+  return {
+    container,
+    cleanup: () => {
+      act(() => root.unmount())
+      document.body.removeChild(container)
+    },
+  }
+}
+
+describe("StoryMapContent 历史导图展示", () => {
+  it("渲染全部历史导图卡片,最新的在前", async () => {
+    listStoryMapHistory.mockResolvedValue([
+      {
+        dirName: "story-map-100",
+        map: makeMap(100, "主线A", [1, 2]),
+        jsonPath: "E:/book/story-maps/story-map-100/story-map.json",
+        htmlPath: "E:/book/story-maps/story-map-100/story-map.html",
+      },
+      {
+        dirName: "story-map-200",
+        map: makeMap(200, "主线B", [3, 4]),
+        jsonPath: "E:/book/story-maps/story-map-200/story-map.json",
+        htmlPath: "E:/book/story-maps/story-map-200/story-map.html",
+      },
+    ])
+    readFile.mockResolvedValue("<html>map</html>")
+    const { container, cleanup } = renderContent("E:/book")
+    await act(async () => {})
+
+    const titleNodes = Array.from(container.querySelectorAll("div"))
+      .filter((node) => typeof node.className === "string"
+        && node.className.includes("truncate")
+        && node.className.includes("text-base"))
+    const titles = titleNodes.map((node) => node.textContent ?? "")
+    expect(titles[0]).toContain("主线:主线B")
+    expect(titles[1]).toContain("主线:主线A")
+
+    expect(container.textContent).toContain("《测试作品》故事导图")
+    expect(container.textContent).toContain("覆盖第 1~2 章")
+    expect(container.textContent).toContain("覆盖第 3~4 章")
+
+    const expandButtons = Array.from(container.querySelectorAll("button"))
+      .filter((button) => button.textContent?.includes("查看全部"))
+    expect(expandButtons.length).toBe(2)
+    cleanup()
+  })
+
+  it("点击「查看全部」展开,再点「收起」恢复预览高度", async () => {
+    listStoryMapHistory.mockResolvedValue([{
+      dirName: "story-map-100",
+      map: makeMap(100, "主线A", [1]),
+      jsonPath: "E:/book/story-maps/story-map-100/story-map.json",
+      htmlPath: "E:/book/story-maps/story-map-100/story-map.html",
+    }])
+    readFile.mockResolvedValue("<html>map</html>")
+    const { container, cleanup } = renderContent("E:/book")
+    await act(async () => {})
+
+    const expandButton = Array.from(container.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("查看全部")) as HTMLButtonElement
+    const iframe = container.querySelector("iframe") as HTMLIFrameElement
+    expect(iframe.className).toContain("h-[300px]")
+
+    act(() => expandButton.click())
+    expect(iframe.className).toContain("h-[70vh]")
+    expect(expandButton.textContent).toContain("收起")
+
+    act(() => expandButton.click())
+    expect(iframe.className).toContain("h-[300px]")
+    cleanup()
+  })
+
+  it("无历史目录时回退展示根目录 story-map.html(旧数据兼容)", async () => {
+    listStoryMapHistory.mockResolvedValue([])
+    readFile.mockResolvedValue("<html>legacy</html>")
+    const { container, cleanup } = renderContent("E:/book")
+    await act(async () => {})
+
+    expect(container.textContent).toContain("故事导图")
+    expect(container.querySelector("iframe")).not.toBeNull()
+    cleanup()
+  })
+
+  it("没有任何导图时显示提示文案", async () => {
+    listStoryMapHistory.mockResolvedValue([])
+    readFile.mockRejectedValue(new Error("missing"))
+    const { container, cleanup } = renderContent("E:/book")
+    await act(async () => {})
+
+    expect(container.textContent).toContain("尚未提取故事导图")
+    cleanup()
+  })
+
+  it("refreshKey 变化时重新读取历史导图(故事任务完成后自动刷新)", async () => {
+    listStoryMapHistory.mockClear()
+    readFile.mockClear()
+    listStoryMapHistory.mockResolvedValue([{
+      dirName: "story-map-100",
+      map: makeMap(100, "主线A", [1]),
+      jsonPath: "E:/book/story-maps/story-map-100/story-map.json",
+      htmlPath: "E:/book/story-maps/story-map-100/story-map.html",
+    }])
+    readFile.mockResolvedValue("<html>map</html>")
+
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+    act(() => {
+      root.render(<StoryMapContent bookPath="E:/book" refreshKey={0} />)
+    })
+    await act(async () => {})
+    expect(listStoryMapHistory).toHaveBeenCalledTimes(1)
+
+    // 故事任务完成 → 刷新键递增 → 重新读取(新导图立即出现,无需切换页签)
+    act(() => {
+      root.render(<StoryMapContent bookPath="E:/book" refreshKey={1} />)
+    })
+    await act(async () => {})
+    expect(listStoryMapHistory).toHaveBeenCalledTimes(2)
+
+    act(() => root.unmount())
+    document.body.removeChild(container)
+  })
+})

+ 216 - 0
src/components/settings/sections/default-model-settings-panel.tsx

@@ -0,0 +1,216 @@
+import { useMemo, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { useWikiStore } from "@/stores/wiki-store"
+import { getFirstAvailableModelKey } from "@/lib/llm-model-keys"
+import { testNovelModel, type TestableNovelModelTask } from "@/lib/novel/novel-model-test"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
+import { Label } from "@/components/ui/label"
+import { Button } from "@/components/ui/button"
+import { saveDefaultLlmModel, saveNovelConfig } from "@/lib/project-store"
+import { notifyDeAiChapterConcurrencyChanged } from "@/lib/novel/de-ai-batch/chapter-concurrency"
+import type { SettingsDraft, DraftSetter } from "../settings-types"
+import type { NovelConfig } from "@/stores/wiki-store"
+
+interface Props {
+  draft: SettingsDraft
+  setDraft: DraftSetter
+}
+
+interface NovelModelPickerBlockProps {
+  title: string
+  footnote?: string
+  followChecked: boolean
+  onFollowChange: (checked: boolean) => void
+  modelValue: string
+  onModelChange: (model: string) => void
+  testState?: { loading: boolean; message: string; success: boolean }
+  onTest?: () => void
+  followLabel: string
+  testLoadingLabel: string
+  testLabel: string
+}
+
+function NovelModelPickerBlock({
+  title,
+  footnote,
+  followChecked,
+  onFollowChange,
+  modelValue,
+  onModelChange,
+  testState,
+  onTest,
+  followLabel,
+  testLoadingLabel,
+  testLabel,
+}: NovelModelPickerBlockProps) {
+  return (
+    <div className="space-y-3 rounded-lg border border-border/60 p-4">
+      <div className="flex items-center gap-1.5">
+        <Label>{title}</Label>
+      </div>
+      <div className="flex flex-wrap items-center gap-2">
+        <label className="flex shrink-0 items-center gap-2">
+          <input
+            type="checkbox"
+            checked={followChecked}
+            onChange={(e) => onFollowChange(e.target.checked)}
+            className="h-4 w-4"
+          />
+          <span className="text-sm">{followLabel}</span>
+        </label>
+        <ChatModelSelector
+          value={modelValue}
+          onChange={onModelChange}
+          disabled={followChecked}
+        />
+        {onTest ? (
+          <Button type="button" size="sm" variant="outline" disabled={testState?.loading} onClick={onTest}>
+            {testState?.loading ? testLoadingLabel : testLabel}
+          </Button>
+        ) : null}
+      </div>
+      {footnote ? (
+        <p className="text-xs leading-5 text-muted-foreground/80">{footnote}</p>
+      ) : null}
+      {testState?.message ? (
+        <p className={`text-xs ${testState.success ? "text-emerald-600" : "text-destructive"}`}>
+          {testState.message}
+        </p>
+      ) : null}
+    </div>
+  )
+}
+
+/**
+ * 默认模型设置面板:默认模型(通用)+ 审稿 / 摘要 / 提取 / 去 AI 味 四个分环节模型。
+ * 原本位于「写作设置」,现归并到「模型设置」聚合页的「默认模型」标签页下。
+ */
+export function DefaultModelSettingsPanel({ draft, setDraft }: Props) {
+  const { t } = useTranslation()
+  const setNovelConfigStore = useWikiStore((s) => s.setNovelConfig)
+  const llmConfig = useWikiStore((s) => s.llmConfig)
+  const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const project = useWikiStore((s) => s.project)
+
+  const defaultLlmModel = draft.novelConfig.defaultLlmModel
+  const isFollowingChat = !defaultLlmModel.trim()
+  const displayDefault = isFollowingChat ? "" : defaultLlmModel
+
+  const [testStates, setTestStates] = useState<Record<TestableNovelModelTask, {
+    loading: boolean
+    message: string
+    success: boolean
+  } | undefined>>({
+    writing: undefined,
+    workflow: undefined,
+    review: undefined,
+    summary: undefined,
+    extract: undefined,
+    deAi: undefined,
+  })
+
+  const updateNovelConfig = async (patch: Partial<NovelConfig>) => {
+    const newConfig = { ...draft.novelConfig, ...patch }
+    setDraft("novelConfig", newConfig)
+    setNovelConfigStore(patch)
+    await saveNovelConfig(newConfig, project?.id, project?.path)
+    if (patch.deAiBatchConcurrency !== undefined) {
+      notifyDeAiChapterConcurrencyChanged()
+    }
+  }
+
+  const updateWorkflowDefaultModel = async (model: string) => {
+    await updateNovelConfig({ defaultLlmModel: model })
+    await saveDefaultLlmModel(model)
+  }
+
+  const modelItems = useMemo(() => ([
+    { task: "review", field: "reviewModel" },
+    { task: "summary", field: "summaryModel" },
+    { task: "extract", field: "extractModel" },
+    { task: "deAi", field: "deAiModel" },
+  ] as const), [])
+
+  const runModelTest = async (task: TestableNovelModelTask) => {
+    setTestStates((prev) => ({
+      ...prev,
+      [task]: { loading: true, message: t("novel.settings.testingModel"), success: false },
+    }))
+    try {
+      const result = await testNovelModel(llmConfig, draft.novelConfig, task)
+      const suffix = result.usedFallbackModel
+        ? t("novel.settings.testUsingDefaultMainModel", { model: result.model })
+        : t("novel.settings.testUsingCurrentModel", { model: result.model })
+      setTestStates((prev) => ({
+        ...prev,
+        [task]: { loading: false, message: `${t("novel.settings.testSuccess")} ${suffix}`, success: true },
+      }))
+    } catch (error) {
+      setTestStates((prev) => ({
+        ...prev,
+        [task]: {
+          loading: false,
+          message: t("novel.settings.testFailed", {
+            message: error instanceof Error ? error.message : String(error),
+          }),
+          success: false,
+        },
+      }))
+    }
+  }
+
+  return (
+    <div className="space-y-4">
+      <NovelModelPickerBlock
+        title={t("novel.settings.defaultLlmModel")}
+        footnote={t("novel.settings.defaultLlmModelScopeNote")}
+        followChecked={isFollowingChat}
+        onFollowChange={(checked) => {
+          if (checked) {
+            void updateWorkflowDefaultModel("")
+          } else {
+            void updateWorkflowDefaultModel(
+              aiChatModel.trim() || getFirstAvailableModelKey(providerConfigs),
+            )
+          }
+        }}
+        modelValue={displayDefault}
+        onModelChange={(model) => void updateWorkflowDefaultModel(model)}
+        testState={testStates.workflow}
+        onTest={() => runModelTest("workflow")}
+        followLabel={t("novel.settings.followChatModel")}
+        testLoadingLabel={t("novel.settings.testingModel")}
+        testLabel={t("novel.settings.testModel")}
+      />
+
+      {modelItems.map((item) => {
+        const state = testStates[item.task]
+        const modelValue = draft.novelConfig[item.field] || ""
+        const isFollowing = !modelValue
+        const displayValue = isFollowing ? "" : modelValue
+        return (
+          <NovelModelPickerBlock
+            key={item.task}
+            title={t(`novel.settings.${item.field}`)}
+            followChecked={isFollowing}
+            onFollowChange={(checked) => {
+              if (checked) {
+                void updateNovelConfig({ [item.field]: "" } as Partial<NovelConfig>)
+              } else {
+                void updateNovelConfig({ [item.field]: aiChatModel || " " } as Partial<NovelConfig>)
+              }
+            }}
+            modelValue={displayValue}
+            onModelChange={(model) => void updateNovelConfig({ [item.field]: model } as Partial<NovelConfig>)}
+            testState={state}
+            onTest={() => runModelTest(item.task)}
+            followLabel={t("novel.settings.followDefaultModel")}
+            testLoadingLabel={t("novel.settings.testingModel")}
+            testLabel={t("novel.settings.testModel")}
+          />
+        )
+      })}
+    </div>
+  )
+}

+ 76 - 0
src/components/settings/sections/model-settings-section.tsx

@@ -0,0 +1,76 @@
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import type { SettingsDraft, DraftSetter } from "../settings-types"
+import { LlmProviderSection } from "./llm-provider-section"
+import { EmbeddingSection } from "./embedding-section"
+import { RerankSection } from "./rerank-section"
+import { DefaultModelSettingsPanel } from "./default-model-settings-panel"
+
+type ModelTabId = "default" | "llm" | "rerank" | "embedding"
+
+interface Props {
+  draft: SettingsDraft
+  setDraft: DraftSetter
+}
+
+/**
+ * 模型设置聚合页:左侧设置分类中的「模型设置」入口进入本页。
+ * 页面上方 4 个按钮(默认模型 / 大语言模型 / 重排模型 / 向量模型)切换,
+ * 默认选中「大语言模型」。
+ */
+export function ModelSettingsSection({ draft, setDraft }: Props) {
+  const { t } = useTranslation()
+
+  const TABS: Array<{ id: ModelTabId; label: string }> = [
+    { id: "default", label: t("settings.categories.defaultModel", { defaultValue: "默认模型" }) },
+    { id: "llm", label: t("settings.categories.llm", { defaultValue: "大语言模型" }) },
+    { id: "rerank", label: t("settings.categories.rerank", { defaultValue: "重排模型" }) },
+    { id: "embedding", label: t("settings.categories.embedding", { defaultValue: "向量模型" }) },
+  ]
+
+  const [active, setActive] = useState<ModelTabId>("llm")
+
+  return (
+    <div className="space-y-4">
+      <div>
+        <h2 className="text-xl font-semibold">
+          {t("settings.categories.model", { defaultValue: "模型设置" })}
+        </h2>
+        <p className="mt-1 text-sm text-muted-foreground">
+          {t("settings.modelSettingsDescription", {
+            defaultValue: "配置写小说各环节使用的模型:默认模型、大语言模型、重排模型、向量检索模型。",
+          })}
+        </p>
+      </div>
+
+      <div role="tablist" aria-label="模型设置" className="flex flex-wrap gap-2 border-b pb-0">
+        {TABS.map((tab) => {
+          const isActive = active === tab.id
+          return (
+            <button
+              key={tab.id}
+              type="button"
+              role="tab"
+              aria-selected={isActive}
+              onClick={() => setActive(tab.id)}
+              className={`rounded-t-md border-b-2 px-4 py-2 text-sm transition-colors ${
+                isActive
+                  ? "border-primary text-foreground"
+                  : "border-transparent text-muted-foreground hover:text-foreground"
+              }`}
+            >
+              {tab.label}
+            </button>
+          )
+        })}
+      </div>
+
+      <div className="min-h-0 pt-2">
+        {active === "llm" && <LlmProviderSection />}
+        {active === "default" && <DefaultModelSettingsPanel draft={draft} setDraft={setDraft} />}
+        {active === "rerank" && <RerankSection draft={draft} setDraft={setDraft} />}
+        {active === "embedding" && <EmbeddingSection draft={draft} setDraft={setDraft} />}
+      </div>
+    </div>
+  )
+}

+ 10 - 239
src/components/settings/sections/novel-section.tsx

@@ -1,19 +1,13 @@
-import { useMemo, useState, type ReactNode } from "react"
 import { useTranslation } from "react-i18next"
 import { Info } from "lucide-react"
 import { Label } from "@/components/ui/label"
 import { Input } from "@/components/ui/input"
-import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { useWikiStore } from "@/stores/wiki-store"
-import { saveNovelConfig, saveDefaultLlmModel, saveRevisionFeedbackWindowConfig, saveMaxHistoryMessages } from "@/lib/project-store"
-import { getFirstAvailableModelKey, hasAvailableModels } from "@/lib/llm-model-keys"
+import { saveNovelConfig, saveRevisionFeedbackWindowConfig, saveMaxHistoryMessages } from "@/lib/project-store"
 import { notifyDeAiChapterConcurrencyChanged } from "@/lib/novel/de-ai-batch/chapter-concurrency"
 import { CHAPTER_TARGET_CHARS_MAX, CHAPTER_TARGET_CHARS_MIN } from "@/lib/novel/deep-chapter-prompts"
 import { useChatStore } from "@/stores/chat-store"
-
-import { testNovelModel, type TestableNovelModelTask } from "@/lib/novel/novel-model-test"
-import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import type { SettingsDraft, DraftSetter } from "../settings-types"
 import type { NovelConfig, RevisionFeedbackWindowConfig } from "@/stores/wiki-store"
 
@@ -22,114 +16,12 @@ interface Props {
   setDraft: DraftSetter
 }
 
-const MODEL_PICKER_BLOCK_CLASS = "space-y-3 rounded-lg border border-border/60 p-4"
-
-interface NovelModelPickerBlockProps {
-  title: string
-  hintKey?: string
-  footnote?: string
-  followChecked: boolean
-  onFollowChange: (checked: boolean) => void
-  modelValue: string
-  onModelChange: (model: string) => void
-  renderHint?: (hintKey: string) => ReactNode
-  testState?: { loading: boolean; message: string; success: boolean }
-  onTest?: () => void
-  testLoadingLabel: string
-  testLabel: string
-  followLabel: string
-}
-
-function NovelModelPickerBlock({
-  title,
-  hintKey,
-  footnote,
-  followChecked,
-  onFollowChange,
-  modelValue,
-  onModelChange,
-  renderHint,
-  testState,
-  onTest,
-  testLoadingLabel,
-  testLabel,
-  followLabel,
-}: NovelModelPickerBlockProps) {
-  return (
-    <div className={MODEL_PICKER_BLOCK_CLASS}>
-      <div className="flex items-center gap-1.5">
-        <Label>{title}</Label>
-        {hintKey && renderHint?.(hintKey)}
-      </div>
-      <div className="flex flex-wrap items-center gap-2">
-        <label className="flex shrink-0 items-center gap-2">
-          <input
-            type="checkbox"
-            checked={followChecked}
-            onChange={(e) => onFollowChange(e.target.checked)}
-            className="h-4 w-4"
-          />
-          <span className="text-sm">{followLabel}</span>
-        </label>
-        <ChatModelSelector
-          value={modelValue}
-          onChange={onModelChange}
-          disabled={followChecked}
-        />
-        {onTest ? (
-          <Button
-            type="button"
-            size="sm"
-            variant="outline"
-            disabled={testState?.loading}
-            onClick={onTest}
-          >
-            {testState?.loading ? testLoadingLabel : testLabel}
-          </Button>
-        ) : null}
-      </div>
-      {footnote ? (
-        <p className="text-xs leading-5 text-muted-foreground/80">{footnote}</p>
-      ) : null}
-      {testState?.message ? (
-        <p className={`text-xs ${testState.success ? "text-emerald-600" : "text-destructive"}`}>
-          {testState.message}
-        </p>
-      ) : null}
-    </div>
-  )
-}
-
 export function NovelSection({ draft, setDraft }: Props) {
   const { t } = useTranslation()
   const setNovelConfigStore = useWikiStore((s) => s.setNovelConfig)
   const setRevisionFeedbackWindowConfig = useWikiStore((s) => s.setRevisionFeedbackWindowConfig)
   const setMaxHistoryMessages = useChatStore((s) => s.setMaxHistoryMessages)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
-  const aiChatModel = useWikiStore((s) => s.aiChatModel)
-  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const project = useWikiStore((s) => s.project)
-  const defaultLlmModel = draft.novelConfig.defaultLlmModel
-  const isWorkflowModelFollowingChat = !defaultLlmModel.trim()
-  const displayWorkflowDefaultModel = isWorkflowModelFollowingChat ? "" : defaultLlmModel
-
-  const modelsAvailable = useMemo(
-    () => hasAvailableModels(providerConfigs),
-    [providerConfigs],
-  )
-
-  const [testStates, setTestStates] = useState<Record<TestableNovelModelTask, {
-    loading: boolean
-    message: string
-    success: boolean
-  } | undefined>>({
-    writing: undefined,
-    workflow: undefined,
-    review: undefined,
-    summary: undefined,
-    extract: undefined,
-    deAi: undefined,
-  })
 
   const updateNovelConfig = async (patch: Partial<NovelConfig>) => {
     const newConfig = { ...draft.novelConfig, ...patch }
@@ -154,18 +46,6 @@ export function NovelSection({ draft, setDraft }: Props) {
     await saveMaxHistoryMessages(count, project?.id, project?.path)
   }
 
-  const updateWorkflowDefaultModel = async (model: string) => {
-    await updateNovelConfig({ defaultLlmModel: model })
-    await saveDefaultLlmModel(model)
-  }
-
-  const modelItems = useMemo(() => ([
-    { task: "review", field: "reviewModel" },
-    { task: "summary", field: "summaryModel" },
-    { task: "extract", field: "extractModel" },
-    { task: "deAi", field: "deAiModel" },
-  ] as const), [])
-
   const settingTooltip = (key: string) => (
     <Tooltip>
       <TooltipTrigger
@@ -185,43 +65,6 @@ export function NovelSection({ draft, setDraft }: Props) {
     </Tooltip>
   )
 
-  const runModelTest = async (task: TestableNovelModelTask) => {
-    setTestStates((prev) => ({
-      ...prev,
-      [task]: {
-        loading: true,
-        message: t("novel.settings.testingModel"),
-        success: false,
-      },
-    }))
-
-    try {
-      const result = await testNovelModel(llmConfig, draft.novelConfig, task)
-      const suffix = result.usedFallbackModel
-        ? t("novel.settings.testUsingDefaultMainModel", { model: result.model })
-        : t("novel.settings.testUsingCurrentModel", { model: result.model })
-      setTestStates((prev) => ({
-        ...prev,
-        [task]: {
-          loading: false,
-          message: `${t("novel.settings.testSuccess")} ${suffix}`,
-          success: true,
-        },
-      }))
-    } catch (error) {
-      setTestStates((prev) => ({
-        ...prev,
-        [task]: {
-          loading: false,
-          message: t("novel.settings.testFailed", {
-            message: error instanceof Error ? error.message : String(error),
-          }),
-          success: false,
-        },
-      }))
-    }
-  }
-
   return (
     <TooltipProvider>
       <div className="space-y-6">
@@ -239,49 +82,21 @@ export function NovelSection({ draft, setDraft }: Props) {
 
       <div className="space-y-2">
         <Label>{t("novel.settings.title")}</Label>
-        <div className="grid gap-4 rounded-lg border p-4">
-          {modelsAvailable && (
-            <NovelModelPickerBlock
-              title={t("novel.settings.defaultLlmModel")}
-              hintKey="defaultLlmModelHint"
-              footnote={t("novel.settings.defaultLlmModelScopeNote")}
-              followChecked={isWorkflowModelFollowingChat}
-              onFollowChange={(checked) => {
-                if (checked) {
-                  void updateWorkflowDefaultModel("")
-                } else {
-                  void updateWorkflowDefaultModel(
-                    aiChatModel.trim() || getFirstAvailableModelKey(providerConfigs),
-                  )
-                }
-              }}
-              modelValue={displayWorkflowDefaultModel}
-              onModelChange={(model) => void updateWorkflowDefaultModel(model)}
-              renderHint={settingTooltip}
-              testState={testStates.workflow}
-              onTest={() => runModelTest("workflow")}
-              followLabel={t("novel.settings.followChatModel")}
-              testLoadingLabel={t("novel.settings.testingModel")}
-              testLabel={t("novel.settings.testModel")}
-            />
-          )}
-
-          <div className="space-y-2">
-            <div className="flex items-center gap-1.5">
-              <Label>{t("novel.settings.recentSummaryWindow")}</Label>
-              {settingTooltip("recentSummaryWindowHint")}
-            </div>
-            <Input
-              type="number"
-              min={1}
-              max={30}
+        <div className="grid gap-4 rounded-lg border border-border/60 p-4">
+          <div className="flex items-center gap-1.5">
+            <Label>{t("novel.settings.recentSummaryWindow")}</Label>
+            {settingTooltip("recentSummaryWindowHint")}
+          </div>
+          <Input
+            type="number"
+            min={1}
+            max={30}
               value={draft.novelConfig.recentSummaryWindow}
               onChange={(e) => updateNovelConfig({
                 recentSummaryWindow: Math.max(1, Math.min(30, Number(e.target.value) || 1)),
               })}
               className="w-24"
             />
-          </div>
 
           <div className="space-y-2">
             <div className="flex items-center gap-1.5">
@@ -502,50 +317,6 @@ export function NovelSection({ draft, setDraft }: Props) {
               </div>
             </>
           )}
-
-          <div className="space-y-1 border-t border-border/60 pt-4">
-            <Label>{t("novel.settings.taskModelsTitle")}</Label>
-            <p className="text-xs leading-5 text-muted-foreground">
-              {t("novel.settings.taskModelsHint")}
-            </p>
-          </div>
-
-          {modelItems.map((item) => {
-            const state = testStates[item.task]
-            const modelValue = draft.novelConfig[item.field] || ""
-            const isFollowingChat = !modelValue
-            const displayValue = isFollowingChat ? "" : modelValue
-
-            return (
-              <NovelModelPickerBlock
-                key={item.task}
-                title={t(`novel.settings.${item.field}`)}
-                hintKey={`${item.field}Hint`}
-                followChecked={isFollowingChat}
-                onFollowChange={(checked) => {
-                  if (checked) {
-                    updateNovelConfig({
-                      [item.field]: "",
-                    } as Partial<NovelConfig>)
-                  } else {
-                    updateNovelConfig({
-                      [item.field]: aiChatModel || " ",
-                    } as Partial<NovelConfig>)
-                  }
-                }}
-                modelValue={displayValue}
-                onModelChange={(model) => updateNovelConfig({
-                  [item.field]: model,
-                } as Partial<NovelConfig>)}
-                renderHint={settingTooltip}
-                testState={state}
-                onTest={() => runModelTest(item.task)}
-                followLabel={t("novel.settings.followDefaultModel")}
-                testLoadingLabel={t("novel.settings.testingModel")}
-                testLabel={t("novel.settings.testModel")}
-              />
-            )
-          })}
         </div>
       </div>
 

+ 12 - 20
src/components/settings/settings-model-categories.spec.ts

@@ -6,32 +6,24 @@ import zh from "@/i18n/zh.json"
 const settingsViewSource = readFileSync(resolve(__dirname, "settings-view.tsx"), "utf8")
 
 describe("settings model categories", () => {
-  it("shows LLM, reranker, and embedding as independent settings categories", () => {
-    expect(settingsViewSource).toContain('| "llm"')
-    expect(settingsViewSource).toContain('| "rerank"')
-    expect(settingsViewSource).toContain('| "embedding"')
-
-    expect(settingsViewSource).toContain('{ id: "llm", labelKey: "settings.categories.llm", icon: Bot }')
-    expect(settingsViewSource).toContain('{ id: "rerank", labelKey: "settings.categories.rerank", icon: ListFilter }')
-    expect(settingsViewSource).toContain('{ id: "embedding", labelKey: "settings.categories.embedding", icon: Database }')
+  it("merges LLM, reranker, embedding, and default model into a single model settings category", () => {
+    expect(settingsViewSource).toContain('| "model"')
+    expect(settingsViewSource).toContain('{ id: "model", labelKey: "settings.categories.model", icon: Brain }')
+    // 原独立分类已归并
+    expect(settingsViewSource).not.toContain('| "llm"')
+    expect(settingsViewSource).not.toContain('| "rerank"')
+    expect(settingsViewSource).not.toContain('| "embedding"')
   })
 
-  it("keeps embedding and reranker panels out of the LLM category body", () => {
-    expect(settingsViewSource).toContain('case "llm":')
-    expect(settingsViewSource).toContain("return <LlmProviderSection />")
-    expect(settingsViewSource).toContain('case "rerank":')
-    expect(settingsViewSource).toContain('return <RerankSection draft={draft} setDraft={setDraft} />')
-    expect(settingsViewSource).toContain('case "embedding":')
-    expect(settingsViewSource).toContain('return <EmbeddingSection draft={draft} setDraft={setDraft} />')
+  it("renders the merged ModelSettingsSection for the model category", () => {
+    expect(settingsViewSource).toContain('case "model":')
+    expect(settingsViewSource).toContain("return <ModelSettingsSection draft={draft} setDraft={setDraft} />")
   })
 
   it("uses the requested Chinese model category names", () => {
-    expect(zh.settings.categories.llm).toBe("大语言/LLM模型")
-    expect(zh.settings.categories.rerank).toBe("重排/Reranker模型")
-    expect(zh.settings.categories.embedding).toBe("向量检索/Embedding模型")
+    expect(zh.settings.categories.model).toBe("模型设置")
     expect(zh.settings.sections.llm.title).toBe("大语言/LLM模型")
     expect(zh.settings.sections.rerank.title).toBe("重排/Reranker模型")
     expect(zh.settings.sections.embedding.title).toBe("向量检索/Embedding模型")
-    expect(zh.settings.sections.llm.description).not.toContain("模型设置")
   })
-})
+})

+ 22 - 0
src/components/settings/settings-model-section.spec.ts

@@ -0,0 +1,22 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "sections/model-settings-section.tsx"), "utf8")
+
+describe("ModelSettingsSection", () => {
+  it("defines the four model tabs and defaults to the LLM tab", () => {
+    expect(source).toContain('{ id: "default", label:')
+    expect(source).toContain('{ id: "llm", label:')
+    expect(source).toContain('{ id: "rerank", label:')
+    expect(source).toContain('{ id: "embedding", label:')
+    expect(source).toContain('useState<ModelTabId>("llm")')
+  })
+
+  it("renders default, LLM, rerank, and embedding panels switchably", () => {
+    expect(source).toContain('active === "llm" && <LlmProviderSection')
+    expect(source).toContain('active === "default" && <DefaultModelSettingsPanel draft={draft} setDraft={setDraft}')
+    expect(source).toContain('active === "rerank" && <RerankSection draft={draft} setDraft={setDraft}')
+    expect(source).toContain('active === "embedding" && <EmbeddingSection draft={draft} setDraft={setDraft}')
+  })
+})

+ 8 - 21
src/components/settings/settings-view.tsx

@@ -2,8 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"
 import {
   Bot,
   BookOpen,
-  Database,
-  ListFilter,
   Palette,
   Network,
   History,
@@ -28,9 +26,7 @@ import { saveLanguage, loadNovelConfig, loadRerankConfig } from "@/lib/project-s
 import type { SettingsDraft, DraftSetter } from "./settings-types"
 import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
 import type { UiFontFamily } from "@/lib/font-settings"
-import { LlmProviderSection } from "./sections/llm-provider-section"
-import { EmbeddingSection } from "./sections/embedding-section"
-import { RerankSection } from "./sections/rerank-section"
+import { ModelSettingsSection } from "./sections/model-settings-section"
 import { InterfaceSection } from "./sections/interface-section"
 import { NovelSection } from "./sections/novel-section"
 import { ClassificationSection } from "./sections/classification-section"
@@ -47,9 +43,7 @@ import { ExportCenterSection } from "./sections/export-center-section"
 import { UserMemorySection } from "./sections/user-memory-section"
 
 type CategoryId =
-  | "llm"
-  | "rerank"
-  | "embedding"
+  | "model"
   | "network"
   | "web-search"
   | "mcp"
@@ -78,9 +72,7 @@ interface Category {
 }
 
 const CATEGORIES: Category[] = [
-  { id: "llm", labelKey: "settings.categories.llm", icon: Bot },
-  { id: "rerank", labelKey: "settings.categories.rerank", icon: ListFilter },
-  { id: "embedding", labelKey: "settings.categories.embedding", icon: Database },
+  { id: "model", labelKey: "settings.categories.model", icon: Brain },
   { id: "novel", labelKey: "settings.categories.novel", hintKey: "settings.categories.novelHint", icon: BookOpen },
   { id: "network", labelKey: "settings.categories.network", icon: Network },
   { id: "web-search", labelKey: "settings.categories.webSearch", icon: Search },
@@ -99,8 +91,7 @@ const CATEGORIES: Category[] = [
 
 /** Settings tabs that edit the shared draft and need the global Save footer. */
 const CATEGORIES_WITH_SAVE_FOOTER: CategoryId[] = [
-  "rerank",
-  "embedding",
+  "model",
   "network",
   "interface",
   "novel",
@@ -200,7 +191,7 @@ export function SettingsView() {
   const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
   const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
 
-  const [active, setActive] = useState<CategoryId>("llm")
+  const [active, setActive] = useState<CategoryId>("model")
   const [saved, setSaved] = useState(false)
   const [draft, setDraftState] = useState<SettingsDraft>(() =>
     initialDraft(
@@ -224,7 +215,7 @@ export function SettingsView() {
   useEffect(() => {
     if (!activeSettingsCategory) return
     if (CATEGORIES.some((category) => category.id === activeSettingsCategory)) {
-      setActive(activeSettingsCategory)
+      setActive(activeSettingsCategory as CategoryId)
     }
     setActiveSettingsCategory(null)
   }, [activeSettingsCategory, setActiveSettingsCategory])
@@ -441,12 +432,8 @@ export function SettingsView() {
 
   const body = useMemo(() => {
     switch (active) {
-      case "llm":
-        return <LlmProviderSection />
-      case "rerank":
-        return <RerankSection draft={draft} setDraft={setDraft} />
-      case "embedding":
-        return <EmbeddingSection draft={draft} setDraft={setDraft} />
+      case "model":
+        return <ModelSettingsSection draft={draft} setDraft={setDraft} />
       case "network":
         return <NetworkSection draft={draft} setDraft={setDraft} />
       case "web-search":

+ 9 - 4
src/i18n/en.json

@@ -687,6 +687,7 @@
     "provider": "Provider",
     "language": "Output language",
     "languageHint": "Interface language. Wiki content language follows the original source material.",
+    "modelSettingsDescription": "Configure the models used in each stage of novel writing: default model, LLM, rerank model, and embedding model.",
     "appearance": "Appearance",
     "proxy": "Proxy",
     "updates": "Updates",
@@ -713,9 +714,11 @@
       "budgetHint": "Optional. Some providers ignore this if the model decides reasoning automatically."
     },
     "categories": {
-      "llm": "Large Language / LLM Model",
-      "rerank": "Rerank / Reranker Model",
-      "embedding": "Vector Search / Embedding Model",
+      "model": "Model Settings",
+      "defaultModel": "Default Model",
+      "llm": "LLM",
+      "rerank": "Rerank Model",
+      "embedding": "Embedding Model",
       "multimodal": "Multimodal",
       "webSearch": "Web Search",
       "network": "Network",
@@ -725,7 +728,7 @@
       "output": "Output",
       "interface": "Interface",
       "novel": "Writing Settings",
-      "novelHint": "Model settings",
+      "novelHint": "Other writing settings",
       "userMemory": "Global User Memory",
       "usageGuide": "Usage Guide",
       "maintenance": "Maintenance",
@@ -1375,6 +1378,8 @@
       "deepPreviousChaptersAnalysisHint": "When enabled, standard/strict generation of chapter 2+ first reads and LLM-analyzes the full text of the previous chapters — stronger continuity, but one extra call per chapter and more tokens. Fast mode always skips this. When off, recent summaries and the previous chapter ending from the memory library are still injected. Off by default to save tokens.",
       "reviewReasoningEffort": "Review thinking effort",
       "reviewReasoningEffortHint": "Controls how deeply the AI review (including six-dimension review) reasons. Lower effort is cheaper and faster but weakens the continuity and canon-conflict gate. High by default.",
+      "modelGroupTitle": "Model Settings",
+      "modelGroupHint": "Specify which models each novel-writing step uses. The Default Model (General) handles review, summary, extract, de-AI and more; below you can assign a specific model for a step when needed.",
       "taskModelsTitle": "Per-Step Models (Novel Writing)",
       "taskModelsHint": "While writing a novel, the review, summary, extract, and de-AI steps can each use their own model. By default they all follow the Default Model above, so you don't have to set anything (only when the default model is also empty do they fall back to the model selected in the chat box). Only when a step should always use a different model, uncheck its Follow default model and pick one on the right.",
       "defaultLlmModel": "Default Model (General)",

+ 9 - 5
src/i18n/zh.json

@@ -419,11 +419,13 @@
     "changeHint": "改动会在保存后应用。",
     "language": "语言",
     "languageHint": "界面语言(Wiki 内容语言以原始资料为准)",
+    "modelSettingsDescription": "配置写小说各环节使用的模型:默认模型、大语言模型、重排模型、向量检索模型。",
     "categories": {
-      "llm": "大语言/LLM模型",
-      "rerank": "重排/Reranker模型",
-      "embedding": "向量检索/Embedding模型",
-      "multimodal": "图片描述",
+      "model": "模型设置",
+      "defaultModel": "默认模型",
+      "llm": "大语言模型",
+      "rerank": "重排模型",
+      "embedding": "向量模型",
       "webSearch": "网页搜索",
       "network": "网络",
       "mcp": "MCP 工具",
@@ -432,7 +434,7 @@
       "output": "输出偏好",
       "interface": "界面",
       "novel": "写作设置",
-      "novelHint": "设置模型",
+      "novelHint": "其他写作设置",
       "userMemory": "全局用户记忆",
       "usageGuide": "软件使用说明",
       "maintenance": "维护",
@@ -1244,6 +1246,8 @@
       "deepPreviousChaptersAnalysisHint": "开启后,标准/严格模式在生成第2章及以后时会先读取并用 LLM 分析前几章完整正文,连贯性更强但每章多一次调用、更费 Token。快速模式始终跳过。关闭时仍会注入记忆库的近期摘要与上一章结尾。默认关闭以省 Token。",
       "reviewReasoningEffort": "审稿思考档位",
       "reviewReasoningEffortHint": "控制 AI 审稿(含六维审查)的思考深度。档位越低越省 Token、越快,但连贯性与设定冲突的把关会变弱。默认「高」。",
+      "modelGroupTitle": "模型设置",
+      "modelGroupHint": "在此指定写小说时各环节使用的模型。「默认模型(通用)」负责审稿、摘要、提取、去 AI 味等功能;下方可在某个环节需要时单独指定模型。",
       "taskModelsTitle": "分环节指定模型(写小说)",
       "taskModelsHint": "写小说时,审稿、摘要、提取信息、去 AI 味这几个环节可以各用一个模型。默认它们都跟随上面的「默认模型」,你不用管(默认模型也没设时,才用聊天框底部选中的模型兜底)。只有某个环节想固定用别的模型时,才取消它的「跟随默认模型」并在右侧选一个。",
       "defaultLlmModel": "默认模型(通用)",

+ 40 - 5
src/lib/novel/book-analysis/analysis-scheduler.spec.ts

@@ -23,10 +23,10 @@ function moduleState(skill: AnalysisSkill, chunkIds: string[]): AnalysisModuleSt
   }
 }
 
-function task(selectedSkills: AnalysisSkill[], chunkIds = ["chunk-1", "chunk-2"]): BookAnalysisPipelineTask {
+function task(selectedSkills: AnalysisSkill[], chunkIds = ["chunk-1", "chunk-2"], taskId = "task-1"): BookAnalysisPipelineTask {
   return {
     version: 1,
-    id: "task-1",
+    id: taskId,
     batchId: null,
     projectPath: "E:/Novel",
     bookId: "book-1",
@@ -70,6 +70,7 @@ function chunks(skills: AnalysisSkill[], count = 2): AnalysisChunkRecord[] {
 
 function createHarness(options: {
   onRun?: (skill: AnalysisSkill, chunkId: string, signal: AbortSignal) => Promise<void>
+  concurrency?: number
 } = {}) {
   const calls: string[] = []
   let running = 0
@@ -101,6 +102,7 @@ function createHarness(options: {
   const scheduler = createAnalysisScheduler({
     adapters,
     llmConfig: {} as LlmConfig,
+    concurrency: options.concurrency,
     saveTask: vi.fn(async () => {}),
     saveChunk: vi.fn(async () => {}),
     saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => {
@@ -127,8 +129,11 @@ describe("analysis scheduler", () => {
     expect(harness.calls.at(-1)).toBe("style:publish")
   })
 
-  it("同一 Skill 的区块并发最多为 2", async () => {
-    const harness = createHarness({ onRun: async () => new Promise((resolve) => setTimeout(resolve, 1)) })
+  it("在并发受限时,同一 Skill 的区块并发受 concurrency 限制", async () => {
+    const harness = createHarness({
+      onRun: async () => new Promise((resolve) => setTimeout(resolve, 1)),
+      concurrency: 2,
+    })
     harness.scheduler.initialize([task(["characters"], ["chunk-1", "chunk-2", "chunk-3"])], chunks(["characters"], 3))
 
     await harness.scheduler.continueTask("task-1")
@@ -137,6 +142,36 @@ describe("analysis scheduler", () => {
     expect(harness.getMaxRunning()).toBe(2)
   })
 
+  it("多个任务(角色/故事)并行运行,而非互相等待", async () => {
+    const harness = createHarness({
+      onRun: async () => new Promise((resolve) => setTimeout(resolve, 1)),
+      concurrency: 4,
+    })
+    // 两个独立任务:角色任务 chunk-a、故事任务 chunk-c;id 各不相同以区分任务
+    const charactersTask = task(["characters"], ["chunk-a"], "task-char")
+    const storyTask = task(["story"], ["chunk-c"], "task-story")
+    const charChunks = chunks(["characters"]).map((chunk, index) => ({
+      ...chunk,
+      id: index === 0 ? "chunk-a" : `chunk-a${index}`,
+      taskId: "task-char",
+    })).slice(0, 1)
+    const storyChunks = chunks(["story"]).map((chunk, index) => ({
+      ...chunk,
+      id: index === 0 ? "chunk-c" : `chunk-c${index}`,
+      taskId: "task-story",
+    })).slice(0, 1)
+    harness.scheduler.initialize([charactersTask, storyTask], [...charChunks, ...storyChunks])
+
+    await Promise.all([
+      harness.scheduler.continueTask("task-char"),
+      harness.scheduler.continueTask("task-story"),
+    ])
+    await harness.scheduler.whenIdle()
+
+    // 两个任务的区块都在并发池内并行推进,同一时刻同时运行
+    expect(harness.getMaxRunning()).toBeGreaterThan(1)
+  })
+
   it("继续任务时跳过已完成区块", async () => {
     const harness = createHarness()
     const taskValue = task(["characters"], ["chunk-1", "chunk-2"])
@@ -155,7 +190,7 @@ describe("analysis scheduler", () => {
   it("暂停后不再派发新的区块并可继续", async () => {
     let release!: () => void
     const gate = new Promise<void>((resolve) => { release = resolve })
-    const harness = createHarness({ onRun: async () => gate })
+    const harness = createHarness({ onRun: async () => gate, concurrency: 2 })
     harness.scheduler.initialize([task(["characters"], ["chunk-1", "chunk-2", "chunk-3"])], chunks(["characters"], 3))
 
     const continuing = harness.scheduler.continueTask("task-1")

+ 3 - 1
src/lib/novel/book-analysis/analysis-scheduler.ts

@@ -94,7 +94,9 @@ function clampProgress(progress: AnalysisRuntimeProgress): AnalysisRuntimeProgre
 }
 
 export function createAnalysisScheduler(options: AnalysisSchedulerOptions): AnalysisScheduler {
-  const concurrency = Math.max(1, Math.min(2, Math.floor(options.concurrency ?? 2)))
+  // 全局 chunk 并发:从默认 2 提升到 4,配合「多任务并行」——同一作品的角色/故事/文风
+  // 可各自推进区块,而非互相等待。仍允许调用方通过 options.concurrency 覆盖。
+  const concurrency = Math.max(1, Math.min(6, Math.floor(options.concurrency ?? 4)))
   const persistTask = options.saveTask ?? saveAnalysisTask
   const persistChunk = options.saveChunk ?? saveAnalysisChunk
   const persistCompletedChunk = options.saveCompletedChunk ?? saveCompletedChunk

+ 6 - 1
src/lib/novel/book-analysis/character-analysis-adapter.ts

@@ -13,6 +13,7 @@ import type {
 } from "./analysis-pipeline-types"
 import type { AnalysisSkillAdapter } from "./analysis-skill-adapter"
 import type { ExtractedCharacter } from "./types"
+import { scheduleVerification } from "./verification-engine"
 
 interface CharacterAnalysisChunkResult {
   characters: ExtractedCharacter[]
@@ -249,7 +250,7 @@ export function createCharacterAnalysisAdapter(
       onProgress?.({ stageLabel: `已选出 ${candidates.length} 个候选角色`, percentage: 95 })
       return candidates
     },
-    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
+    async publish({ task, bookPath, projectPath, llmConfig, result, evidence, onProgress }) {
       onProgress?.({ stageLabel: "正在保存角色结果…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布角色分析")
@@ -278,6 +279,10 @@ export function createCharacterAnalysisAdapter(
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
       onProgress?.({ stageLabel: "角色结果已发布", percentage: 100 })
+
+      // 后台审计:三重验证 + 压力测试(best-effort,失败不影响任务)
+      void scheduleVerification(bookPath, "characters", llmConfig)
+        .catch((error) => console.warn("[characters-verify] 校验失败:", error))
       return resultPath
     },
   }

+ 46 - 2
src/lib/novel/book-analysis/character-candidate-selection.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest"
-import { selectCharacterCandidates } from "./character-candidate-selection"
-import type { ExtractedCharacter } from "./types"
+import { recognizedCharacterToExtracted, selectCharacterCandidates } from "./character-candidate-selection"
+import type { ExtractedCharacter, RecognizedCharacter } from "./types"
 
 const character = (overrides: Partial<ExtractedCharacter>): ExtractedCharacter => ({
   id: "character",
@@ -31,3 +31,47 @@ describe("角色候选筛选", () => {
     expect(candidates.map((item) => item.candidateCategory)).toEqual(["protagonist", "supporting"])
   })
 })
+
+describe("recognizedCharacterToExtracted(识别角色 → 角色档案)", () => {
+  const recognized = (overrides: Partial<RecognizedCharacter>): RecognizedCharacter => ({
+    id: "char-hanli",
+    name: "韩立",
+    aliases: ["小韩"],
+    appearances: 12,
+    chapterIndices: [0, 4, 9],
+    importanceScore: 92,
+    category: "主角",
+    sourceBook: "E:/book",
+    ...overrides,
+  })
+
+  it("映射分类、出场范围与重要性(0-100 → 1-10)", () => {
+    const extracted = recognizedCharacterToExtracted(recognized())
+    expect(extracted.id).toBe("char-hanli")
+    expect(extracted.name).toBe("韩立")
+    expect(extracted.aliases).toEqual(["小韩"])
+    expect(extracted.category).toBe("protagonist")
+    expect(extracted.importance).toBe(10)
+    expect(extracted.firstAppearance).toBe(1)
+    expect(extracted.lastAppearance).toBe(10)
+    expect(extracted.appearanceCount).toBe(12)
+    expect(extracted.relationships).toEqual([])
+    expect(extracted.keyEvents).toEqual([])
+  })
+
+  it("配角 / 次要分类与重要性越界钳制", () => {
+    const supporting = recognizedCharacterToExtracted(recognized({ category: "配角", importanceScore: 55 }))
+    expect(supporting.category).toBe("supporting")
+    expect(supporting.importance).toBe(6)
+
+    const minor = recognizedCharacterToExtracted(recognized({ category: "次要", importanceScore: 3 }))
+    expect(minor.category).toBe("minor")
+    expect(minor.importance).toBe(1)
+  })
+
+  it("无出场章节索引时兜底为第 1 章", () => {
+    const extracted = recognizedCharacterToExtracted(recognized({ chapterIndices: [] }))
+    expect(extracted.firstAppearance).toBe(1)
+    expect(extracted.lastAppearance).toBe(1)
+  })
+})

+ 32 - 3
src/lib/novel/book-analysis/character-candidate-selection.ts

@@ -1,8 +1,8 @@
-import type { ExtractedCharacter } from "./types"
+import type { ExtractedCharacter, RecognizedCharacter } from "./types"
 
-type CharacterCandidateCategory = "protagonist" | "supporting" | "minor"
+export type CharacterCandidateCategory = "protagonist" | "supporting" | "minor"
 
-interface CharacterCandidate extends ExtractedCharacter {
+export interface CharacterCandidate extends ExtractedCharacter {
   candidateCategory: CharacterCandidateCategory
   candidateScore: number
 }
@@ -40,3 +40,32 @@ export function selectCharacterCandidates(characters: ExtractedCharacter[]): Cha
     .filter((character): character is CharacterCandidate => character !== null)
     .sort((left, right) => right.candidateScore - left.candidateScore)
 }
+
+/**
+ * 把「已识别角色」转换为可持久化的最小角色档案(fix/recognized-persist)。
+ * 深度分析尚未完成时,用户在「选择角色生成 Skill」弹窗中勾选已识别角色,
+ * 先落盘为角色档案(characters/{id}.json)再生成 Skill,之后无需重新识别。
+ */
+export function recognizedCharacterToExtracted(character: RecognizedCharacter): ExtractedCharacter {
+  const category: ExtractedCharacter["category"] =
+    character.category === "主角" ? "protagonist"
+    : character.category === "配角" ? "supporting"
+    : "minor"
+  const firstIndex = character.chapterIndices[0] ?? 0
+  const lastIndex = character.chapterIndices[character.chapterIndices.length - 1] ?? 0
+  return {
+    id: character.id,
+    name: character.name,
+    aliases: character.aliases ?? [],
+    importance: Math.max(1, Math.min(10, Math.ceil(character.importanceScore / 10))),
+    category,
+    firstAppearance: firstIndex + 1,
+    lastAppearance: lastIndex + 1,
+    appearanceCount: character.appearances,
+    description: "",
+    personality: "",
+    speechStyle: "",
+    relationships: [],
+    keyEvents: [],
+  }
+}

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

@@ -145,4 +145,51 @@ describe("loadBookAnalysisLibraryState", () => {
     expect(state.bindings).toEqual([{ characterName: "主角", auraId: "aura-hanli", auraName: "韩立" }])
     expect(state.books[0].boundAurasCount).toBe(1)
   })
+
+  it("skills 按确定性规则匹配角色:短名角色不再抢走长名角色的 Skill(fix/skill-match 回归)", async () => {
+    const projectPath = "E:/Novel"
+    const bookPath = `${projectPath}/book-analysis/book-1`
+    mockFs.directories.set(`${projectPath}/book-analysis`, [
+      { name: "book-1", path: bookPath, is_dir: true },
+    ])
+    mockFs.files.set(`${bookPath}/metadata.json`, JSON.stringify({
+      title: "凡人修仙传",
+      totalChapters: 10,
+      totalWords: 100000,
+      sourceType: "file",
+      createdAt: 1,
+      updatedAt: 2,
+    }))
+    // 「韩」在 characters 数组中排在「韩立」之前(listDirectory 返回顺序)
+    mockFs.directories.set(`${bookPath}/characters`, [
+      { name: "han.json", path: `${bookPath}/characters/han.json`, is_dir: false },
+      { name: "hanli.json", path: `${bookPath}/characters/hanli.json`, is_dir: false },
+    ])
+    mockFs.files.set(`${bookPath}/characters/han.json`, JSON.stringify({
+      id: "char-han", name: "韩", aliases: [], importance: 4, category: "minor",
+      firstAppearance: 1, lastAppearance: 2, appearanceCount: 3,
+      description: "", personality: "", speechStyle: "", relationships: [], keyEvents: [],
+    }))
+    mockFs.files.set(`${bookPath}/characters/hanli.json`, JSON.stringify({
+      id: "char-hanli", name: "韩立", aliases: [], importance: 9, category: "protagonist",
+      firstAppearance: 1, lastAppearance: 10, appearanceCount: 30,
+      description: "谨慎", personality: "隐忍", speechStyle: "少承诺", relationships: [], keyEvents: [],
+    }))
+    mockFs.directories.set(`${bookPath}/skills`, [
+      { name: "韩立-skill.md", path: `${bookPath}/skills/韩立-skill.md`, is_dir: false },
+    ])
+    mockFs.files.set(`${bookPath}/skills/韩立-skill.md`, "---\nname: 韩立\n---\n# 韩立")
+
+    const state = await loadBookAnalysisLibraryState(projectPath)
+    const target = state.books.find((book) => book.id === "book-1")!
+    expect(target.skills).toHaveLength(1)
+    expect(target.skills[0].characterId).toBe("char-hanli")
+    expect(target.skills[0].characterName).toBe("韩立")
+
+    // 韩立应有 Skill(按钮不再置灰),韩没有
+    const hanliHasSkill = target.skills.some((skill) => skill.characterId === "char-hanli")
+    const hanHasSkill = target.skills.some((skill) => skill.characterId === "char-han")
+    expect(hanliHasSkill).toBe(true)
+    expect(hanHasSkill).toBe(false)
+  })
 })

+ 5 - 9
src/lib/novel/book-analysis/library-state.ts

@@ -4,6 +4,7 @@ import { loadWritingStyleStore, type WritingStylePreset } from "@/lib/novel/writ
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { isSameBookAnalysisCharacterAura } from "./aura-match"
 import { loadRecognizedCharacters } from "./recognized-character-store"
+import { findCharacterForSkillFile } from "./skill-character-match"
 import type {
   BookAnalysisMetadata,
   BookAnalysisResult,
@@ -79,15 +80,10 @@ async function loadSkills(
       if (!file.is_dir && file.name.endsWith(".md")) {
         const content = await readFile(file.path)
         const baseName = file.name.replace(/-skill\.md$/i, "").replace(/\.md$/i, "")
-        // 匹配策略:1) 精确名称匹配 2) 文件名包含角色名 3) safeFileName 转换后匹配
-        const character = characters.find((item) => {
-          if (item.name === baseName) return true
-          if (file.name.includes(item.name)) return true
-          // skill-generator 使用 safeFileName = name.replace(/[^一-龥a-zA-Z0-9]/g, "_")
-          const safeName = item.name.replace(/[^一-龥a-zA-Z0-9]/g, "_")
-          if (safeName === baseName) return true
-          return false
-        })
+        // 匹配策略(fix/skill-match,确定性优先级):
+        // 文件名精确名 → safeFileName → 别名 → Skill frontmatter 的 name 字段。
+        // 移除旧的「文件名包含角色名」宽松子串匹配,避免短名角色抢走长名角色的 Skill。
+        const character = findCharacterForSkillFile(file.name, content, characters)
         skills.push({
           id: character ? `skill-${character.id}` : `skill-${baseName}`,
           characterId: character?.id ?? baseName,

+ 3 - 3
src/lib/novel/book-analysis/result-loader.ts

@@ -6,6 +6,7 @@
  */
 import { readFile, listDirectory } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
+import { findCharacterForSkillFile } from "./skill-character-match"
 import type {
   BookAnalysisResult,
   BookAnalysisMetadata,
@@ -60,9 +61,8 @@ export async function loadBookAnalysisResult(
       if (!f.is_dir && f.name.endsWith(".md")) {
         const content = await readFile(f.path)
         const baseName = f.name.replace(/-skill\.md$/i, "").replace(/\.md$/i, "")
-        const character = characters.find(
-          (c) => c.name === baseName || f.name.includes(c.name),
-        )
+        // fix/skill-match:与 library-state 同一确定性匹配,避免子串误配
+        const character = findCharacterForSkillFile(f.name, content, characters)
         skills.push({
           id: character ? `skill-${character.id}` : `skill-${baseName}`,
           characterId: character?.id ?? baseName,

+ 64 - 0
src/lib/novel/book-analysis/skill-character-match.spec.ts

@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest"
+import { findCharacterForSkillFile } from "./skill-character-match"
+import type { ExtractedCharacter } from "./types"
+
+const character = (overrides: Partial<ExtractedCharacter>): ExtractedCharacter => ({
+  id: `char-${overrides.name ?? "角色"}`,
+  name: "角色",
+  aliases: [],
+  importance: 5,
+  category: "minor",
+  firstAppearance: 1,
+  lastAppearance: 4,
+  appearanceCount: 2,
+  description: "",
+  personality: "",
+  speechStyle: "",
+  relationships: [],
+  keyEvents: [],
+  ...overrides,
+})
+
+describe("findCharacterForSkillFile(Skill ↔ 角色确定性匹配)", () => {
+  it("文件名与角色名精确匹配", () => {
+    const hanli = character({ name: "韩立", id: "char-hanli" })
+    const found = findCharacterForSkillFile("韩立-skill.md", "---\nname: 韩立\n---\n正文", [hanli])
+    expect(found?.id).toBe("char-hanli")
+  })
+
+  it("短名角色不再抢走长名角色的 Skill(旧子串匹配回归)", () => {
+    const han = character({ name: "韩", id: "char-han", appearanceCount: 3 })
+    const hanli = character({ name: "韩立", id: "char-hanli", appearanceCount: 30 })
+    // 旧逻辑:characters 中「韩」在前,file.name.includes("韩") 为真 → 「韩」抢走 Skill
+    const found = findCharacterForSkillFile("韩立-skill.md", "---\nname: 韩立\n---\n正文", [han, hanli])
+    expect(found?.id).toBe("char-hanli")
+
+    // 「韩」自己的 Skill 才归「韩」
+    const foundShort = findCharacterForSkillFile("韩-skill.md", "---\nname: 韩\n---\n正文", [han, hanli])
+    expect(foundShort?.id).toBe("char-han")
+  })
+
+  it("safeFileName 匹配:名字含特殊字符时按替换规则命中", () => {
+    const iron = character({ name: "铁·柱", id: "char-tiezhu" })
+    const found = findCharacterForSkillFile("铁_柱-skill.md", "---\nname: 铁·柱\n---\n正文", [iron])
+    expect(found?.id).toBe("char-tiezhu")
+  })
+
+  it("别名匹配:Skill 文件名使用角色别名时命中本人", () => {
+    const hanli = character({ name: "韩立", aliases: ["小韩", "韩跑跑"], id: "char-hanli" })
+    const found = findCharacterForSkillFile("韩跑跑-skill.md", "---\nname: 韩跑跑\n---\n正文", [hanli])
+    expect(found?.id).toBe("char-hanli")
+  })
+
+  it("frontmatter name 兜底:文件名与角色名不一致时按 frontmatter 命中", () => {
+    const hanli = character({ name: "韩立", id: "char-hanli" })
+    const found = findCharacterForSkillFile("renamed-skill.md", "---\nname: 韩立\nsourceBook: 凡人修仙传\n---\n正文", [hanli])
+    expect(found?.id).toBe("char-hanli")
+  })
+
+  it("完全无法匹配时返回 undefined", () => {
+    const hanli = character({ name: "韩立", id: "char-hanli" })
+    expect(findCharacterForSkillFile("张三-skill.md", "---\nname: 张三\n---\n正文", [hanli])).toBeUndefined()
+    expect(findCharacterForSkillFile("张三-skill.md", "无 frontmatter 正文", [hanli])).toBeUndefined()
+  })
+})

+ 47 - 0
src/lib/novel/book-analysis/skill-character-match.ts

@@ -0,0 +1,47 @@
+/**
+ * Skill 文件 ↔ 角色 的确定性匹配(fix/skill-match)
+ *
+ * 背景:旧逻辑含「文件名包含角色名」的宽松子串匹配,短名角色会抢走
+ * 长名角色的 Skill(如角色「韩」抢先匹配到「韩立-skill.md」),导致
+ * 角色面板显示「未生成」、「加入自定义灵魂库」按钮置灰。
+ *
+ * 匹配链(确定性优先级,逐级回退):
+ *   1. 文件名(去掉 -skill.md / .md)与角色名精确相等
+ *   2. 与 safeFileName(非法字符替换为 _)相等
+ *   3. 与别名(原名 / safe 名)相等
+ *   4. 与 Skill frontmatter 的 name 字段相等(原名 / safe 名 / 别名)
+ */
+import type { ExtractedCharacter } from "./types"
+
+function safeSkillFileName(value: string): string {
+  return value.replace(/[^一-龥a-zA-Z0-9]/g, "_")
+}
+
+function skillBaseName(fileName: string): string {
+  return fileName.replace(/-skill\.md$/i, "").replace(/\.md$/i, "")
+}
+
+function parseFrontmatterName(content: string): string | null {
+  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+  if (!match) return null
+  const nameMatch = match[1].match(/^name:\s*(.+?)\s*$/m)
+  return nameMatch ? nameMatch[1].trim() : null
+}
+
+function matchesName(character: ExtractedCharacter, name: string): boolean {
+  if (character.name === name || safeSkillFileName(character.name) === name) return true
+  return character.aliases.some((alias) => alias === name || safeSkillFileName(alias) === name)
+}
+
+export function findCharacterForSkillFile(
+  fileName: string,
+  skillContent: string,
+  characters: ExtractedCharacter[],
+): ExtractedCharacter | undefined {
+  const baseName = skillBaseName(fileName)
+  const byBase = characters.find((character) => matchesName(character, baseName))
+  if (byBase) return byBase
+  const frontmatterName = parseFrontmatterName(skillContent)
+  if (!frontmatterName) return undefined
+  return characters.find((character) => matchesName(character, frontmatterName))
+}

+ 56 - 4
src/lib/novel/book-analysis/story-analysis-adapter.spec.ts

@@ -56,7 +56,19 @@ describe("story analysis adapter", () => {
       category: "主角" as const,
       sourceBook: "测试作品",
     }])
-    const callModel = vi.fn(async (messages) => messages[1].content)
+    const mapJson = JSON.stringify({
+      mainLineLabel: "成长主线",
+      mainSummary: "主角一路推进",
+      chapters: [{
+        id: "ch-0001",
+        order: 1,
+        title: "第一章",
+        summary: "林远推门而入",
+        mainEvents: [{ label: "夜探老宅", beats: ["潜入", "发现线索"], characters: ["林远"] }],
+        branches: [{ id: "b1", kind: "foreshadow", label: "老宅秘密", triggeredBy: "夜探老宅", events: [] }],
+      }],
+    })
+    const callModel = vi.fn(async () => mapJson)
     const adapter = createStoryAnalysisAdapter({
       recognizeCharacters,
       callModel,
@@ -94,13 +106,35 @@ describe("story analysis adapter", () => {
     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.result.map.chapters[0].id).toBe("ch-0001")
+    expect(output.result.map.chapters[0].branches[0].kind).toBe("foreshadow")
     expect(output.evidence[0].skill).toBe("story")
   })
 
   it("故事汇总只保留已完成区块的用户章节范围", async () => {
-    const callModel = vi.fn(async () => "汇总结果")
+    const mergedJson = JSON.stringify({
+      mainLineLabel: "主线",
+      chapters: [{ id: "ch-0001", order: 1, summary: "s", mainEvents: [{ label: "e" }] }],
+    })
+    const callModel = vi.fn(async () => mergedJson)
     const adapter = createStoryAnalysisAdapter({ callModel })
     const inputTask = task()
+    const makeMap = (chapterId: string, order: number) => ({
+      schemaVersion: 1 as const,
+      bookId: inputTask.bookId,
+      bookTitle: "测试作品",
+      mainLineLabel: "主线",
+      mainSummary: "",
+      createdAt: 1,
+      chapters: [{
+        id: chapterId,
+        order,
+        title: `第${order}章`,
+        summary: "s",
+        mainEvents: [{ label: "e", beats: [], characters: [] }],
+        branches: [],
+      }],
+    })
     const result = await adapter.aggregate({
       task: inputTask,
       skill: "story",
@@ -108,13 +142,31 @@ describe("story analysis adapter", () => {
       projectPath: inputTask.projectPath,
       llmConfig: {} as LlmConfig,
       chunks: [
-        { markdown: "区块一", rangeChapterIds: ["ch-0001", "ch-0002"] },
-        { markdown: "区块二", rangeChapterIds: ["ch-0011", "ch-0012"] },
+        { map: makeMap("ch-0001", 1), rangeChapterIds: ["ch-0001", "ch-0002"] },
+        { map: makeMap("ch-0011", 11), 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)
+    expect(result.map.chapters[0].id).toBe("ch-0001")
+  })
+
+  it("检测到旧版四段区块时明确报错,提示重新提取", async () => {
+    const adapter = createStoryAnalysisAdapter({ callModel: vi.fn(async () => "{}") })
+    const inputTask = task()
+    await expect(adapter.aggregate({
+      task: inputTask,
+      skill: "story",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunks: [
+        { markdown: "区块一", rangeChapterIds: ["ch-0001"] } as never,
+        { markdown: "区块二", rangeChapterIds: ["ch-0011"] } as never,
+      ],
+      signal: new AbortController().signal,
+    })).rejects.toThrow("旧版四段格式")
   })
 })

+ 137 - 40
src/lib/novel/book-analysis/story-analysis-adapter.ts

@@ -8,14 +8,21 @@ import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-
 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"
+import {
+  buildStoryMapAggregatePrompt,
+  buildStoryMapPrompt,
+  parseStoryMapResult,
+} from "./story-map-prompts"
+import { writeStoryMapFiles } from "./story-map-history"
+import type { StoryMap } from "./story-map-types"
+import { scheduleVerification } from "./verification-engine"
 
 interface StoryAnalysisChunkResult {
-  markdown: string
+  map: StoryMap
   rangeChapterIds: string[]
 }
 
@@ -30,6 +37,7 @@ interface StoryAnalysisAdapterDependencies {
   loadManifest: typeof loadAnalysisManifest
   saveManifest: typeof saveAnalysisManifest
   rebuildContextIndex: typeof rebuildBookAnalysisContextIndex
+  writeMapFiles: (bookPath: string, map: StoryMap) => Promise<void>
   now: () => number
 }
 
@@ -61,6 +69,7 @@ const defaultDependencies: StoryAnalysisAdapterDependencies = {
   loadManifest: loadAnalysisManifest,
   saveManifest: saveAnalysisManifest,
   rebuildContextIndex: rebuildBookAnalysisContextIndex,
+  writeMapFiles: writeStoryMapFiles,
   now: Date.now,
 }
 
@@ -88,7 +97,7 @@ function storyEvidence(
     text: trimEvidenceText(chapter.content),
     tags: [index === 0 ? "开局与铺垫" : "推进与钩子", "故事结构"],
     reason: index === 0 ? "用于理解本区块如何建立期待" : "用于理解本区块如何推进并留下钩子",
-    purpose: "故事节奏与四段结构参考",
+    purpose: "故事节奏与主线/分支结构参考",
     enabled: true,
     userPinned: false,
     createdAt: now,
@@ -96,17 +105,64 @@ function storyEvidence(
   })).filter((item) => item.text)
 }
 
-function buildAggregatePrompt(chunks: StoryAnalysisChunkResult[]): string {
+/** 本地合并多区块导图(LLM 汇总失败时的兜底:按 order 排序拼接) */
+function mergeStoryMapsLocally(maps: StoryMap[], createdAt: number): StoryMap {
+  const base = maps[0]
+  const chapters = maps
+    .flatMap((map) => map.chapters)
+    .sort((left, right) => left.order - right.order)
+  return {
+    ...base,
+    createdAt,
+    chapters,
+    mainLineLabel: maps.find((map) => map.mainLineLabel && map.mainLineLabel !== "主线")?.mainLineLabel ?? base.mainLineLabel,
+    mainSummary: maps.map((map) => map.mainSummary).filter(Boolean).join(";") || base.mainSummary,
+  }
+}
+
+/** 由 StoryMap 合成旧版四段 markdown(保持 PlotFramework 兼容,缺段不致命) */
+export function synthesizeLegacyMarkdownFromMap(map: StoryMap): string {
+  const eventLine = (event: StoryMap["chapters"][number]["mainEvents"][number]): string =>
+    [event.label, ...event.beats].filter(Boolean).join(";")
+  const chapters = map.chapters
+  const firstChapter = chapters[0]
+  const lastChapter = chapters[chapters.length - 1]
+  const first = firstChapter?.mainEvents[0]
+  const lastChapterEvents = lastChapter?.mainEvents ?? []
+  const last = lastChapterEvents[lastChapterEvents.length - 1]
+  const middle = chapters
+    .slice(1, Math.max(1, chapters.length - 1))
+    .flatMap((chapter) => chapter.mainEvents.slice(0, 2))
+  const hook = first ? eventLine(first) : map.mainSummary
+  const buildup = middle.length > 0 ? middle.map(eventLine).join(";") : hook
+  const lastMiddle = middle[middle.length - 1]
+  const payoff = last ? eventLine(last) : lastMiddle ? eventLine(lastMiddle) : hook
+  const endingHook = last?.spinoff ?? lastChapter?.branches[0]?.label ?? payoff
+  const reusableTemplate = map.mainSummary || `${map.mainLineLabel}结构`
   return [
-    "你是小说故事框架汇总助手。以下内容是同一用户所选章节范围内,各章节区块已经完成的四段框架分析。",
-    "只合并这些已完成结果,不补写范围外剧情,不输出角色档案或角色 Skill。",
-    "请去重并保留区块间的衔接,严格按原有标题输出:框架归属与衔接、开局钩子、铺垫、爽点、结尾钩子、可复用结构记忆。",
+    "## 框架归属与衔接",
+    `属于:${"main"}`,
+    `与上一框架衔接点:${map.chapters[0]?.summary ?? ""}`,
+    `与下一框架衔接点:${endingHook}`,
+    `覆盖本批章节数:${map.chapters.length}`,
+    "",
+    "## 开局钩子",
+    hook,
     "",
-    ...chunks.map((chunk, index) => [
-      `# 区块 ${index + 1}(${chunk.rangeChapterIds.join("、")})`,
-      chunk.markdown,
-    ].join("\n")),
-  ].join("\n\n")
+    "## 铺垫",
+    buildup,
+    "",
+    "## 爽点",
+    payoff,
+    "",
+    "## 结尾钩子",
+    endingHook,
+    "",
+    "## 可复用结构记忆",
+    `一句话可复用模板:${reusableTemplate}`,
+    "适用场景:主线推进 + 分支生发的节奏参考。",
+    "作者手搓留白:请在章纲阶段用人设卡、文风、对话设计、整活或玩梗补充血肉层。",
+  ].join("\n")
 }
 
 export function createStoryAnalysisAdapter(
@@ -139,55 +195,92 @@ export function createStoryAnalysisAdapter(
           category: character.category,
         }))
       }
-      onProgress?.({ stageLabel: "正在调用模型分析故事框架…", percentage: 50 })
-      const markdown = await dependencies.callModel([
-        { role: "system", content: "你是严谨的小说故事框架拆解助手,必须输出可复用的中文四段框架。" },
+      onProgress?.({ stageLabel: "正在提取主线与分支导图…", percentage: 50 })
+      const raw = await dependencies.callModel([
+        { role: "system", content: "你是严谨的小说故事结构拆解助手,只输出用户要求的 JSON,不要围栏与解释。" },
         {
           role: "user",
-          content: buildBookStoryFrameworkPrompt({
+          content: buildStoryMapPrompt({
             bookTitle: metadata.title,
             chapters,
             temporaryCharacters,
           }),
         },
       ], llmConfig, signal)
-      onProgress?.({ stageLabel: "解析故事框架结果…", percentage: 90 })
+      onProgress?.({ stageLabel: "解析故事导图结果…", percentage: 90 })
+      const map = parseStoryMapResult(raw, {
+        bookId: task.bookId,
+        bookTitle: metadata.title,
+        chapterIds: chunk.chapterIds,
+      })
+      if (!map) {
+        throw new Error("故事导图提取失败:AI 输出缺少主线事件或章节结构,请重试该区块")
+      }
       return {
-        result: { markdown, rangeChapterIds: chunk.chapterIds },
+        result: { map, rangeChapterIds: chunk.chapterIds },
         evidence: storyEvidence(task.id, task.bookId, chunk.id, chapters, dependencies.now()),
       }
     },
-    async aggregate({ chunks, llmConfig, signal, onProgress }) {
+    async aggregate({ task, chunks, llmConfig, signal, onProgress }) {
       if (chunks.length === 0) throw new Error("没有已完成的故事区块可供汇总")
+      const legacy = chunks.find((chunk) => !(chunk as Partial<StoryAnalysisChunkResult>).map)
+      if (legacy) {
+        throw new Error("检测到旧版四段格式的故事区块,无法合并为导图;请重新提取故事区块")
+      }
       const rangeChapterIds = chunks.flatMap((chunk) => chunk.rangeChapterIds)
       if (chunks.length === 1) {
         onProgress?.({ stageLabel: "单区块无需汇总", percentage: 95 })
         return { ...chunks[0], rangeChapterIds }
       }
-      onProgress?.({ stageLabel: "正在调用模型汇总故事框架…", percentage: 93 })
-      const markdown = await dependencies.callModel([
-        { role: "system", content: "你只汇总已有故事分析,禁止补写未分析章节。" },
-        { role: "user", content: buildAggregatePrompt(chunks) },
-      ], llmConfig, signal)
-      onProgress?.({ stageLabel: "故事汇总完成", percentage: 95 })
-      return { markdown, rangeChapterIds }
+      onProgress?.({ stageLabel: "正在汇总主线与分支…", percentage: 93 })
+      let merged: StoryMap | null = null
+      try {
+        const raw = await dependencies.callModel([
+          { role: "system", content: "你只汇总已有故事导图,禁止新增未分析章节。" },
+          { role: "user", content: buildStoryMapAggregatePrompt(chunks.map((chunk) => chunk.map)) },
+        ], llmConfig, signal)
+        merged = parseStoryMapResult(raw, {
+          bookId: task.bookId,
+          bookTitle: chunks[0].map.bookTitle,
+          chapterIds: rangeChapterIds,
+        })
+      } catch (error) {
+        if (signal.aborted) throw error
+        console.warn("[story-map] LLM 汇总失败,回退本地合并:", error)
+      }
+      const map = merged ?? mergeStoryMapsLocally(chunks.map((chunk) => chunk.map), dependencies.now())
+      onProgress?.({ stageLabel: "故事导图汇总完成", percentage: 95 })
+      return { map, rangeChapterIds }
     },
-    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
-      onProgress?.({ stageLabel: "正在发布故事框架…", percentage: 97 })
+    async publish({ task, bookPath, projectPath, llmConfig, result, evidence, onProgress }) {
+      onProgress?.({ stageLabel: "正在发布故事导图…", percentage: 97 })
       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)
+
+      // 主产物:story-map.json + story-map.html
+      const map = { ...result.map, createdAt: result.map.createdAt || dependencies.now() }
+      await dependencies.writeMapFiles(bookPath, map)
+
+      // 兼容产物:由导图合成四段框架(失败仅告警,不再让整个故事分析失败)
+      let resultPath = `story-map:${task.bookId}`
+      try {
+        const markdown = synthesizeLegacyMarkdownFromMap(map)
+        const framework = dependencies.buildDraft({
+          bookId: task.bookId,
+          bookTitle: metadata.title,
+          markdown,
+          rangeChapterIds: result.rangeChapterIds,
+          createdAt: dependencies.now(),
+        })
+        if (framework) {
+          const saved = await dependencies.upsertFramework(projectPath, framework)
+          resultPath = `plot-framework:${saved.id}`
+        }
+      } catch (error) {
+        console.warn("[story-map] 合成剧情框架失败(不影响导图发布):", error)
+      }
       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 = {
@@ -199,7 +292,7 @@ export function createStoryAnalysisAdapter(
             ...task.modules.story,
             status: "completed",
             resultPath,
-            summary: `提取四段故事框架,覆盖第 ${task.modules.story.range.startOrder}~${task.modules.story.range.endOrder} 章。`,
+            summary: `提取故事导图(主线 ${map.chapters.reduce((sum, c) => sum + c.mainEvents.length, 0)} 事件 / 分支 ${map.chapters.reduce((sum, c) => sum + c.branches.length, 0)} 条),覆盖第 ${task.modules.story.range.startOrder}~${task.modules.story.range.endOrder} 章。`,
             updatedAt,
           },
         },
@@ -207,7 +300,11 @@ export function createStoryAnalysisAdapter(
       }
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
-      onProgress?.({ stageLabel: "故事框架已发布", percentage: 100 })
+      onProgress?.({ stageLabel: "故事导图已发布", percentage: 100 })
+
+      // 后台审计:三重验证 + 压力测试(best-effort,失败不影响任务)
+      void scheduleVerification(bookPath, "story", llmConfig)
+        .catch((error) => console.warn("[story-verify] 校验失败:", error))
       return resultPath
     },
   }

+ 99 - 0
src/lib/novel/book-analysis/story-map-history.spec.ts

@@ -0,0 +1,99 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { realFs, createTempProject, fileExists, readFileRaw } from "@/test-helpers/fs-temp"
+import type { StoryMap } from "./story-map-types"
+import { listStoryMapHistory, writeStoryMapFiles } from "./story-map-history"
+
+vi.mock("@/commands/fs", () => realFs)
+
+function makeMap(createdAt: number, mainLineLabel: string, orders: number[]): StoryMap {
+  return {
+    schemaVersion: 1,
+    bookId: "book-1",
+    bookTitle: "测试作品",
+    mainLineLabel,
+    mainSummary: "",
+    createdAt,
+    chapters: orders.map((order) => ({
+      id: `ch-${order}`,
+      order,
+      title: `第${order}章`,
+      summary: "摘要",
+      mainEvents: [{ label: `主线事件${order}`, beats: [], characters: [] }],
+      branches: order % 2 === 0 ? [{ id: `b-${order}`, kind: "foreshadow", label: `伏笔${order}`, triggeredBy: "", events: [] }] : [],
+    })),
+  }
+}
+
+describe("story map history storage", () => {
+  let dir: string
+  let cleanup: () => Promise<void>
+
+  beforeEach(async () => {
+    const temp = await createTempProject("story-map-history")
+    dir = temp.path
+    cleanup = temp.cleanup
+  })
+
+  afterEach(async () => {
+    await cleanup()
+  })
+
+  it("每次生成写入独立历史目录,且根目录保留最新引用", async () => {
+    const first = makeMap(100, "主线A", [1, 2])
+    const second = makeMap(200, "主线B", [3, 4])
+
+    await writeStoryMapFiles(dir, first)
+    await writeStoryMapFiles(dir, second)
+
+    // 历史目录两份,各自保留 json + html
+    expect(await fileExists(`${dir}/story-maps/story-map-100/story-map.json`)).toBe(true)
+    expect(await fileExists(`${dir}/story-maps/story-map-100/story-map.html`)).toBe(true)
+    expect(await fileExists(`${dir}/story-maps/story-map-200/story-map.json`)).toBe(true)
+    expect(await fileExists(`${dir}/story-maps/story-map-200/story-map.html`)).toBe(true)
+
+    // 根目录最新引用指向最近一次
+    const latest = JSON.parse(await readFileRaw(`${dir}/story-map.json`)) as StoryMap
+    expect(latest.createdAt).toBe(200)
+    expect(await fileExists(`${dir}/story-map.html`)).toBe(true)
+  })
+
+  it("同一毫秒重复生成时自动追加序号,互不覆盖", async () => {
+    const a = makeMap(100, "主线A", [1])
+    const b = makeMap(100, "主线B", [2])
+
+    await writeStoryMapFiles(dir, a)
+    await writeStoryMapFiles(dir, b)
+
+    expect(await fileExists(`${dir}/story-maps/story-map-100/story-map.json`)).toBe(true)
+    expect(await fileExists(`${dir}/story-maps/story-map-100-1/story-map.json`)).toBe(true)
+    // 根目录最新为后写入那份
+    const latest = JSON.parse(await readFileRaw(`${dir}/story-map.json`)) as StoryMap
+    expect(latest.mainLineLabel).toBe("主线B")
+  })
+
+  it("listStoryMapHistory 按生成时间升序返回全部历史", async () => {
+    await writeStoryMapFiles(dir, makeMap(300, "主线C", [7]))
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+    await writeStoryMapFiles(dir, makeMap(200, "主线B", [4]))
+
+    const history = await listStoryMapHistory(dir)
+    expect(history.map((entry) => entry.map.createdAt)).toEqual([100, 200, 300])
+    expect(history.map((entry) => entry.map.mainLineLabel)).toEqual(["主线A", "主线B", "主线C"])
+    // html 路径存在
+    expect(await fileExists(history[0].htmlPath)).toBe(true)
+  })
+
+  it("无历史目录时返回空数组", async () => {
+    expect(await listStoryMapHistory(dir)).toEqual([])
+  })
+
+  it("损坏的历史目录被跳过,不影响其他历史", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+    // 写入一个没有有效 json 的目录
+    await realFs.writeFile(`${dir}/story-maps/story-map-500/story-map.json`, "not-json{{")
+
+    const history = await listStoryMapHistory(dir)
+    expect(history).toHaveLength(1)
+    expect(history[0].map.createdAt).toBe(100)
+  })
+})

+ 102 - 0
src/lib/novel/book-analysis/story-map-history.ts

@@ -0,0 +1,102 @@
+/**
+ * 故事导图历史管理(chaishugushidaotu 分支)
+ *
+ * 需求:每次故事 Skill 生成的导图都要完整保留,不能覆盖之前生成的。
+ *
+ * 落盘策略:
+ * 1. 历史版:story-maps/story-map-<createdAt>/story-map.{json,html}
+ *    —— 每次生成新增一份,永不覆盖。
+ * 2. 最新引用:根目录 story-map.{json,html}
+ *    —— 始终指向最近一次,供验证引擎 / 「打开故事导图」/ 旧数据兼容使用。
+ */
+
+import {
+  createDirectory,
+  fileExists,
+  listDirectory,
+  readFile,
+  writeFile,
+} from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import { renderStoryMapHtml } from "./story-map-renderer"
+import type { StoryMap } from "./story-map-types"
+
+/** 历史导图存放目录名(位于 bookPath 下) */
+export const STORY_MAPS_DIR = "story-maps"
+
+export interface StoryMapHistoryEntry {
+  /** 历史目录名,如 story-map-1756100000000 */
+  dirName: string
+  /** 该版本的导图数据 */
+  map: StoryMap
+  /** 该版本的 story-map.json 绝对路径 */
+  jsonPath: string
+  /** 该版本的 story-map.html 绝对路径 */
+  htmlPath: string
+}
+
+function historyRoot(bookPath: string): string {
+  return normalizePath(joinPath(bookPath, STORY_MAPS_DIR))
+}
+
+function historyDirName(createdAt: number, index: number): string {
+  return index === 0 ? `story-map-${createdAt}` : `story-map-${createdAt}-${index}`
+}
+
+/**
+ * 写入故事导图(历史版 + 最新引用)。
+ * 历史版目录按 createdAt 命名,若同毫秒重复生成则追加序号,确保互不覆盖。
+ */
+export async function writeStoryMapFiles(bookPath: string, map: StoryMap): Promise<void> {
+  const json = JSON.stringify(map, null, 2)
+  const html = renderStoryMapHtml(map)
+
+  const root = historyRoot(bookPath)
+  await createDirectory(root)
+  let dirName = historyDirName(map.createdAt, 0)
+  let index = 1
+  while (await fileExists(joinPath(root, dirName))) {
+    dirName = historyDirName(map.createdAt, index++)
+  }
+  const dirPath = normalizePath(joinPath(root, dirName))
+  await createDirectory(dirPath)
+  await writeFile(normalizePath(joinPath(dirPath, "story-map.json")), json)
+  await writeFile(normalizePath(joinPath(dirPath, "story-map.html")), html)
+
+  // 最新引用:根目录固定两份,验证引擎与「打开故事导图」继续使用
+  await writeFile(normalizePath(joinPath(bookPath, "story-map.json")), json)
+  await writeFile(normalizePath(joinPath(bookPath, "story-map.html")), html)
+}
+
+/** 列出全部历史导图(按生成时间升序,旧的在前;调用方可自行倒序)。损坏目录自动跳过。 */
+export async function listStoryMapHistory(bookPath: string): Promise<StoryMapHistoryEntry[]> {
+  const root = historyRoot(bookPath)
+  let entries
+  try {
+    entries = await listDirectory(root)
+  } catch {
+    return []
+  }
+
+  const result: StoryMapHistoryEntry[] = []
+  for (const entry of entries) {
+    if (!entry.is_dir) continue
+    const jsonPath = normalizePath(joinPath(entry.path, "story-map.json"))
+    try {
+      const raw = await readFile(jsonPath)
+      const map = JSON.parse(raw) as StoryMap
+      if (!map || typeof map !== "object" || !Array.isArray(map.chapters)) continue
+      result.push({
+        dirName: entry.name,
+        map,
+        jsonPath,
+        htmlPath: normalizePath(joinPath(entry.path, "story-map.html")),
+      })
+    } catch {
+      // 跳过损坏 / 不完整的历史目录
+    }
+  }
+
+  result.sort((left, right) => left.map.createdAt - right.map.createdAt)
+  return result
+}

+ 118 - 0
src/lib/novel/book-analysis/story-map-prompts.ts

@@ -0,0 +1,118 @@
+/**
+ * 拆书故事导图 - 提取/汇总 prompt + 解析(chaishugushidaotu 分支)
+ *
+ * 目标:让 LLM 逐章输出「主线事件链 + 分支」结构化 JSON,
+ * 分支必须标明由主线哪一环节触发;解析容错对标 style-prompts。
+ */
+import type { BookStoryFrameworkChapter } from "./story-framework-extraction"
+import type { StoryMap } from "./story-map-types"
+import { normalizeStoryMap } from "./story-map-types"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
+
+const SAMPLE_TEXT_LIMIT = 24000
+
+function truncate(value: string, limit: number): string {
+  if (!value) return ""
+  if (value.length <= limit) return value
+  return value.slice(0, limit) + "\n\n…(样本过长已截断)…"
+}
+
+/** 构建逐章故事导图提取 prompt(输出 JSON) */
+export function buildStoryMapPrompt(input: {
+  bookTitle: string
+  chapters: BookStoryFrameworkChapter[]
+  temporaryCharacters?: Array<{ name: string; aliases: string[]; category: string }>
+}): string {
+  const chapterBlocks = input.chapters.map((chapter) => {
+    const content = chapter.content.trim().length <= CHAPTER_BODY_EXCERPT_MAX_CHARS
+      ? chapter.content.trim()
+      : `${chapter.content.trim().slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}\n[本章内容过长已截断]`
+    return `【章节ID:${chapter.id} · 第 ${chapter.order} 章 · ${chapter.title}】\n${content}`
+  }).join("\n\n———\n\n")
+
+  const characterLines = input.temporaryCharacters?.length
+    ? [
+        "临时人物线索(只用于理解人物关系、目标和冲突):",
+        ...input.temporaryCharacters.map((c) => `- ${c.name}${c.aliases.length ? `(别名:${c.aliases.join("、")})` : ""} · ${c.category}`),
+        "禁止输出角色 Skill、角色档案或人物仿写指令。",
+        "",
+      ]
+    : []
+
+  return [
+    `拆书作品:《${input.bookTitle}》`,
+    "",
+    ...characterLines,
+    "你是小说故事结构拆解专家。请把下面提供的章节拆解成「主线 + 分支」的故事导图(思维导图式结构),供作者写新书时复用节奏与结构。",
+    "",
+    "核心约定:",
+    "- 主线 = 支撑全书推进的核心事件链(每章按推进顺序列出主线事件)。",
+    "- 分支 = 由主线某环节生发的新故事:支线(sub)、任务(task)、伏笔(foreshadow)、世界观展开(world)、情感线(emotion)。",
+    "- 每个分支必须写清 triggeredBy(由本章哪个主线事件触发,填该主线事件的 label 原文)。",
+    "- 事件只描述结构与功能(怎么推进、怎么施压、怎么释放),不复述原作专有设定、不复用原作人物名设定名(角色名仅用于标注涉及谁)。",
+    "- 全部使用中文。不要编造:某章确实没有分支时 branches 返回空数组。",
+    "",
+    "只输出一个 JSON 对象(不要 markdown 围栏、不要解释),结构如下:",
+    "{",
+    '  "mainLineLabel": "主线名(如:主角成长+复仇主线)",',
+    '  "mainSummary": "主线整体一句话概括",',
+    '  "chapters": [',
+    "    {",
+    '      "id": "必须使用上文【章节ID:】中的 ID",',
+    '      "order": 章节序号,',
+    '      "title": "章节标题",',
+    '      "summary": "本章发生了什么(一句话概括)",',
+    '      "mainEvents": [',
+    "        {",
+    '          "label": "主线事件一句话(导图节点主文本)",',
+    '          "beats": ["具体情节点1", "情节点2"],',
+    '          "characters": ["涉及角色名"],',
+    '          "spinoff": "由此延伸的悬念(可省略)"',
+    "        }",
+    "      ],",
+    '      "branches": [',
+    "        {",
+    '          "id": "branch-ch0001-0",',
+    '          "kind": "sub|task|foreshadow|world|emotion",',
+    '          "label": "分支名(如:炼丹师大赛支线)",',
+    '          "triggeredBy": "由本章哪个主线事件触发(填其 label)",',
+    '          "events": [ { "label": "分支事件一句话", "beats": ["..."], "characters": ["..."] } ]',
+    "        }",
+    "      ]",
+    "    }",
+    "  ]",
+    "}",
+    "",
+    "原文样本(已截断):",
+    truncate(chapterBlocks, SAMPLE_TEXT_LIMIT),
+  ].join("\n")
+}
+
+/** 多区块汇总 prompt:合并各区块 StoryMap JSON,保留衔接 */
+export function buildStoryMapAggregatePrompt(maps: unknown[]): string {
+  return [
+    "你是小说故事导图汇总专家。以下是同一作品不同章节区块已经完成的故事导图 JSON。",
+    "请合并为一个导图:按章节 order 排序、主线事件链保持推进顺序、同名/同功能分支去重合并,",
+    "分支的 triggeredBy 保持指向合并后的主线事件 label。不得新增未分析章节,不得编造事件。",
+    "输出与输入相同结构的单个 JSON(含 mainLineLabel / mainSummary / chapters),不要围栏与解释。",
+    "",
+    ...maps.map((map, index) => `区块 ${index + 1}:\n${JSON.stringify(map)}`),
+  ].join("\n\n")
+}
+
+/** 解析 LLM 输出为 StoryMap:剥围栏 + 取最外层 {};失败返回 null(由调用方降级) */
+export function parseStoryMapResult(
+  raw: string,
+  input: { bookId: string; bookTitle: string; chapterIds: string[]; createdAt?: number },
+): StoryMap | null {
+  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
+  if (!objectText) return null
+  try {
+    const parsed: unknown = JSON.parse(objectText)
+    const map = normalizeStoryMap({ ...input, raw: parsed })
+    return map.chapters.length > 0 ? map : null
+  } catch {
+    return null
+  }
+}

+ 111 - 0
src/lib/novel/book-analysis/story-map-renderer.spec.ts

@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest"
+import { renderStoryMapHtml } from "./story-map-renderer"
+import { normalizeStoryMap, isStoryMapUsable, type StoryMap } from "./story-map-types"
+import { parseStoryMapResult } from "./story-map-prompts"
+
+function makeMap(): StoryMap {
+  return normalizeStoryMap({
+    bookId: "book-1",
+    bookTitle: "测试书",
+    createdAt: 1700000000000,
+    raw: {
+      mainLineLabel: "主角成长主线",
+      mainSummary: "主角一路升级打怪",
+      chapters: [
+        {
+          id: "ch-0001",
+          order: 1,
+          title: "第一章 起点",
+          summary: "主角获得金手指",
+          mainEvents: [
+            { label: "获得传承 <b>意外</b>", beats: ["被追杀", "掉入洞穴"], characters: ["主角"], spinoff: "传承来历成谜" },
+          ],
+          branches: [
+            { id: "b1", kind: "task", label: "寻宝任务", triggeredBy: "获得传承", events: [{ label: "接取任务", beats: [], characters: [] }] },
+          ],
+        },
+        {
+          id: "ch-0002",
+          order: 2,
+          title: "第二章 风波",
+          summary: "主角初次显威",
+          mainEvents: [{ label: "击败挑衅者", beats: ["被打压", "反杀"], characters: ["主角", "反派"] }],
+          branches: [],
+        },
+      ],
+    },
+  })
+}
+
+describe("normalizeStoryMap", () => {
+  it("正常结构完整保留并按 order 排序", () => {
+    const map = makeMap()
+    expect(map.chapters).toHaveLength(2)
+    expect(map.chapters[0].mainEvents[0].label).toBe("获得传承 <b>意外</b>")
+    expect(map.chapters[0].branches[0].kind).toBe("task")
+    expect(map.chapters[0].branches[0].triggeredBy).toBe("获得传承")
+  })
+
+  it("脏数据兜底:无效章节被剔除、字段补默认值", () => {
+    const map = normalizeStoryMap({
+      bookId: "b",
+      bookTitle: "t",
+      raw: {
+        chapters: [
+          { summary: "", mainEvents: [] }, // 无内容 → 剔除
+          { id: "ch-0001", summary: "有摘要", mainEvents: [{ label: "事件" }] },
+          "garbage",
+        ],
+      },
+    })
+    expect(map.chapters).toHaveLength(1)
+    expect(map.mainLineLabel).toBe("主线")
+    expect(map.chapters[0].branches).toEqual([])
+  })
+
+  it("isStoryMapUsable 判定", () => {
+    expect(isStoryMapUsable(makeMap())).toBe(true)
+    expect(isStoryMapUsable(normalizeStoryMap({ bookId: "b", bookTitle: "t", raw: {} }))).toBe(false)
+    expect(isStoryMapUsable(null)).toBe(false)
+  })
+})
+
+describe("parseStoryMapResult", () => {
+  it("解析带围栏 JSON 输出", () => {
+    const raw = "```json\n{\"mainLineLabel\":\"主线A\",\"chapters\":[{\"id\":\"ch-0001\",\"order\":1,\"summary\":\"s\",\"mainEvents\":[{\"label\":\"e1\"}]}]}\n```"
+    const map = parseStoryMapResult(raw, { bookId: "b", bookTitle: "t", chapterIds: ["ch-0001"] })
+    expect(map).not.toBeNull()
+    expect(map!.mainLineLabel).toBe("主线A")
+    expect(map!.chapters[0].mainEvents[0].label).toBe("e1")
+  })
+
+  it("非 JSON 输出返回 null", () => {
+    expect(parseStoryMapResult("抱歉我无法输出", { bookId: "b", bookTitle: "t", chapterIds: [] })).toBeNull()
+  })
+})
+
+describe("renderStoryMapHtml", () => {
+  it("渲染书名、主线、章节事件与分支标签", () => {
+    const html = renderStoryMapHtml(makeMap())
+    expect(html).toContain("《测试书》故事导图")
+    expect(html).toContain("主角成长主线")
+    expect(html).toContain("第 1 章")
+    expect(html).toContain("击败挑衅者")
+    expect(html).toContain("寻宝任务")
+    expect(html).toContain("任务")
+    expect(html).toContain("触发环节:获得传承")
+    expect(html).toContain("<details")
+  })
+
+  it("HTML 转义原文中的标签,防注入", () => {
+    const html = renderStoryMapHtml(makeMap())
+    expect(html).not.toContain("<b>意外</b>")
+    expect(html).toContain("&lt;b&gt;意外&lt;/b&gt;")
+  })
+
+  it("空导图渲染占位而不抛错", () => {
+    const empty = normalizeStoryMap({ bookId: "b", bookTitle: "空书", raw: { chapters: [] } })
+    const html = renderStoryMapHtml(empty)
+    expect(html).toContain("未提取到章节内容")
+  })
+})

+ 157 - 0
src/lib/novel/book-analysis/story-map-renderer.ts

@@ -0,0 +1,157 @@
+/**
+ * 拆书故事导图 HTML 渲染器(chaishugushidaotu 分支)
+ *
+ * 把 StoryMap 渲染为自包含 HTML(内联 CSS,无外部依赖):
+ * - 顶部:书名 + 主线名 + 主线一句话
+ * - 主体:按章纵向的主线时间轴;每章可展开主线事件编号列表
+ * - 每章下方列分支(带类型标签),标明「由哪一主线环节触发」
+ * - 使用 <details> 折叠,长内容不超屏、可滚动
+ */
+
+import type { StoryMap, StoryBranch, StoryEvent } from "./story-map-types"
+import { STORY_BRANCH_KIND_LABELS } from "./story-map-types"
+
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+}
+
+const BRANCH_KIND_COLORS: Record<string, string> = {
+  sub: "#4f8cff",
+  task: "#37c9a0",
+  foreshadow: "#ffb454",
+  world: "#b07cff",
+  emotion: "#ff7ba9",
+}
+
+function renderEvent(event: StoryEvent, prefix: string): string {
+  const beats = event.beats.length > 0
+    ? `<ul class="beats">${event.beats.map((beat) => `<li>${escapeHtml(beat)}</li>`).join("")}</ul>`
+    : ""
+  const characters = event.characters.length > 0
+    ? `<div class="chars">涉及:${event.characters.map(escapeHtml).join("、")}</div>`
+    : ""
+  const spinoff = event.spinoff
+    ? `<div class="spinoff">延伸悬念:${escapeHtml(event.spinoff)}</div>`
+    : ""
+  return `<div class="event"><div class="event-label">${prefix}${escapeHtml(event.label)}</div>${beats}${characters}${spinoff}</div>`
+}
+
+function renderBranch(branch: StoryBranch): string {
+  const color = BRANCH_KIND_COLORS[branch.kind] ?? "#4f8cff"
+  const events = branch.events.length > 0
+    ? branch.events.map((event, index) => renderEvent(event, `${index + 1}. `)).join("")
+    : ""
+  return `<div class="branch" style="--branch-color:${color}">
+  <div class="branch-head">
+    <span class="branch-kind" style="background:${color}1a;color:${color}">${STORY_BRANCH_KIND_LABELS[branch.kind]}</span>
+    <span class="branch-label">${escapeHtml(branch.label)}</span>
+  </div>
+  ${branch.triggeredBy ? `<div class="branch-trigger">触发环节:${escapeHtml(branch.triggeredBy)}</div>` : ""}
+  ${events}
+</div>`
+}
+
+export function renderStoryMapHtml(map: StoryMap): string {
+  const chapters = map.chapters.map((chapter) => {
+    const mainEvents = chapter.mainEvents.length > 0
+      ? chapter.mainEvents.map((event, index) => renderEvent(event, `${index + 1}. `)).join("")
+      : `<div class="empty">(本章未提取到主线事件)</div>`
+    const branches = chapter.branches.length > 0
+      ? chapter.branches.map(renderBranch).join("")
+      : ""
+    return `<details class="chapter" open>
+  <summary>
+    <span class="chapter-order">第 ${chapter.order} 章</span>
+    <span class="chapter-title">${escapeHtml(chapter.title)}</span>
+    <span class="chapter-count">${chapter.mainEvents.length} 主线 · ${chapter.branches.length} 分支</span>
+  </summary>
+  <div class="chapter-body">
+    <p class="chapter-summary">${escapeHtml(chapter.summary)}</p>
+    <div class="section-title">主线事件</div>
+    <div class="main-events">${mainEvents}</div>
+    ${chapter.branches.length > 0 ? `<div class="section-title">分支(由主线环节生发)</div><div class="branches">${branches}</div>` : ""}
+  </div>
+</details>`
+  }).join("\n")
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>《${escapeHtml(map.bookTitle)}》故事导图</title>
+<style>
+  :root { color-scheme: light; }
+  * { box-sizing: border-box; }
+  body {
+    margin: 0; padding: 24px; background: #f6f7fa; color: #23272f;
+    font-family: "Microsoft YaHei", "PingFang SC", -apple-system, sans-serif; line-height: 1.7;
+  }
+  .wrap { max-width: 860px; margin: 0 auto; }
+  header.map-header { border-left: 4px solid #4f8cff; padding-left: 14px; margin-bottom: 8px; }
+  h1 { font-size: 20px; margin: 0; }
+  .mainline { margin: 6px 0 0; color: #4f8cff; font-weight: 600; font-size: 14px; }
+  .main-summary { margin: 2px 0 0; color: #6b7280; font-size: 13px; }
+  .meta { color: #9aa3b2; font-size: 12px; margin: 8px 0 20px; }
+  .chapter {
+    background: #fff; border: 1px solid #e5e8ef; border-radius: 10px;
+    margin-bottom: 12px; overflow: hidden;
+  }
+  .chapter summary {
+    cursor: pointer; list-style: none; padding: 12px 16px;
+    display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
+  }
+  .chapter summary::-webkit-details-marker { display: none; }
+  .chapter summary::before { content: "▸"; color: #9aa3b2; transition: transform .15s; }
+  .chapter[open] summary::before { transform: rotate(90deg); }
+  .chapter-order { font-weight: 700; color: #4f8cff; white-space: nowrap; }
+  .chapter-title { font-weight: 600; }
+  .chapter-count { margin-left: auto; color: #9aa3b2; font-size: 12px; white-space: nowrap; }
+  .chapter-body { padding: 4px 16px 16px; border-top: 1px dashed #e5e8ef; }
+  .chapter-summary { color: #4b5563; font-size: 13px; margin: 10px 0; }
+  .section-title {
+    font-size: 12px; font-weight: 700; color: #6b7280; letter-spacing: 1px;
+    margin: 14px 0 8px;
+  }
+  .event { border-left: 2px solid #cdd6e4; padding-left: 12px; margin: 10px 0; }
+  .event-label { font-weight: 600; font-size: 14px; }
+  .beats { margin: 4px 0 0; padding-left: 18px; color: #4b5563; font-size: 13px; }
+  .chars, .spinoff { color: #6b7280; font-size: 12px; margin-top: 2px; }
+  .spinoff { color: #b45309; }
+  .empty { color: #9aa3b2; font-size: 13px; }
+  .branches { display: grid; gap: 10px; }
+  .branch {
+    border: 1px solid #e5e8ef; border-left: 3px solid var(--branch-color, #4f8cff);
+    border-radius: 8px; padding: 10px 12px; background: #fbfcfe;
+  }
+  .branch-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+  .branch-kind {
+    font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 999px; white-space: nowrap;
+  }
+  .branch-label { font-weight: 600; font-size: 13px; }
+  .branch-trigger { color: #6b7280; font-size: 12px; margin-top: 2px; }
+  footer { color: #9aa3b2; font-size: 12px; text-align: center; margin-top: 24px; }
+  @media (max-width: 640px) { body { padding: 12px; } .chapter-count { display: none; } }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header class="map-header">
+    <h1>《${escapeHtml(map.bookTitle)}》故事导图</h1>
+    <div class="mainline">主线:${escapeHtml(map.mainLineLabel)}</div>
+    ${map.mainSummary ? `<p class="main-summary">${escapeHtml(map.mainSummary)}</p>` : ""}
+  </header>
+  <div class="meta">共 ${map.chapters.length} 章 · 主线事件 ${map.chapters.reduce((sum, c) => sum + c.mainEvents.length, 0)} 条 · 分支 ${map.chapters.reduce((sum, c) => sum + c.branches.length, 0)} 条 · 生成于 ${new Date(map.createdAt).toLocaleString("zh-CN")}</div>
+  <main>
+${chapters || '<div class="empty">(未提取到章节内容)</div>'}
+  </main>
+  <footer>本导图由 QMaiWrite 拆书库生成 · 仅供结构参考,禁止复用原作人物、设定与表达</footer>
+</div>
+</body>
+</html>`
+}

+ 152 - 0
src/lib/novel/book-analysis/story-map-types.ts

@@ -0,0 +1,152 @@
+/**
+ * 拆书故事导图(StoryMap)类型定义(chaishugushidaotu 分支)
+ *
+ * 心智模型:主线为核心的时间轴,逐章列出主线事件;
+ * 每章可挂若干「分支」(支线故事、任务、伏笔、世界观展开、情感线),
+ * 分支需标明由主线哪一环节触发,形成一目了然的思维导图结构。
+ */
+
+/** 分支类型 */
+export type StoryBranchKind = "sub" | "task" | "foreshadow" | "world" | "emotion"
+
+export const STORY_BRANCH_KIND_LABELS: Record<StoryBranchKind, string> = {
+  sub: "支线",
+  task: "任务",
+  foreshadow: "伏笔",
+  world: "世界观",
+  emotion: "情感",
+}
+
+/** 主线事件 / 分支事件共用结构 */
+export interface StoryEvent {
+  /** 事件一句话(思维导图节点主文本) */
+  label: string
+  /** 具体情节点(2~4 条,每条一句话) */
+  beats: string[]
+  /** 涉及角色名 */
+  characters: string[]
+  /** 由此延伸的伏笔/悬念(可选) */
+  spinoff?: string
+}
+
+/** 某一章生成的分支 */
+export interface StoryBranch {
+  id: string
+  kind: StoryBranchKind
+  /** 分支名(如「炼丹师大赛支线」) */
+  label: string
+  /** 由主线哪一环节触发(对应主线事件 label) */
+  triggeredBy: string
+  events: StoryEvent[]
+}
+
+/** 单章导图节点 */
+export interface StoryMapChapter {
+  id: string
+  order: number
+  title: string
+  /** 本章发生了什么(一句话概括 + 要点) */
+  summary: string
+  /** 主线事件(按推进顺序) */
+  mainEvents: StoryEvent[]
+  /** 本章生发的分支 */
+  branches: StoryBranch[]
+}
+
+/** 整本作品的故事导图 */
+export interface StoryMap {
+  schemaVersion: 1
+  bookId: string
+  bookTitle: string
+  /** 主线名(如「主角成长主线」) */
+  mainLineLabel: string
+  /** 主线整体一句话 */
+  mainSummary: string
+  chapters: StoryMapChapter[]
+  createdAt: number
+}
+
+/** 规范化:字段兜底 + 清理脏数据,单章失败不整体失败 */
+export function normalizeStoryMap(input: {
+  bookId: string
+  bookTitle: string
+  raw: unknown
+  createdAt?: number
+}): StoryMap {
+  const createdAt = input.createdAt ?? Date.now()
+  const raw = (input.raw && typeof input.raw === "object" ? input.raw : {}) as Record<string, unknown>
+
+  const asString = (v: unknown): string => (typeof v === "string" ? v.trim() : "")
+  const asStringArray = (v: unknown): string[] =>
+    Array.isArray(v) ? v.map(asString).filter(Boolean) : []
+
+  const normalizeEvent = (v: unknown): StoryEvent | null => {
+    if (!v || typeof v !== "object") return null
+    const e = v as Record<string, unknown>
+    const label = asString(e.label)
+    if (!label) return null
+    return {
+      label,
+      beats: asStringArray(e.beats),
+      characters: asStringArray(e.characters),
+      spinoff: asString(e.spinoff) || undefined,
+    }
+  }
+
+  const normalizeBranch = (v: unknown, chapterId: string, index: number): StoryBranch | null => {
+    if (!v || typeof v !== "object") return null
+    const b = v as Record<string, unknown>
+    const label = asString(b.label)
+    if (!label) return null
+    const kind = (["sub", "task", "foreshadow", "world", "emotion"] as const)
+      .find((k) => k === asString(b.kind))
+      ?? "sub"
+    return {
+      id: asString(b.id) || `branch-${chapterId}-${index}`,
+      kind,
+      label,
+      triggeredBy: asString(b.triggeredBy),
+      events: (Array.isArray(b.events) ? b.events : [])
+        .map(normalizeEvent)
+        .filter((e): e is StoryEvent => e !== null),
+    }
+  }
+
+  const rawChapters = Array.isArray(raw.chapters) ? raw.chapters : []
+  const chapters: StoryMapChapter[] = rawChapters.flatMap((v, index): StoryMapChapter[] => {
+    if (!v || typeof v !== "object") return []
+    const c = v as Record<string, unknown>
+    const summary = asString(c.summary)
+    const mainEvents = (Array.isArray(c.mainEvents) ? c.mainEvents : [])
+      .map(normalizeEvent)
+      .filter((e): e is StoryEvent => e !== null)
+    if (!summary && mainEvents.length === 0) return []
+    const id = asString(c.id) || `ch-${index + 1}`
+    return [{
+      id,
+      order: Number(c.order) || index + 1,
+      title: asString(c.title) || `第 ${index + 1} 章`,
+      summary,
+      mainEvents,
+      branches: (Array.isArray(c.branches) ? c.branches : [])
+        .map((b, bIndex) => normalizeBranch(b, id, bIndex))
+        .filter((b): b is StoryBranch => b !== null),
+    }]
+  }).sort((a, b) => a.order - b.order)
+
+  return {
+    schemaVersion: 1,
+    bookId: input.bookId,
+    bookTitle: input.bookTitle,
+    mainLineLabel: asString(raw.mainLineLabel) || "主线",
+    mainSummary: asString(raw.mainSummary),
+    chapters,
+    createdAt,
+  }
+}
+
+/** 导图是否有可用内容(至少一章含主线事件) */
+export function isStoryMapUsable(map: StoryMap | null | undefined): boolean {
+  if (!map) return false
+  return map.chapters.some((chapter) => chapter.mainEvents.length > 0 || chapter.branches.length > 0)
+}

+ 6 - 1
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -15,6 +15,7 @@ import {
 } from "./style-prompts"
 import { styleProfileToMarkdown } from "./style-extraction-engine"
 import type { BookStyleProfile } from "./types"
+import { scheduleVerification } from "./verification-engine"
 import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 interface StyleAnalysisChunkResult {
@@ -183,7 +184,7 @@ export function createStyleAnalysisAdapter(
       onProgress?.({ stageLabel: "文风汇总完成", percentage: 95 })
       return profile
     },
-    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
+    async publish({ task, bookPath, projectPath, llmConfig, result, evidence, onProgress }) {
       onProgress?.({ stageLabel: "正在发布文风结果…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布文风分析")
@@ -220,6 +221,10 @@ export function createStyleAnalysisAdapter(
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
       onProgress?.({ stageLabel: "文风结果已发布", percentage: 100 })
+
+      // 后台审计:三重验证 + 压力测试(best-effort,失败不影响任务)
+      void scheduleVerification(bookPath, "style", llmConfig)
+        .catch((error) => console.warn("[style-verify] 校验失败:", error))
       return resultPath
     },
   }

+ 191 - 0
src/lib/novel/book-analysis/verification-engine.spec.ts

@@ -0,0 +1,191 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const mockFs = vi.hoisted(() => ({
+  files: new Map<string, string>(),
+  directories: new Map<string, Array<{ name: string; path: string; is_dir: boolean }>>(),
+  writes: new Map<string, string>(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(async (path: string) => {
+    const key = path.replace(/\\/g, "/")
+    if (!mockFs.files.has(key)) throw new Error(`missing ${key}`)
+    return mockFs.files.get(key)!
+  }),
+  listDirectory: vi.fn(async (path: string) => mockFs.directories.get(path.replace(/\\/g, "/")) ?? []),
+  writeFile: vi.fn(async (path: string, content: string) => {
+    mockFs.writes.set(path.replace(/\\/g, "/"), content)
+  }),
+  createDirectory: vi.fn(async () => undefined),
+  fileExists: vi.fn(async (path: string) => mockFs.files.has(path.replace(/\\/g, "/"))),
+}))
+
+import { createVerificationEngine, MAX_VERIFY_UNITS, scheduleVerification } from "./verification-engine"
+import { parseVerifyResult, pressureKindsFor } from "./verification-prompts"
+
+const VERIFY_JSON = JSON.stringify({
+  triple: [
+    { key: "crossDomain", status: "pass", detail: "两处独立佐证", evidenceCount: 2 },
+    { key: "predictive", status: "warn", detail: "部分可预测", evidenceCount: 0 },
+    { key: "unique", status: "pass", detail: "特征具体", evidenceCount: 1 },
+  ],
+  pressure: [
+    { kind: "apply", prompt: "迁移到都市题材", verdict: "pass", reason: "自洽" },
+    { kind: "boundary", prompt: "纯日常流水", verdict: "fail", reason: "不适用" },
+  ],
+})
+
+function makeEngine(callModel: (prompt: string) => string) {
+  return createVerificationEngine({
+    callModel: vi.fn(async (messages: Array<{ role: string; content: string }>) => {
+      const user = messages.find((message) => message.role === "user")
+      return callModel(user?.content ?? "")
+    }),
+    now: () => 1700000000000,
+  })
+}
+
+function makeCharacter(id: string, name: string, importance: number) {
+  return {
+    id,
+    name,
+    aliases: [],
+    importance,
+    category: "protagonist" as const,
+    firstAppearance: 1,
+    lastAppearance: 2,
+    appearanceCount: 3,
+    description: "",
+    personality: "沉稳",
+    speechStyle: "简洁",
+    relationships: [],
+    keyEvents: [],
+    corpus: "原文片段:韩立盘膝而坐。原文片段2:韩立睁开眼。",
+  }
+}
+
+beforeEach(() => {
+  mockFs.files.clear()
+  mockFs.directories.clear()
+  mockFs.writes.clear()
+})
+
+describe("parseVerifyResult", () => {
+  it("解析围栏 JSON 并规范化字段", () => {
+    const result = parseVerifyResult("```json\n" + VERIFY_JSON + "\n```")
+    expect(result.triple).toHaveLength(3)
+    expect(result.triple[1].status).toBe("warn")
+    expect(result.pressure).toHaveLength(2)
+  })
+
+  it("脏输入返回空结构不抛错", () => {
+    expect(parseVerifyResult("模型罢工了").triple).toEqual([])
+  })
+})
+
+describe("pressureKindsFor", () => {
+  it("角色 2 条、文风/故事 3 条", () => {
+    expect(pressureKindsFor("characters")).toEqual(["apply", "boundary"])
+    expect(pressureKindsFor("style")).toHaveLength(3)
+    expect(pressureKindsFor("story")).toHaveLength(3)
+  })
+})
+
+describe("createVerificationEngine.runVerification", () => {
+  it("characters:逐角色验证并落盘 json+md,超上限截断", async () => {
+    mockFs.directories.set("book/characters", Array.from({ length: MAX_VERIFY_UNITS + 3 }, (_, index) => ({
+      name: `c${index}.json`,
+      path: `book/characters/c${index}.json`,
+      is_dir: false,
+    })))
+    for (let index = 0; index < MAX_VERIFY_UNITS + 3; index++) {
+      mockFs.files.set(`book/characters/c${index}.json`, JSON.stringify(makeCharacter(`c${index}`, `角色${index}`, index)))
+    }
+    const engine = makeEngine(() => VERIFY_JSON)
+    const report = await engine.runVerification("characters", "book", {} as never)
+
+    expect(report.units).toHaveLength(MAX_VERIFY_UNITS)
+    expect(report.skippedUnitCount).toBe(3)
+    expect(report.costBounded).toBe(true)
+    expect(report.units[0].passed).toBe(true) // warn 不算 fail
+    // 截断后取 importance 最高的前 N 个
+    expect(report.units[0].name).toBe("角色22")
+    expect(mockFs.writes.has("book/verification/characters-verification.json")).toBe(true)
+    const md = mockFs.writes.get("book/verification/characters-verification.md")!
+    expect(md).toContain("角色提取")
+    expect(md).toContain("跨域佐证")
+    expect(md).toContain("压力测试")
+  })
+
+  it("style:读取 style-profile.json 生成单单元报告", async () => {
+    mockFs.files.set("book/style-profile.json", JSON.stringify({
+      schemaVersion: 1,
+      generatedAt: 1,
+      sampledChapterIds: ["ch-1"],
+      narrativeDensity: "中高",
+      descriptionWeight: "", emotionRendering: "", sentenceStyle: "", rhetoricDensity: "",
+      transitionStyle: "", narrativeVoice: "", dialogueStyle: "", thematicHabits: "",
+      constitution: "1. 短句优先",
+      samples: ["样本一:短句推进。", "样本二:动作带情绪。"],
+    }))
+    const engine = makeEngine(() => VERIFY_JSON)
+    const report = await engine.runVerification("style", "book", {} as never)
+    expect(report.units).toHaveLength(1)
+    expect(report.summary.total).toBe(1)
+    expect(mockFs.writes.has("book/verification/style-verification.json")).toBe(true)
+  })
+
+  it("story:读取 story-map.json 生成报告", async () => {
+    mockFs.files.set("book/story-map.json", JSON.stringify({
+      schemaVersion: 1,
+      bookId: "b",
+      bookTitle: "测试书",
+      mainLineLabel: "成长主线",
+      mainSummary: "一路升级",
+      createdAt: 1,
+      chapters: [{
+        id: "ch-0001", order: 1, title: "一", summary: "开篇",
+        mainEvents: [{ label: "获得传承", beats: ["a"], characters: [] }],
+        branches: [{ id: "b1", kind: "task", label: "任务", triggeredBy: "获得传承", events: [] }],
+      }],
+    }))
+    const engine = makeEngine(() => VERIFY_JSON)
+    const report = await engine.runVerification("story", "book", {} as never)
+    expect(report.units).toHaveLength(1)
+    expect(report.units[0].name).toContain("成长主线")
+    expect(mockFs.writes.has("book/verification/story-verification.md")).toBe(true)
+  })
+
+  it("LLM 失败时补齐三项 fail,报告结构完整", async () => {
+    mockFs.files.set("book/style-profile.json", JSON.stringify({
+      constitution: "x", samples: [], sampledChapterIds: [],
+    }))
+    const engine = makeEngine(() => "输出异常")
+    const report = await engine.runVerification("style", "book", {} as never)
+    expect(report.units[0].triple).toHaveLength(3)
+    expect(report.units[0].triple.every((item) => item.status === "fail")).toBe(true)
+    expect(report.units[0].passed).toBe(false)
+    expect(report.summary.fail).toBe(1)
+  })
+
+  it("style 源文件缺失时返回空报告不抛错", async () => {
+    // 不写入 style-profile.json
+    const engine = makeEngine(() => VERIFY_JSON)
+    const report = await engine.runVerification("style", "book", {} as never)
+    expect(report.units).toHaveLength(0)
+    expect(report.summary.total).toBe(0)
+  })
+
+  it("story 源文件缺失时返回空报告不抛错", async () => {
+    const engine = makeEngine(() => VERIFY_JSON)
+    const report = await engine.runVerification("story", "book", {} as never)
+    expect(report.units).toHaveLength(0)
+    expect(report.summary.total).toBe(0)
+  })
+})
+
+describe("scheduleVerification", () => {
+  it("数据缺失时吞异常不抛出(best-effort)", async () => {
+    await expect(scheduleVerification("nowhere", "style", {} as never)).resolves.toBeUndefined()
+  })
+})

+ 376 - 0
src/lib/novel/book-analysis/verification-engine.ts

@@ -0,0 +1,376 @@
+/**
+ * 拆书三重验证 + 压力测试引擎(chaishugushidaotu 分支)
+ *
+ * 纯后台审计:读取拆书落盘产物(characters/*.json、style-profile.json、story-map.json),
+ * 逐单元跑一次 LLM 三重验证(跨域佐证/预测力/独特性)+ 压力测试(apply/boundary/confusion),
+ * 结果落盘 verification/<skill>-verification.json + .md。
+ * best-effort:任何异常只 warn,不剔除产出、不影响拆书任务成功。
+ */
+import type { LlmConfig } from "@/stores/wiki-store"
+import { createDirectory, fileExists, listDirectory, readFile, writeFile } from "@/commands/fs"
+import { joinPath } from "@/lib/path-utils"
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type { ExtractedCharacter } from "./types"
+import type { BookStyleProfile } from "./types"
+import type { StoryMap } from "./story-map-types"
+import { STORY_BRANCH_KIND_LABELS } from "./story-map-types"
+import {
+  buildCharacterVerifyPrompt,
+  buildStoryVerifyPrompt,
+  buildStyleVerifyPrompt,
+  parseVerifyResult,
+  pressureKindsFor,
+} from "./verification-prompts"
+import type {
+  PressureTestItem,
+  TripleVerifyItem,
+  VerificationReport,
+  VerificationSkill,
+  VerificationUnit,
+} from "./verification-types"
+
+/** 角色 units 数量上限(超出只取 importance 最高的前 N 个) */
+export const MAX_VERIFY_UNITS = 20
+
+interface VerificationEngineDependencies {
+  readFile: typeof readFile
+  listDirectory: typeof listDirectory
+  writeFile: typeof writeFile
+  createDirectory: typeof createDirectory
+  fileExists: typeof fileExists
+  callModel: (messages: ChatMessage[], llmConfig: LlmConfig, signal: AbortSignal) => Promise<string>
+  now: () => number
+}
+
+async function defaultCallModel(
+  messages: ChatMessage[],
+  llmConfig: LlmConfig,
+  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: VerificationEngineDependencies = {
+  readFile,
+  listDirectory,
+  writeFile,
+  createDirectory,
+  fileExists,
+  callModel: defaultCallModel,
+  now: Date.now,
+}
+
+function bookIdFromPath(bookPath: string): string {
+  const parts = bookPath.replace(/\\/g, "/").split("/").filter(Boolean)
+  return parts[parts.length - 1] ?? bookPath
+}
+
+function characterProfileText(character: ExtractedCharacter): string {
+  return [
+    `姓名:${character.name}(别名:${character.aliases.join("、") || "无"})`,
+    `分类:${character.category} · 重要度:${character.importance} · 出场 ${character.appearanceCount} 次`,
+    `性格:${character.personality}`,
+    `动机:${character.motivation || "未提取"}`,
+    `说话方式:${character.speechStyle}`,
+    `行为模式:${character.behaviorPatterns || "未提取"}`,
+    `目标:${character.goals?.join(";") || "未提取"}`,
+    `恐惧:${character.fears?.join(";") || "未提取"}`,
+    `代表台词:${(character.representativeQuotes ?? []).map((quote) => quote.text).join(" / ").slice(0, 800) || "未提取"}`,
+  ].join("\n")
+}
+
+function styleProfileText(profile: BookStyleProfile): string {
+  return [
+    `叙事密度:${profile.narrativeDensity}`,
+    `描写比重:${profile.descriptionWeight}`,
+    `情绪呈现:${profile.emotionRendering}`,
+    `句式:${profile.sentenceStyle}`,
+    `修辞密度:${profile.rhetoricDensity}`,
+    `过渡方式:${profile.transitionStyle}`,
+    `叙事视角:${profile.narrativeVoice}`,
+    `对白风格:${profile.dialogueStyle}`,
+    `点题习惯:${profile.thematicHabits}`,
+    `幽默机制:${(profile.humorMechanisms ?? []).join(";") || "无"}`,
+    `热血机制:${(profile.highEnergyMechanisms ?? []).join(";") || "无"}`,
+    `词汇偏好:${(profile.vocabularyPreferences ?? []).join(";") || "无"}`,
+    `禁用写法:${(profile.avoidPatterns ?? []).join(";") || "无"}`,
+    `风格宪法:\n${profile.constitution}`,
+  ].join("\n")
+}
+
+function storyMapDigest(map: StoryMap): string {
+  const lines: string[] = [
+    `主线:${map.mainLineLabel}`,
+    `主线概括:${map.mainSummary}`,
+  ]
+  for (const chapter of map.chapters) {
+    lines.push(`第 ${chapter.order} 章 ${chapter.title}:${chapter.summary}`)
+    chapter.mainEvents.forEach((event, index) => {
+      lines.push(`  主线${index + 1}:${event.label}(${event.beats.join(";")})${event.spinoff ? ` → 悬念:${event.spinoff}` : ""}`)
+    })
+    chapter.branches.forEach((branch) => {
+      lines.push(`  [${STORY_BRANCH_KIND_LABELS[branch.kind]}] ${branch.label} ← 触发于:${branch.triggeredBy || "未标注"}(${branch.events.map((event) => event.label).join(";")})`)
+    })
+  }
+  return lines.join("\n")
+}
+
+/** 单个单元跑一次 LLM:三重验证 + 压力测试 */
+async function verifyUnit(
+  unitId: string,
+  unitName: string,
+  prompt: string,
+  skill: VerificationSkill,
+  llmConfig: LlmConfig,
+  dependencies: VerificationEngineDependencies,
+): Promise<VerificationUnit> {
+  const allowedKinds = new Set<string>(pressureKindsFor(skill))
+  let triple: TripleVerifyItem[] = []
+  let pressure: PressureTestItem[] = []
+  try {
+    const raw = await dependencies.callModel(
+      [
+        { role: "system", content: "你是严格的拆书质量审计员,只输出要求的 JSON。" },
+        { role: "user", content: prompt },
+      ],
+      llmConfig,
+      new AbortController().signal,
+    )
+    const parsed = parseVerifyResult(raw)
+    triple = parsed.triple
+    pressure = parsed.pressure
+      .filter((item) => allowedKinds.has(item.kind))
+      .slice(0, allowedKinds.size)
+      .map((item, index) => ({
+        id: `${unitId}-p${index}`,
+        kind: item.kind as PressureTestItem["kind"],
+        prompt: item.prompt,
+        verdict: item.verdict as PressureTestItem["verdict"],
+        reason: item.reason,
+      }))
+  } catch (error) {
+    console.warn(`[verify] 单元校验调用失败(${unitName}):`, error)
+  }
+
+  // LLM 未返回有效判定时补齐三项 fail,保证报告结构完整
+  if (triple.length < 3) {
+    const returned = new Set(triple.map((item) => item.key))
+    const fallback: Array<TripleVerifyItem["key"]> = ["crossDomain", "predictive", "unique"]
+    for (const key of fallback) {
+      if (returned.has(key)) continue
+      triple.push({
+        key,
+        label: { crossDomain: "跨域佐证", predictive: "预测力", unique: "独特性" }[key],
+        status: "fail",
+        detail: "AI 未返回有效判定",
+        evidenceCount: 0,
+      })
+    }
+  }
+
+  return {
+    id: unitId,
+    name: unitName,
+    triple,
+    passed: !triple.some((item) => item.status === "fail"),
+    pressureTests: pressure,
+  }
+}
+
+function buildReport(
+  skill: VerificationSkill,
+  bookPath: string,
+  units: VerificationUnit[],
+  skippedUnitCount: number,
+  now: number,
+): VerificationReport {
+  const passed = units.filter((unit) => unit.passed && !unit.triple.some((item) => item.status === "warn")).length
+  const warn = units.filter((unit) => unit.passed && unit.triple.some((item) => item.status === "warn")).length
+  const fail = units.filter((unit) => !unit.passed).length
+  return {
+    schemaVersion: 1,
+    skill,
+    bookId: bookIdFromPath(bookPath),
+    bookPath,
+    verifiedAt: now,
+    costBounded: skippedUnitCount > 0,
+    skippedUnitCount,
+    units,
+    summary: { total: units.length, passed, warn, fail },
+  }
+}
+
+/** 数据源缺失(style-profile.json / story-map.json 不存在)时返回空报告,避免抛错。 */
+function reportForMissingSource(
+  skill: VerificationSkill,
+  bookPath: string,
+  now: number,
+): VerificationReport {
+  return {
+    schemaVersion: 1,
+    skill,
+    bookId: bookIdFromPath(bookPath),
+    bookPath,
+    verifiedAt: now,
+    costBounded: false,
+    skippedUnitCount: 0,
+    units: [],
+    summary: { total: 0, passed: 0, warn: 0, fail: 0 },
+  }
+}
+
+function reportToMarkdown(report: VerificationReport): string {
+  const skillLabel: Record<VerificationSkill, string> = {
+    characters: "角色提取",
+    style: "文风提取",
+    story: "故事导图",
+  }
+  const lines: string[] = [
+    `# 拆书${skillLabel[report.skill]}质量审计报告`,
+    "",
+    `> 校验时间:${new Date(report.verifiedAt).toLocaleString("zh-CN")} · 单元 ${report.summary.total} 个(通过 ${report.summary.passed} / 降级 ${report.summary.warn} / 未通过 ${report.summary.fail})${report.costBounded ? ` · 因数量上限跳过 ${report.skippedUnitCount} 个` : ""}`,
+    "",
+    "> 本报告为后台审计记录:未通过的单元不会被剔除,仅提示提取质量,可据此重新提取。",
+    "",
+  ]
+  for (const unit of report.units) {
+    lines.push(`## ${unit.name}${unit.passed ? "" : "(未通过)"}`)
+    lines.push("")
+    lines.push("| 验证项 | 结果 | 佐证数 | 说明 |")
+    lines.push("| --- | --- | --- | --- |")
+    for (const item of unit.triple) {
+      lines.push(`| ${item.label} | ${item.status === "pass" ? "通过" : item.status === "warn" ? "降级" : "未通过"} | ${item.evidenceCount} | ${item.detail.replace(/\|/g, "|")} |`)
+    }
+    if (unit.pressureTests.length > 0) {
+      lines.push("")
+      lines.push("**压力测试**")
+      for (const test of unit.pressureTests) {
+        const verdict = test.verdict === "pass" ? "通过" : test.verdict === "warn" ? "降级" : "未通过"
+        lines.push(`- [${verdict}] ${test.prompt} —— ${test.reason}`)
+      }
+    }
+    lines.push("")
+  }
+  return lines.join("\n")
+}
+
+export function createVerificationEngine(
+  overrides: Partial<VerificationEngineDependencies> = {},
+) {
+  const dependencies = { ...defaultDependencies, ...overrides }
+
+  async function loadCharacters(bookPath: string): Promise<ExtractedCharacter[]> {
+    const dir = joinPath(bookPath, "characters")
+    const files = await listDirectory(dir)
+    const characters: ExtractedCharacter[] = []
+    for (const file of files) {
+      if (file.is_dir || !file.name.endsWith(".json")) continue
+      try {
+        const parsed = JSON.parse(await readFile(file.path)) as ExtractedCharacter
+        if (parsed && typeof parsed.name === "string") characters.push(parsed)
+      } catch {
+        // 跳过无法解析的角色文件
+      }
+    }
+    return characters.sort((left, right) => (right.importance ?? 0) - (left.importance ?? 0))
+  }
+
+  async function runVerification(
+    skill: VerificationSkill,
+    bookPath: string,
+    llmConfig: LlmConfig,
+  ): Promise<VerificationReport> {
+    const now = dependencies.now()
+    const units: VerificationUnit[] = []
+    let skippedUnitCount = 0
+
+    if (skill === "characters") {
+      const characters = await loadCharacters(bookPath)
+      const selected = characters.slice(0, MAX_VERIFY_UNITS)
+      skippedUnitCount = characters.length - selected.length
+      for (const character of selected) {
+        units.push(await verifyUnit(
+          character.id,
+          character.name,
+          buildCharacterVerifyPrompt({
+            characterName: character.name,
+            profileText: characterProfileText(character),
+            corpus: character.corpus ?? "",
+          }),
+          skill,
+          llmConfig,
+          dependencies,
+        ))
+      }
+    } else if (skill === "style") {
+      if (!await dependencies.fileExists(joinPath(bookPath, "style-profile.json"))) {
+        return reportForMissingSource(skill, bookPath, now)
+      }
+      const profile = JSON.parse(await readFile(joinPath(bookPath, "style-profile.json"))) as BookStyleProfile
+      units.push(await verifyUnit(
+        "style",
+        "作品文风",
+        buildStyleVerifyPrompt({
+          bookTitle: bookIdFromPath(bookPath),
+          profileText: styleProfileText(profile),
+          samples: profile.samples ?? [],
+        }),
+        skill,
+        llmConfig,
+        dependencies,
+      ))
+    } else {
+      if (!await dependencies.fileExists(joinPath(bookPath, "story-map.json"))) {
+        return reportForMissingSource(skill, bookPath, now)
+      }
+      const map = JSON.parse(await readFile(joinPath(bookPath, "story-map.json"))) as StoryMap
+      units.push(await verifyUnit(
+        "story",
+        `${map.mainLineLabel || "主线"}导图`,
+        buildStoryVerifyPrompt({
+          bookTitle: map.bookTitle || bookIdFromPath(bookPath),
+          mapDigest: storyMapDigest(map),
+        }),
+        skill,
+        llmConfig,
+        dependencies,
+      ))
+    }
+
+    const report = buildReport(skill, bookPath, units, skippedUnitCount, now)
+    const verifyDir = joinPath(bookPath, "verification")
+    await dependencies.createDirectory(verifyDir)
+    await dependencies.writeFile(joinPath(verifyDir, `${skill}-verification.json`), JSON.stringify(report, null, 2))
+    await dependencies.writeFile(joinPath(verifyDir, `${skill}-verification.md`), reportToMarkdown(report))
+    return report
+  }
+
+  return { runVerification }
+}
+
+export const verificationEngine = createVerificationEngine()
+
+/**
+ * best-effort 触发:捕获一切异常只 warn,绝不影响拆书任务。
+ * 各技能 adapter 的 publish 末尾调用。
+ */
+export async function scheduleVerification(
+  bookPath: string,
+  skill: VerificationSkill,
+  llmConfig: LlmConfig,
+): Promise<void> {
+  try {
+    await verificationEngine.runVerification(skill, bookPath, llmConfig)
+  } catch (error) {
+    console.warn(`[verify] ${skill} 审计未完成(不影响拆书结果):`, error)
+  }
+}

+ 193 - 0
src/lib/novel/book-analysis/verification-prompts.ts

@@ -0,0 +1,193 @@
+/**
+ * 拆书三重验证 + 压力测试 - prompt 构建 + 解析(chaishugushidaotu 分支)
+ *
+ * 三重验证:V1 跨域佐证 / V2 预测力 / V3 独特性,合并为一次 LLM 调用/单元。
+ * 压力测试:apply(换场景)/ boundary(边界反例)/ confusion(混淆取舍)。
+ * 解析容错对标 style-prompts:剥代码围栏 + 取最外层 {},不抛错。
+ */
+import type {
+  PressureTestKind,
+  TripleVerifyItem,
+} from "./verification-types"
+
+interface VerifyRawItem {
+  key: string
+  status: string
+  detail: string
+  evidenceCount?: number
+}
+
+function asString(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function asInt(value: unknown): number {
+  const num = Number(value)
+  return Number.isFinite(num) && num >= 0 ? Math.floor(num) : 0
+}
+
+/** 三重验证通用输出说明(嵌入各技能 prompt) */
+const TRIPLE_RULES = [
+  "对下面的提取单元执行三重验证,逐项判定:",
+  "- crossDomain(跨域佐证):单元的关键特征在提供的原文证据中是否有至少 2 处相互独立的佐证;evidenceCount 填命中的佐证条数。",
+  "- predictive(预测力):用这个单元能否回答一个原文没明说的新问题(如预测后续剧情/新场景的写法);不能则 fail。",
+  "- unique(独特性):内容是否具体到这本作品独有,而不是任何小说都适用的套话;套话判 fail。",
+  "status 取值:pass(通过)/ warn(勉强通过,有瑕疵)/ fail(不通过),detail 用中文说明判定依据。",
+].join("\n")
+
+/** 压力测试通用输出说明 */
+const PRESSURE_RULES = [
+  "再对单元做压力测试抽检,为指定类型的场景各构造 1 条并判定:",
+  "- apply(换场景运用):把单元迁移到一个新的写作场景,判断是否仍自洽可用。",
+  "- boundary(边界反例):构造一个不应套用该单元的场景,判断单元描述是否明确排除了它。",
+  "- confusion(混淆取舍):构造一个容易与相邻单元混淆的场景,判断能否正确取舍。",
+  "每条包含:kind、prompt(测试场景一句话)、verdict(pass/warn/fail)、reason(判定原因)。",
+].join("\n")
+
+const OUTPUT_CONTRACT = [
+  "只输出一个 JSON 对象(不要围栏、不要解释):",
+  "{",
+  '  "triple": [ { "key": "crossDomain|predictive|unique", "status": "pass|warn|fail", "detail": "...", "evidenceCount": 0 } ],',
+  '  "pressure": [ { "kind": "apply|boundary|confusion", "prompt": "...", "verdict": "pass|warn|fail", "reason": "..." } ]',
+  "}",
+].join("\n")
+
+/** 角色单元:三重验证 + 压力测试 prompt */
+export function buildCharacterVerifyPrompt(input: {
+  characterName: string
+  profileText: string
+  corpus: string
+}): string {
+  return [
+    `你是拆书质量审计员。请审计下面这个从小说中提取的角色档案的「可复用性」。`,
+    "",
+    `角色:${input.characterName}`,
+    "",
+    "角色档案(被审计单元):",
+    input.profileText.slice(0, 4000),
+    "",
+    "原文语料片段(用于跨域佐证核对):",
+    input.corpus.slice(0, 6000) || "(无原文语料,crossDomain 无法核对请判 fail)",
+    "",
+    TRIPLE_RULES,
+    "",
+    PRESSURE_RULES,
+    "角色单元的相邻单元是同书其他角色(confusion 场景用「容易认错/混淆的两个角色」构造)。",
+    "",
+    OUTPUT_CONTRACT,
+  ].join("\n")
+}
+
+/** 文风单元:三重验证 + 压力测试 prompt */
+export function buildStyleVerifyPrompt(input: {
+  bookTitle: string
+  profileText: string
+  samples: string[]
+}): string {
+  return [
+    `你是拆书质量审计员。请审计下面这本作品提取的「文风画像」的可复用性。`,
+    "",
+    `作品:《${input.bookTitle}》`,
+    "",
+    "文风画像(被审计单元):",
+    input.profileText.slice(0, 4000),
+    "",
+    "代表原文样本(用于跨域佐证核对):",
+    input.samples.length > 0
+      ? input.samples.map((sample, index) => `样本${index + 1}:${sample}`).join("\n\n").slice(0, 6000)
+      : "(无样本,crossDomain 无法核对请判 fail)",
+    "",
+    TRIPLE_RULES,
+    "文风的 predictive 判定示例:能否据此预测本书未提供章节的句子长度、对话密度、比喻用法。",
+    "",
+    PRESSURE_RULES,
+    "文风单元的相邻单元是同书角色 Skill(confusion 场景用「文风约束 vs 角色说话方式」构造)。",
+    "",
+    OUTPUT_CONTRACT,
+  ].join("\n")
+}
+
+/** 故事导图单元:三重验证 + 压力测试 prompt */
+export function buildStoryVerifyPrompt(input: {
+  bookTitle: string
+  mapDigest: string
+}): string {
+  return [
+    `你是拆书质量审计员。请审计下面这本作品提取的「故事导图(主线+分支)」的可复用性。`,
+    "",
+    `作品:《${input.bookTitle}》`,
+    "",
+    "故事导图摘要(被审计单元,章节→主线事件→分支):",
+    input.mapDigest.slice(0, 8000),
+    "",
+    TRIPLE_RULES,
+    "导图的判定口径:crossDomain=主线事件链与分支是否各自有明确章节来源与触发关系;predictive=能否据此预测后续章节的推进节奏;unique=主线/分支归类是否具体,而非「主角变强」这类泛化套话。",
+    "",
+    PRESSURE_RULES,
+    "故事单元的压力测试类型:apply=迁移到新书能否复用节奏;boundary=什么样的题材不该套用此导图;confusion=主线事件与分支事件混淆时如何取舍。",
+    "",
+    OUTPUT_CONTRACT,
+  ].join("\n")
+}
+
+/** 需要跑的压力测试类型(角色 2 条、文风/故事 3 条) */
+export function pressureKindsFor(skill: "characters" | "style" | "story"): PressureTestKind[] {
+  return skill === "characters"
+    ? ["apply", "boundary"]
+    : ["apply", "boundary", "confusion"]
+}
+
+/** 解析三重验证 + 压力测试结果(容错,不抛错) */
+export function parseVerifyResult(
+  raw: string,
+): {
+  triple: TripleVerifyItem[]
+  pressure: Array<{ kind: string; prompt: string; verdict: string; reason: string }>
+} {
+  const fenceStripped = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw
+  const objectText = fenceStripped.match(/\{[\s\S]*\}/)?.[0]
+  if (!objectText) return { triple: [], pressure: [] }
+  try {
+    const parsed = JSON.parse(objectText) as Record<string, unknown>
+    const tripleRaw = Array.isArray(parsed.triple) ? parsed.triple : []
+    const labelMap: Record<string, string> = {
+      crossDomain: "跨域佐证",
+      predictive: "预测力",
+      unique: "独特性",
+    }
+    const triple: TripleVerifyItem[] = tripleRaw.flatMap((item): TripleVerifyItem[] => {
+      if (!item || typeof item !== "object") return []
+      const candidate = item as VerifyRawItem
+      const key = asString(candidate.key)
+      if (key !== "crossDomain" && key !== "predictive" && key !== "unique") return []
+      const status = candidate.status === "pass" || candidate.status === "warn" || candidate.status === "fail"
+        ? candidate.status
+        : "fail"
+      return [{
+        key,
+        label: labelMap[key],
+        status,
+        detail: asString(candidate.detail),
+        evidenceCount: asInt(candidate.evidenceCount),
+      }]
+    })
+    const pressureRaw = Array.isArray(parsed.pressure) ? parsed.pressure : []
+    const pressure = pressureRaw.flatMap((item): Array<{ kind: string; prompt: string; verdict: string; reason: string }> => {
+      if (!item || typeof item !== "object") return []
+      const candidate = item as Record<string, unknown>
+      const prompt = asString(candidate.prompt)
+      if (!prompt) return []
+      return [{
+        kind: asString(candidate.kind),
+        prompt,
+        verdict: candidate.verdict === "pass" || candidate.verdict === "warn" || candidate.verdict === "fail"
+          ? candidate.verdict
+          : "fail",
+        reason: asString(candidate.reason),
+      }]
+    })
+    return { triple, pressure }
+  } catch {
+    return { triple: [], pressure: [] }
+  }
+}

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

@@ -0,0 +1,83 @@
+/**
+ * 拆书三重验证 + 压力测试 - 类型定义(chaishugushidaotu 分支)
+ *
+ * 纯审计:结果只落盘(verification/<skill>-verification.json / .md),
+ * 不剔除任何提取产出,不影响拆书任务成功。
+ */
+
+/** 参与验证的拆书技能 */
+export type VerificationSkill = "characters" | "style" | "story"
+
+/** 三重验证维度 */
+export type TripleVerifyKey = "crossDomain" | "predictive" | "unique"
+
+export const TRIPLE_VERIFY_LABELS: Record<TripleVerifyKey, string> = {
+  crossDomain: "跨域佐证",
+  predictive: "预测力",
+  unique: "独特性",
+}
+
+export interface TripleVerifyItem {
+  key: TripleVerifyKey
+  label: string
+  status: "pass" | "warn" | "fail"
+  /** 判定说明:命中点 / 反例 / 原因 */
+  detail: string
+  /** 跨域佐证:命中的原文佐证条数 */
+  evidenceCount: number
+}
+
+/** 压力测试类型 */
+export type PressureTestKind = "apply" | "boundary" | "confusion"
+
+export const PRESSURE_TEST_KIND_LABELS: Record<PressureTestKind, string> = {
+  apply: "换场景运用",
+  boundary: "边界反例",
+  confusion: "混淆取舍",
+}
+
+export interface PressureTestItem {
+  id: string
+  kind: PressureTestKind
+  /** 测试场景描述 */
+  prompt: string
+  verdict: "pass" | "warn" | "fail"
+  reason: string
+}
+
+/** 被验证的单元(角色 / 文风 / 故事导图) */
+export interface VerificationUnit {
+  /** 角色用 character.id;文风/故事用 "style" / "story" */
+  id: string
+  name: string
+  triple: TripleVerifyItem[]
+  /** 三重验证是否全部通过(warn 计为通过但降级,fail 不通过) */
+  passed: boolean
+  pressureTests: PressureTestItem[]
+}
+
+export interface VerificationReport {
+  schemaVersion: 1
+  skill: VerificationSkill
+  bookId: string
+  bookPath: string
+  verifiedAt: number
+  /** 是否因数量上限截断 */
+  costBounded: boolean
+  /** 被截断未验证的单元数 */
+  skippedUnitCount: number
+  units: VerificationUnit[]
+  summary: {
+    total: number
+    passed: number
+    warn: number
+    fail: number
+  }
+}
+
+/** 单元状态汇总辅助:任一 fail 即 fail;否则有 warn 记 warn;否则 pass */
+export function summarizeUnitStatus(items: Array<{ status: "pass" | "warn" | "fail" }>): "pass" | "warn" | "fail" {
+  if (items.some((item) => item.status === "fail")) return "fail"
+  if (items.some((item) => item.status === "warn")) return "warn"
+  return "pass"
+}

+ 1 - 3
src/stores/wiki-store.ts

@@ -51,9 +51,7 @@ const UI_FONT_FAMILY_KEY = "qmai-ui-font-family"
 const SIDEBAR_NAV_CONFIG_KEY = "qmai-sidebar-nav-config"
 
 type SettingsCategoryId =
-  | "llm"
-  | "rerank"
-  | "embedding"
+  | "model"
   | "network"
   | "interface"
   | "novel"