Browse Source

feat(book-analysis): 增加角色选择门禁、运行时进度与 LLM JSON 修复

配置角色分析后先识别再勾选目标角色,再进入深度分析队列。
补充 pipeline 进度快照与 jsonrepair 解析,并清理未接线 novel 模块。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 month ago
parent
commit
7d6f85b858
58 changed files with 2106 additions and 6138 deletions
  1. 0 89
      aidagangxiugaiduibi-分支说明.md
  2. 12 2
      package-lock.json
  3. 1 0
      package.json
  4. 0 40
      shanchujuesejiantourukou-分支说明.md
  5. 0 1
      src/components/chat/chat-panel.tsx
  6. 136 1
      src/components/layout/book-analysis-sidebar-panel.spec.tsx
  7. 28 18
      src/components/layout/book-analysis-sidebar-panel.tsx
  8. 2 250
      src/components/layout/sidebar-panel.tsx
  9. 0 87
      src/components/novel/book-analysis-book-list.tsx
  10. 41 0
      src/components/novel/book-analysis-character-panel.spec.tsx
  11. 22 4
      src/components/novel/book-analysis-character-panel.tsx
  12. 4 4
      src/components/novel/book-analysis-import-task-panel.spec.tsx
  13. 25 23
      src/components/novel/book-analysis-import-task-panel.tsx
  14. 15 1
      src/components/novel/book-analysis-library-layout.tsx
  15. 285 2
      src/components/novel/book-analysis-module-view.spec.tsx
  16. 193 29
      src/components/novel/book-analysis-module-view.tsx
  17. 2 16
      src/components/novel/book-analysis-routing.spec.ts
  18. 216 38
      src/components/novel/book-analysis-view.tsx
  19. 0 107
      src/components/novel/character-profile.tsx
  20. 47 1
      src/components/novel/character-selection-panel.spec.tsx
  21. 17 1
      src/components/novel/character-selection-panel.tsx
  22. 2 15
      src/components/novel/de-ai-batch-entry.spec.ts
  23. 0 92
      src/components/novel/de-ai-batch-start-dialog.spec.tsx
  24. 0 154
      src/components/novel/de-ai-batch-start-dialog.tsx
  25. 0 157
      src/components/novel/de-ai-batch-task-panel.spec.tsx
  26. 0 132
      src/components/novel/de-ai-batch-task-panel.tsx
  27. 0 206
      src/components/novel/de-ai-batch-workspace.tsx
  28. 0 51
      src/components/novel/de-ai-preview-dialog.tsx
  29. 0 101
      src/components/novel/de-ai-skill-editor.tsx
  30. 0 836
      src/components/novel/dismantling-view.tsx
  31. 0 143
      src/components/novel/foreshadowing-panel.tsx
  32. 0 676
      src/components/novel/plot-framework-extract-dialog.tsx
  33. 0 14
      src/components/novel/plot-framework-library-view.layout.spec.ts
  34. 0 1223
      src/components/novel/plot-framework-library-view.tsx
  35. 0 169
      src/components/novel/retrieval-status-indicator.tsx
  36. 0 326
      src/components/novel/story-simulation/clue-board-panel.tsx
  37. 0 67
      src/components/novel/timeline-view.tsx
  38. 39 0
      src/lib/novel/book-analysis/analysis-pipeline-storage.spec.ts
  39. 20 0
      src/lib/novel/book-analysis/analysis-pipeline-storage.ts
  40. 22 0
      src/lib/novel/book-analysis/analysis-pipeline-types.ts
  41. 166 8
      src/lib/novel/book-analysis/analysis-scheduler.spec.ts
  42. 129 10
      src/lib/novel/book-analysis/analysis-scheduler.ts
  43. 6 0
      src/lib/novel/book-analysis/analysis-skill-adapter.ts
  44. 53 0
      src/lib/novel/book-analysis/character-analysis-adapter.spec.ts
  45. 18 3
      src/lib/novel/book-analysis/character-analysis-adapter.ts
  46. 58 3
      src/lib/novel/book-analysis/character-extraction-engine.spec.ts
  47. 97 71
      src/lib/novel/book-analysis/character-extraction-engine.ts
  48. 52 0
      src/lib/novel/book-analysis/llm-json.spec.ts
  49. 71 0
      src/lib/novel/book-analysis/llm-json.ts
  50. 30 16
      src/lib/novel/book-analysis/story-analysis-adapter.ts
  51. 15 4
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  52. 153 0
      src/stores/book-analysis-pipeline-store.character-selection.spec.ts
  53. 1 0
      src/stores/book-analysis-pipeline-store.spec.ts
  54. 128 7
      src/stores/book-analysis-pipeline-store.ts
  55. 0 425
      src/stores/de-ai-batch-store.spec.ts
  56. 0 466
      src/stores/de-ai-batch-store.ts
  57. 0 4
      src/stores/wiki-store.ts
  58. 0 45
      xiangliangzaoyinzhili-分支说明.md

+ 0 - 89
aidagangxiugaiduibi-分支说明.md

@@ -1,89 +0,0 @@
-# AI 大纲修改对比分支说明
-
-## 分支名称
-
-`aidagangxiugaiduibi`
-
-## 基线版本
-
-- 基线提交:`a6e3ee053856db7d6e04a4fc4e596224e05f67b7`
-- 创建日期:2026-07-21
-
-## 本分支目标
-
-1. 修复 AI 大纲用户确认后仍无法写入 `replace` 或 `patch` 请求的问题。
-2. 重做 AI 大纲确认保存界面,支持按文件选择、原始内容与最新内容逐行对比、变更高亮和最新内容编辑。
-3. 原始内容与最新内容双侧同步滚动。
-4. 增加“源码对比 / 渲染预览”查看模式。
-5. AI 去 AI 味确认流程复用相同的对比、编辑和预览体验。
-6. 所有大纲新建和后续保存统一使用纯 Markdown,只保留 Markdown 标题和正文,不再写入 YAML 前置信息。
-
-## 修改边界
-
-- 只修改与大纲创建、AI 大纲确认写入、AI 去 AI 味确认和共享差异编辑器直接相关的代码。
-- 不批量清理未被用户选择和确认的历史大纲文件。
-- 不修改章节等非大纲文件现有的 YAML 存储规则。
-- 不删除已有函数;如需改变行为,优先增加明确参数或复用现有入口。
-
-## 成功标准
-
-1. `replace`、`patch` 请求在用户确认后能够写入目标文件,未确认时仍禁止写入。
-2. 用户可按文件勾选、切换文件、编辑最新 Markdown 内容并确认保存。
-3. 源码模式逐行高亮差异,左右两侧同步滚动。
-4. 预览模式显示渲染后的 Markdown,不显示源码符号且保持双侧同步滚动。
-5. 保存成功后刷新左侧大纲树;失败时保留弹窗和用户编辑内容,并显示中文错误。
-6. 新建或再次保存的大纲文件不包含 YAML 前置信息。
-7. AI 大纲和 AI 去 AI 味旧功能测试、类型检查、构建与打包通过。
-
-## 更新记录
-
-### 2026-07-21
-
-- 创建独立分支。
-- 完成需求澄清和交互方案确认。
-- 记录写入失败根因和实施边界。
-
-## Git 状态
-
-- 提交状态:未提交
-- 合并状态:未合并
-
-
-### 2026-07-23
-
-- 修复 AI 大纲意图分析完成后,生成阶段重复触发意图分析导致外层请求循环的问题。
-- 自动续发仅允许从 `intent_analysis` 阶段进入一次生成;增加阶段闸门回归测试。
-- 已完成大纲相关回归、类型检查、源码启动检查、生产构建和便携版打包。
-- 当前版本沿用 `2.2.37`;Git 状态:未提交、未合并、未推送。
-
-### 2026-07-23 大纲保存交互
-
-- 普通大纲保存改用文件夹确认弹窗,不再显示源码对比内容。
-- 保留人物小传勾选、默认文件夹和 `confirmed: true` 保存保护。
-- 已完成保存交互回归、类型检查、源码启动、构建与便携版打包。
-- Git 状态:未提交、未合并、未推送。
-
-
-
-### 2026-07-23 去 AI 味逐行对比
-
-- 新增共享差异工作区,提供行数统计、源码对比、渲染预览、同步滚动和候选稿编辑。
-- 单次选区去 AI 味与批量/章节去 AI 味审核统一接入;AI 润色保持原交互。
-- 编辑后的候选稿会传入替换正文、另存草稿和批量确认写回;重新生成会丢弃当前章未确认编辑。
-- 普通大纲保存仍使用文件夹确认弹窗,不显示差异内容。
-- 专项测试 9 个文件、75 项通过;全量离线回归 4035 项通过、6 项 todo、4 项既有模型相关失败。
-- 类型检查、源码启动、生产构建和便携版打包通过;版本沿用 `2.2.37`。
-- Git 状态:未提交、未合并、未推送。
-
-### 2026-07-23 大纲保存与标题优化
-
-- 修复 AI 大纲保存后内容包含 JSON 格式块的问题,保存时增加 `stripStructuredMarkers` 和 `extractBodyContent` 过滤。
-- 修复 AI 大纲保存内容包含前置分析/前言文本的问题,新增 `stripPrefaceText` 函数自动定位大纲正文起始位置。
-- 优化大纲标题提取逻辑,无需再次调用模型,直接从内容中智能提取标题:
-  - 自动识别内容类型(章纲、卷纲、人物、设定、伏笔、组织、总纲)
-  - 章纲提取"第X章 章节标题"格式
-  - 卷纲提取"第X卷 卷标题"格式
-  - 人物设定优先提取姓名字段
-  - 多级回退机制确保总能生成合适标题
-- 大纲保存相关测试全部通过;类型检查无错误。
-- Git 状态:未提交、未合并、未推送。

+ 12 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "qmai",
-  "version": "3.0.3",
+  "version": "3.0.7",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "qmai",
-      "version": "3.0.3",
+      "version": "3.0.7",
       "license": "GPL-3.0-or-later",
       "dependencies": {
         "@base-ui/react": "^1.6.0",
@@ -35,6 +35,7 @@
         "graphology-layout-forceatlas2": "^0.10.1",
         "i18next": "^26.3.4",
         "js-yaml": "^4.3.0",
+        "jsonrepair": "^3.15.0",
         "katex": "^0.16.47",
         "lucide-react": "^1.23.0",
         "mermaid": "^11.16.0",
@@ -6839,6 +6840,15 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/jsonrepair": {
+      "version": "3.15.0",
+      "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.15.0.tgz",
+      "integrity": "sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==",
+      "license": "ISC",
+      "bin": {
+        "jsonrepair": "bin/cli.js"
+      }
+    },
     "node_modules/katex": {
       "version": "0.16.47",
       "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",

+ 1 - 0
package.json

@@ -44,6 +44,7 @@
     "graphology-layout-forceatlas2": "^0.10.1",
     "i18next": "^26.3.4",
     "js-yaml": "^4.3.0",
+    "jsonrepair": "^3.15.0",
     "katex": "^0.16.47",
     "lucide-react": "^1.23.0",
     "mermaid": "^11.16.0",

+ 0 - 40
shanchujuesejiantourukou-分支说明.md

@@ -1,40 +0,0 @@
-# shanchujuesejiantourukou 分支说明
-
-## 目标
-
-删除拆书库中旧的“简单提取”和“深度提取(6 维)”用户入口,统一使用新的章节分块角色 Skill 分析流水线。
-
-## 保留范围
-
-1. 保留旧角色数据中的六维字段读取和展示兼容。
-2. 保留旧 Skill 生成器对已保存六维数据的兼容处理。
-3. 不删除 `simple-extraction-engine` 和 `six-dimension-engine` 底层代码,避免历史作品数据无法读取。
-4. 不修改新的角色、故事、文风分析流水线。
-
-## 提交状态
-
-将随本次角色信息与章节范围修复一并提交。
-
-## 验证结果
-
-- 删除入口专项:2 个测试文件、9 项通过。
-- 完整离线回归:345 个测试文件、2496 项通过、6 项 todo、0 项失败。
-- 类型检查、生产构建和 `git diff --check` 通过。
-- 便携打包成功,版本 2.2.37,SHA-256 为 `925BDAEA2AED82661F2C74342583F0265BCD9209E8239128BAEBFA2D0CBBF710`。
-
-## 角色信息与 Skill 选择流程
-
-- 角色流水线先保存角色信息,不再自动为全部角色生成 Skill。
-- 过滤低频、低描述度的路人角色,并按出场频次、重要度和信息完整度分类主角、配角、次要配角。
-- 角色面板新增选择角色生成 Skill 的弹窗;生成后继续使用现有自定义灵魂库入口。
-- 角色分析完成后通过右下角通知打开角色信息面板,面板也可从角色 Skill 页面重复打开。
-- 完整回归:346 个测试文件、2497 项通过、6 项 todo、0 项失败。
-- 次要配角可以由用户手动选择生成 Skill,只有低频且信息不足的路人角色会被过滤。
-
-## 章节范围修复
-
-- 显示作品实际章节总数和可选择范围。
-- 超出实际章节数时直接提示作品总章节数,不再显示错误的缺失章节编号。
-- 单次分析最多 100 章,界面和任务提交层均保留限制。
-- 兼容旧作品中从 0 开始或存在断号的章节顺序,读取后按作品顺序规范为 1~N。
-- 最新便携包已重新构建,版本 2.2.37。

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

@@ -121,7 +121,6 @@ import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plu
 import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
 // import { getLoadedCategories, DATA_SOURCE_CATEGORY_LABELS } from "@/lib/novel/classification"
 // import { RetrievalStore } from "@/lib/novel/retrieval"
-// import { RetrievalStatusIndicator } from "@/components/novel/retrieval-status-indicator"
 // import { readFile as fsReadFile, writeFile as fsWriteFile, fileExists, listDirectory, createDirectory as fsCreateDirectory } from "@/commands/fs"
 // import { joinPath } from "@/lib/path-utils"
 // import type { AiCapability } from "@/lib/agent/capabilities/types"

+ 136 - 1
src/components/layout/book-analysis-sidebar-panel.spec.tsx

@@ -51,9 +51,53 @@ vi.mock("@/stores/book-analysis-store", () => ({
     selector ? selector(mockBookAnalysisState) : mockBookAnalysisState,
 }))
 
+const mockImportState = {
+  deletePublishedBook: mockDeletePublishedBook,
+  batches: [] as Array<{
+    version: 1
+    id: string
+    projectPath: string
+    taskIds: string[]
+    panelDismissedAt?: number | null
+    createdAt: number
+    updatedAt: number
+  }>,
+  tasks: [] as Array<{
+    version: 1
+    id: string
+    batchId: string
+    projectPath: string
+    originalPath: string
+    originalFileName: string
+    cachedSourcePath: string
+    sourceSha256: string | null
+    requestedTitle: string
+    finalTitle: string | null
+    bookId: string
+    status: string
+    completed: number
+    total: number
+    error: string | null
+    skipReason: string | null
+    createdAt: number
+    startedAt: number | null
+    completedAt: number | null
+    updatedAt: number
+  }>,
+  createBatch: vi.fn(),
+  continueTask: vi.fn(),
+  regenerateTask: vi.fn(),
+  cancelTask: vi.fn(),
+  cancelAllQueued: vi.fn(),
+  deleteFailedTask: vi.fn(),
+  renameCompletedTask: vi.fn(),
+  panelCollapsed: false,
+  setPanelCollapsed: vi.fn(),
+}
+
 vi.mock("@/stores/book-analysis-import-store", () => ({
   useBookAnalysisImportStore: (selector: (state: any) => unknown) =>
-    selector({ deletePublishedBook: mockDeletePublishedBook }),
+    selector(mockImportState),
 }))
 
 vi.mock("@/lib/toast", () => ({
@@ -91,11 +135,102 @@ beforeEach(async () => {
   vi.clearAllMocks()
   mockWikiState.project = { id: "p1", name: "Novel", path: "/proj" }
   mockBookAnalysisState.tasks = []
+  mockImportState.batches = []
+  mockImportState.tasks = []
+  mockImportState.panelCollapsed = false
   mockDeletePublishedBook.mockResolvedValue(undefined)
   await flushAsync(20)
 })
 
 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",

+ 28 - 18
src/components/layout/book-analysis-sidebar-panel.tsx

@@ -56,9 +56,17 @@ export function BookAnalysisSidebarPanel() {
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
   const [bookAuraCount, setBookAuraCount] = useState<Record<string, number>>({})
   const [inputOpen, setInputOpen] = useState(false)
-  const multiBatchIds = new Set(importBatches.filter((batch) => batch.taskIds.length > 1 && !batch.panelDismissedAt).map((batch) => batch.id))
-  const visibleBatches = importBatches.filter((batch) => multiBatchIds.has(batch.id))
-  const visibleImportTasks = importTasks.filter((task) => multiBatchIds.has(task.batchId))
+  // 未 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")
@@ -231,27 +239,29 @@ export function BookAnalysisSidebarPanel() {
   return (
     <div className="flex h-full flex-col">
       {/* 标题栏 */}
-      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+      <div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
         <div className="min-w-0">
           <PanelHeaderWithHelp title="作品库" helpKey="bookAnalysis" />
           <div className="mt-0.5 text-xs text-muted-foreground">
             已分析 {books.length} 部作品
           </div>
         </div>
-        <Button
-          type="button"
-          size="icon"
-          variant="ghost"
-          className="h-8 w-8"
-          onClick={loadBooks}
-          disabled={loading}
-          title="刷新列表"
-        >
-          <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
-        </Button>
-        <Button type="button" size="icon" variant="ghost" className="h-8 w-8" onClick={() => setInputOpen(true)} title="导入小说">
-          <Plus className="h-4 w-4" />
-        </Button>
+        <div className="flex shrink-0 items-center">
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-8 w-8"
+            onClick={loadBooks}
+            disabled={loading}
+            title="刷新列表"
+          >
+            <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
+          </Button>
+          <Button type="button" size="icon" variant="ghost" className="h-8 w-8" onClick={() => setInputOpen(true)} title="导入小说">
+            <Plus className="h-4 w-4" />
+          </Button>
+        </div>
       </div>
 
       <BookAnalysisImportTaskPanel

+ 2 - 250
src/components/layout/sidebar-panel.tsx

@@ -7,9 +7,7 @@ import {
   ChevronDown,
   CircleHelp,
   Clock3,
-  FilePlus2,
   FileText,
-  FolderPlus,
   GitBranchPlus,
   Plus,
   RefreshCw,
@@ -31,17 +29,8 @@ import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { loadFrameworks, loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import type { StoryFramework } from "@/lib/novel/story-simulation/types"
-import { createDirectory, fileExists, listDirectory, preprocessFile, readFile, writeFile } from "@/commands/fs"
-import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
-import {
-  loadDismantlingLibrary,
-  normalizeDismantlingLibrary,
-  saveDismantlingLibrary,
-  splitDismantlingTextIntoChapters,
-  type DismantlingChapter,
-  type DismantlingLibrary,
-  type DismantlingProject,
-} from "@/lib/novel/dismantling"
+import { createDirectory, fileExists, listDirectory, readFile, writeFile } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
 import { flattenMdFiles, getNextChapterNumber } from "@/lib/novel/chapter-utils"
 import { Button } from "@/components/ui/button"
 import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
@@ -58,8 +47,6 @@ import {
   collectChapterImportCandidatesFromFolder,
   importChapterFiles,
   runImportedChapterMemoryExtraction,
-  sortChapterImportCandidates,
-  type ChapterImportCandidate,
   type ImportedChapter,
 } from "@/lib/novel/chapter-import"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
@@ -85,21 +72,6 @@ const UnifiedSkillLibrarySidebarPanel = lazy(async () => {
 
 const USAGE_GUIDE_URL = "https://tcnk9ik08e1c.feishu.cn/wiki/FWiSwYQKoifpwBk6mSRcSlB8nrh?from=from_copylink"
 
-const DISMANTLING_NO_PREPROCESSING_NEEDED = "no preprocessing needed"
-
-function normalizeDismantlingProjectTitle(title: string): string {
-  return title
-    .normalize("NFKC")
-    .trim()
-    .replace(/\.(txt|md|mdx|doc|docx)$/i, "")
-    .replace(/\s+/g, "")
-    .toLowerCase()
-}
-
-function shouldReadDismantlingOriginalFile(preprocessedText: string): boolean {
-  return preprocessedText.trim().toLowerCase() === DISMANTLING_NO_PREPROCESSING_NEEDED
-}
-
 function SearchHistoryPanel() {
   const { t } = useTranslation()
   const searchHistory = useWikiStore((s) => s.searchHistory)
@@ -148,226 +120,6 @@ function SidebarPanelLoading() {
   )
 }
 
-export function DismantlingSidebarPanel() {
-  const project = useWikiStore((s) => s.project)
-  const selectedDismantlingProjectId = useWikiStore((s) => s.selectedDismantlingProjectId)
-  const setSelectedDismantlingProjectId = useWikiStore((s) => s.setSelectedDismantlingProjectId)
-  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
-  const [library, setLibrary] = useState<DismantlingLibrary>({ version: 1, projects: [], selectedProjectId: null })
-  const [loading, setLoading] = useState(false)
-  const [importing, setImporting] = useState(false)
-  const [importStatus, setImportStatus] = useState("")
-
-  useEffect(() => {
-    if (!project?.path) {
-      setLibrary({ version: 1, projects: [], selectedProjectId: null })
-      setSelectedDismantlingProjectId(null)
-      return
-    }
-    let cancelled = false
-    setLoading(true)
-    void loadDismantlingLibrary(project.path)
-      .then((value) => {
-        if (cancelled) return
-        setLibrary(value)
-        const selected = value.projects.find((item) => item.id === selectedDismantlingProjectId) ?? value.projects[0] ?? null
-        if (selected?.id !== selectedDismantlingProjectId) setSelectedDismantlingProjectId(selected?.id ?? null)
-      })
-      .finally(() => {
-        if (!cancelled) setLoading(false)
-      })
-    return () => {
-      cancelled = true
-    }
-  }, [project?.path, selectedDismantlingProjectId, setSelectedDismantlingProjectId])
-
-  async function importDismantlingCandidates(candidates: ChapterImportCandidate[], titleFallback: string) {
-    if (!project?.path || importing) return
-    setImporting(true)
-    setImportStatus("正在提取章节...")
-    try {
-      const normalizedTitle = normalizeDismantlingProjectTitle(titleFallback || "未命名拆文作品")
-      const existingProject = library.projects.find((item) => normalizeDismantlingProjectTitle(item.title) === normalizedTitle)
-      if (existingProject) {
-        setSelectedDismantlingProjectId(existingProject.id)
-        setImportStatus(`已存在相同拆文作品:${existingProject.title}`)
-        window.alert(`已存在相同拆文作品:${existingProject.title},不会重复显示。`)
-        return
-      }
-
-      const chapters: DismantlingChapter[] = []
-      const sorted = sortChapterImportCandidates(candidates)
-      for (const candidate of sorted) {
-        setImportStatus(`正在提取章节:${candidate.name}`)
-        let content = ""
-        try {
-          content = await preprocessFile(candidate.path)
-          if (shouldReadDismantlingOriginalFile(content)) {
-            content = await readFile(candidate.path)
-          }
-        } catch {
-          content = await readFile(candidate.path)
-        }
-        const split = splitDismantlingTextIntoChapters(content)
-        if (split.length <= 1) {
-          const chapterNumber = chapters.length + 1
-          chapters.push({
-            id: `chapter-${String(chapterNumber).padStart(3, "0")}`,
-            chapterNumber,
-            title: split[0]?.title === "第1章" ? getFileStem(candidate.name) || `第${chapterNumber}章` : split[0]?.title ?? getFileStem(candidate.name),
-            content: split[0]?.content || content,
-            status: "pending",
-          })
-        } else {
-          for (const item of split) {
-            chapters.push({ ...item, id: `chapter-${String(chapters.length + 1).padStart(3, "0")}`, chapterNumber: chapters.length + 1 })
-          }
-        }
-      }
-      if (chapters.length === 0) {
-        window.alert("没有找到可导入的拆文资料。")
-        return
-      }
-      const now = Date.now()
-      const nextProject: DismantlingProject = {
-        id: `dismantling-${now}`,
-        title: titleFallback || "未命名拆文作品",
-        createdAt: now,
-        updatedAt: now,
-        chapters,
-        analyses: [],
-        structureMemory: [],
-        useInChat: false,
-      }
-      const nextLibrary: DismantlingLibrary = {
-        ...library,
-        projects: [nextProject, ...library.projects],
-        selectedProjectId: nextProject.id,
-      }
-      const normalizedLibrary = normalizeDismantlingLibrary(nextLibrary)
-      await saveDismantlingLibrary(project.path, normalizedLibrary)
-      setLibrary(normalizedLibrary)
-      setSelectedDismantlingProjectId(nextProject.id)
-      setImportStatus(`已提取 ${chapters.length} 个章节。`)
-      bumpDataVersion()
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error)
-      window.alert(`导入失败:${message}`)
-    } finally {
-      setImporting(false)
-    }
-  }
-
-  async function handleImportDismantlingFiles() {
-    if (!project?.path || importing) return
-
-    const { open } = await import("@tauri-apps/plugin-dialog")
-    const selected = await open({
-      multiple: true,
-      title: "导入拆文文件",
-      filters: [{ name: "文档", extensions: ["txt", "md", "mdx", "doc", "docx"] }],
-    })
-    const paths = Array.isArray(selected) ? selected : selected ? [selected] : []
-    if (paths.length === 0) return
-    const candidates = paths.map((path) => ({ path: normalizePath(path), name: getFileName(path) }))
-
-    await importDismantlingCandidates(candidates, getFileStem(candidates[0]?.name ?? "") || "拆文作品")
-  }
-
-  async function handleImportDismantlingFolder() {
-    if (!project?.path || importing) return
-
-    const { open } = await import("@tauri-apps/plugin-dialog")
-    const selected = await open({ directory: true, title: "导入拆文文件夹" })
-    if (!selected || Array.isArray(selected)) return
-    const candidates = await collectChapterImportCandidatesFromFolder(selected)
-    await importDismantlingCandidates(candidates, getFileName(selected) || "拆文作品")
-  }
-
-  async function handleDeleteDismantlingProject(item: DismantlingProject) {
-    if (!project?.path || importing) return
-    const confirmed = window.confirm(`确认删除拆文作品“${item.title}”吗?删除后会移除该作品的章节、拆文结果和结构记忆。`)
-    if (!confirmed) return
-
-    const nextProjects = library.projects.filter((projectItem) => projectItem.id !== item.id)
-    const nextSelectedProjectId = selectedDismantlingProjectId === item.id
-      ? nextProjects[0]?.id ?? null
-      : selectedDismantlingProjectId
-    const normalizedLibrary = normalizeDismantlingLibrary({
-      ...library,
-      projects: nextProjects,
-      selectedProjectId: nextSelectedProjectId,
-    })
-
-    await saveDismantlingLibrary(project.path, normalizedLibrary)
-    setLibrary(normalizedLibrary)
-    setSelectedDismantlingProjectId(normalizedLibrary.selectedProjectId ?? null)
-    setImportStatus(`已删除拆文作品:${item.title}`)
-    bumpDataVersion()
-  }
-
-  return (
-    <div className="flex h-full flex-col">
-      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
-        <div className="min-w-0">
-          <div className="text-sm font-semibold text-foreground">拆文作品</div>
-          <div className="mt-0.5 text-xs text-muted-foreground">独立拆文库</div>
-        </div>
-        <div className="flex items-center gap-1">
-          <Button type="button" size="icon" variant="ghost" className="h-8 w-8" onClick={() => void handleImportDismantlingFiles()} disabled={importing} title="导入文件">
-            <FilePlus2 className="h-4 w-4" />
-          </Button>
-          <Button type="button" size="icon" variant="ghost" className="h-8 w-8" onClick={() => void handleImportDismantlingFolder()} disabled={importing} title="导入文件夹">
-            <FolderPlus className="h-4 w-4" />
-          </Button>
-        </div>
-      </div>
-      {importStatus ? (
-        <div className="border-b px-3 py-2 text-xs text-muted-foreground">
-          {importStatus}
-        </div>
-      ) : null}
-      <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
-        {loading ? (
-          <div className="px-2 py-4 text-xs text-muted-foreground">正在读取拆文库...</div>
-        ) : library.projects.length === 0 ? (
-          <div className="rounded-lg border border-dashed p-4 text-xs leading-5 text-muted-foreground">还没有拆文作品,请使用上方按钮导入文件或文件夹。</div>
-        ) : library.projects.map((item) => (
-          <div
-            key={item.id}
-            className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left text-sm transition ${selectedDismantlingProjectId === item.id ? "border-primary bg-primary/10" : "bg-background hover:bg-muted"}`}
-          >
-            <button
-              type="button"
-              onClick={() => setSelectedDismantlingProjectId(item.id)}
-              className="flex min-w-0 flex-1 items-start gap-2 text-left"
-            >
-              <BookOpenCheck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <span className="min-w-0 flex-1">
-                <span className="block truncate font-medium">{item.title}</span>
-                <span className="mt-1 block text-xs text-muted-foreground">{item.chapters.length} 章 · {item.structureMemory.length} 条结构记忆</span>
-              </span>
-            </button>
-            <button
-              type="button"
-              onClick={() => void handleDeleteDismantlingProject(item)}
-              className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
-              title="删除拆文作品"
-              aria-label="删除拆文作品"
-              disabled={importing}
-            >
-              <Trash2 className="h-4 w-4" />
-            </button>
-          </div>
-        ))}
-      </div>
-      <RawSourcesSection onCancelExtraction={() => {}} />
-    </div>
-  )
-}
-
-void DismantlingSidebarPanel
-
 /** 剧情推演室侧边栏:头部标题+新建按钮 + 框架列表 */
 function StorySimulationSidebarPanel() {
   const projectPath = useWikiStore((s) => s.project?.path)

+ 0 - 87
src/components/novel/book-analysis-book-list.tsx

@@ -1,87 +0,0 @@
-import { BookOpen, CheckCircle2, Feather, Trash2 } from "lucide-react"
-import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
-
-interface BookAnalysisBookListProps {
-  books: BookAnalysisLibraryBook[]
-  selectedBookId: string | null
-  onSelectBook: (bookId: string) => void
-  onDeleteBook?: (bookId: string) => void
-}
-
-function styleStatusLabel(book: BookAnalysisLibraryBook): string {
-  if (book.styleStatus === "enabled") return "当前启用文风"
-  if (book.styleStatus === "available") return "可启用文风"
-  return "未提取文风"
-}
-
-export function BookAnalysisBookList({ books, selectedBookId, onSelectBook, onDeleteBook }: BookAnalysisBookListProps) {
-  return (
-    <aside className="flex min-h-0 w-72 shrink-0 flex-col border-r bg-background">
-      <div className="border-b px-4 py-3">
-        <div className="text-sm font-semibold">作品库</div>
-        <div className="mt-1 text-xs text-muted-foreground">已拆书 {books.length} 本</div>
-      </div>
-      <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
-        {books.length === 0 ? (
-          <div className="rounded-lg border border-dashed p-4 text-xs leading-5 text-muted-foreground">
-            还没有拆书作品。点击“导入小说”开始分析。
-          </div>
-        ) : (
-          books.map((book) => {
-            const selected = book.id === selectedBookId
-            return (
-              <div
-                key={book.id}
-                className={`w-full rounded-lg border px-3 py-2 transition ${
-                  selected ? "border-primary bg-primary/10" : "bg-background hover:bg-muted"
-                }`}
-              >
-                <div className="flex items-start gap-2">
-                  <button
-                    type="button"
-                    onClick={() => onSelectBook(book.id)}
-                    className="flex min-w-0 flex-1 items-start gap-2 text-left"
-                  >
-                    <BookOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-                    <div className="min-w-0 flex-1">
-                      <div className="truncate text-sm font-medium">{book.metadata.title}</div>
-                      {book.metadata.author && (
-                        <div className="mt-0.5 truncate text-xs text-muted-foreground">{book.metadata.author}</div>
-                      )}
-                      <div className="mt-1 text-xs text-muted-foreground">
-                        {book.metadata.totalChapters} 章 · {book.characters.length} 角色 · {book.skills.length} Skill
-                      </div>
-                      <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
-                        <span className={book.styleStatus === "enabled" ? "text-primary" : "text-muted-foreground"}>
-                          <Feather className="mr-1 inline h-3 w-3" />
-                          {styleStatusLabel(book)}
-                        </span>
-                        {book.boundAurasCount > 0 && (
-                          <span className="text-primary">
-                            <CheckCircle2 className="mr-1 inline h-3 w-3" />
-                            已绑定 {book.boundAurasCount}
-                          </span>
-                        )}
-                      </div>
-                    </div>
-                  </button>
-                  {onDeleteBook && (
-                    <button
-                      type="button"
-                      onClick={(e) => { e.stopPropagation(); onDeleteBook(book.id) }}
-                      className="mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
-                      title="删除作品"
-                      aria-label="删除作品"
-                    >
-                      <Trash2 className="h-3.5 w-3.5" />
-                    </button>
-                  )}
-                </div>
-              </div>
-            )
-          })
-        )}
-      </div>
-    </aside>
-  )
-}

+ 41 - 0
src/components/novel/book-analysis-character-panel.spec.tsx

@@ -111,4 +111,45 @@ describe("BookAnalysisCharacterPanel", () => {
     expect(container.textContent).not.toContain("绑定到小说人物")
     cleanup()
   })
+
+  it("角色列表按重要度降序排列,同重要度按主角/反派/配角排序", () => {
+    const base = book.characters[0]
+    const { container, cleanup } = renderPanel({
+      book: {
+        ...book,
+        characters: [
+          { ...base, id: "c-supporting", name: "配角乙", importance: 5, category: "supporting" },
+          { ...base, id: "c-villain", name: "反派甲", importance: 8, category: "antagonist" },
+          { ...base, id: "c-hero", name: "主角丙", importance: 8, category: "protagonist" },
+          { ...base, id: "c-minor", name: "路人丁", importance: 3, category: "minor" },
+        ],
+        skills: [],
+      },
+      selectedCharacterId: "c-minor",
+    })
+
+    const names = Array.from(container.querySelectorAll("button .truncate")).map((node) => node.textContent)
+    expect(names).toEqual(["主角丙", "反派甲", "配角乙", "路人丁"])
+    cleanup()
+  })
+
+  it("将 antagonist 显示为反派", () => {
+    const { container, cleanup } = renderPanel({
+      book: {
+        ...book,
+        characters: [{
+          ...book.characters[0],
+          id: "char-villain",
+          name: "反派甲",
+          category: "antagonist",
+        }],
+        skills: [],
+      },
+      selectedCharacterId: "char-villain",
+    })
+
+    expect(container.textContent).toMatch(/反派\s*·\s*重要度/)
+    expect(container.textContent).not.toContain("配角 · 重要度")
+    cleanup()
+  })
 })

+ 22 - 4
src/components/novel/book-analysis-character-panel.tsx

@@ -1,3 +1,4 @@
+import { useMemo } from "react"
 import { Plus, User } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
@@ -13,11 +14,18 @@ interface BookAnalysisCharacterPanelProps {
 
 const categoryLabels: Record<string, string> = {
   protagonist: "主角",
-  antagonist: "配角",
+  antagonist: "反派",
   supporting: "配角",
   minor: "次要配角",
 }
 
+const categoryOrder: Record<string, number> = {
+  protagonist: 0,
+  antagonist: 1,
+  supporting: 2,
+  minor: 3,
+}
+
 export function BookAnalysisCharacterPanel({
   book,
   selectedCharacterId,
@@ -26,7 +34,17 @@ export function BookAnalysisCharacterPanel({
   onSelectCharacter,
   onAddSelectedSkillsToSoul,
 }: BookAnalysisCharacterPanelProps) {
-  const selectedCharacter = book.characters.find((character) => character.id === selectedCharacterId) ?? book.characters[0] ?? null
+  const sortedCharacters = useMemo(
+    () =>
+      [...book.characters].sort(
+        (a, b) =>
+          b.importance - a.importance ||
+          (categoryOrder[a.category] ?? 99) - (categoryOrder[b.category] ?? 99) ||
+          a.name.localeCompare(b.name, "zh-CN"),
+      ),
+    [book.characters],
+  )
+  const selectedCharacter = sortedCharacters.find((character) => character.id === selectedCharacterId) ?? sortedCharacters[0] ?? null
   const selectedSkill = selectedCharacter
     ? book.skills.find((skill) => skill.characterId === selectedCharacter.id || skill.characterName === selectedCharacter.name) ?? null
     : null
@@ -61,10 +79,10 @@ export function BookAnalysisCharacterPanel({
       </div>
       <div className="grid min-h-0 flex-1" style={{ gridTemplateColumns: "minmax(220px, 320px) 1fr" }}>
         <div className="min-h-0 space-y-2 overflow-y-auto border-r p-3">
-          {book.characters.length === 0 ? (
+          {sortedCharacters.length === 0 ? (
             <div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">暂无角色数据。</div>
           ) : (
-            book.characters.map((character) => {
+            sortedCharacters.map((character) => {
               const active = selectedCharacter?.id === character.id
               const hasSkill = book.skills.some((skill) => skill.characterId === character.id || skill.characterName === character.name)
               return (

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

@@ -313,7 +313,7 @@ describe("BookAnalysisImportTaskPanel", () => {
 
     renderPanel([createTask("skipped")], props)
     expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual([
-      "收起导入任务",
+      "收起",
     ])
   })
 
@@ -360,19 +360,19 @@ describe("BookAnalysisImportTaskPanel", () => {
     const props = renderPanel([createTask("splitting")])
     expect(host.textContent).toContain("作品-task-splitting")
     const collapseButton = Array.from(host.querySelectorAll("button")).find(
-      (button) => button.textContent?.trim() === "收起导入任务",
+      (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("收起导入任务")
+    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("展开导入任务")
+    clickButton("展开")
     expect(props.onCollapsedChange).toHaveBeenLastCalledWith(false)
   })
 

+ 25 - 23
src/components/novel/book-analysis-import-task-panel.tsx

@@ -246,32 +246,34 @@ export function BookAnalysisImportTaskPanel({
 
   return (
     <section
-      className="flex min-h-0 max-h-[45%] flex-col overflow-hidden border-b bg-background px-5 py-3"
+      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 items-center justify-between gap-3">
-        <div className="min-w-0">
-          <h3 className="text-sm font-semibold">批量导入任务</h3>
-          <p className="mt-1 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 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>
-        <Button
-          size="sm"
-          variant="ghost"
-          aria-controls={taskListId}
-          aria-expanded={!collapsed}
-          onClick={() => onCollapsedChange(!collapsed)}
-        >
-          {collapsed ? <ChevronRight /> : <ChevronDown />}
-          {collapsed ? "展开导入任务" : "收起导入任务"}
-        </Button>
+        <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 && (

+ 15 - 1
src/components/novel/book-analysis-library-layout.tsx

@@ -2,7 +2,12 @@ import type { ReactNode } from "react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryState } from "@/lib/novel/book-analysis/library-state"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
-import type { AnalysisChunkRecord, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import type {
+  AnalysisChunkRecord,
+  AnalysisRuntimeProgress,
+  AnalysisSkill,
+  BookAnalysisPipelineTask,
+} from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { BookAnalysisActiveContext } from "./book-analysis-active-context"
 import { BookAnalysisModuleView } from "./book-analysis-module-view"
 
@@ -18,6 +23,7 @@ interface BookAnalysisLibraryLayoutProps {
   importTaskPanel?: ReactNode
   analysisTask?: BookAnalysisPipelineTask | null
   analysisChunks?: AnalysisChunkRecord[]
+  analysisProgresses?: Record<string, AnalysisRuntimeProgress>
   onSelectBook: (bookId: string) => void
   onSelectCharacter: (characterId: string) => void
   onImportNovel: () => void
@@ -30,9 +36,11 @@ interface BookAnalysisLibraryLayoutProps {
   onReextractCharacters: () => void
   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
 }
@@ -46,6 +54,7 @@ export function BookAnalysisLibraryLayout({
   storyFrameworks = [],
   analysisTask,
   analysisChunks = [],
+  analysisProgresses = {},
   onSelectCharacter,
   onCreateOutlineFromFramework,
   onToggleStyle,
@@ -56,9 +65,11 @@ export function BookAnalysisLibraryLayout({
   onExtractStyle,
   onReextractSkill,
   onConfigureAnalysisTask,
+  onSelectAnalysisCharacters,
   onPauseAnalysisTask,
   onContinueAnalysisTask,
   onRetryAnalysisTask,
+  onRetryAnalysisChunk,
   onCancelAnalysisTask,
 }: BookAnalysisLibraryLayoutProps) {
   const selectedBook = state.books.find((book) => book.id === selectedBookId) ?? state.books[0] ?? null
@@ -107,6 +118,7 @@ export function BookAnalysisLibraryLayout({
         book={selectedBook}
         task={analysisTask}
         chunks={analysisChunks}
+        progresses={analysisProgresses}
         selectedCharacterId={selectedCharacterId}
         storyContent={storyContent}
         extractingStyle={extractingStyle}
@@ -122,9 +134,11 @@ export function BookAnalysisLibraryLayout({
           else onExtractStyle()
         }}
         onConfigureTask={onConfigureAnalysisTask}
+        onSelectCharacters={onSelectAnalysisCharacters}
         onPauseTask={onPauseAnalysisTask}
         onContinueTask={onContinueAnalysisTask}
         onRetryTask={onRetryAnalysisTask}
+        onRetryChunk={onRetryAnalysisChunk}
         onCancelTask={onCancelAnalysisTask}
         />
       </main>

+ 285 - 2
src/components/novel/book-analysis-module-view.spec.tsx

@@ -109,6 +109,13 @@ describe("BookAnalysisModuleView 分析进度", () => {
             updatedAt: 1,
           },
         ]}
+        progresses={{
+          "task-1:characters:chunk-0001-0010": {
+            stageLabel: "识别角色中",
+            percentage: 42,
+            currentItem: "第一章",
+          },
+        }}
         selectedCharacterId={null}
         extractingStyle={false}
         addingToSoul={false}
@@ -119,9 +126,285 @@ describe("BookAnalysisModuleView 分析进度", () => {
       />,
     )
 
-    expect(html).toContain("正在进行:角色 Skill")
+    expect(html).toContain("分析任务进行中 · 当前:角色 Skill")
+    expect(html).toContain("最近范围:第 1~20 章 · 完成区块 0/2")
     expect(html).toContain("当前区块:第 1/2 个(第 1~10 章)")
-    expect(html).toContain("下一步:文风 Skill")
+    expect(html).toContain("角色 Skill · 分析中")
+    expect(html).toContain("文风 Skill · 待分析")
+    expect(html).toContain("识别角色中")
+    expect(html).toContain("42%")
+    expect(html).toContain("第一章")
+    expect(html).toContain("区块进度 0/2")
+    // (0 + 0.42) / 2 * 90 ≈ 19
+    expect(html).toContain("19%")
+    expect(html).toContain('role="progressbar"')
+    expect(html).not.toContain("完成区块 0/{total}")
     expect(html).not.toContain("未选择")
   })
+
+  it("全部区块完成后汇总阶段整体进度落在 90–100,不会钉死 100% 前就封顶失效", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={task}
+        chunks={[
+          {
+            version: 1,
+            id: "chunk-0001-0010",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-1"],
+            startOrder: 1,
+            endOrder: 10,
+            wordCount: 1000,
+            status: "completed",
+            attempts: 1,
+            resultPath: "a.result.json",
+            error: null,
+            startedAt: 1,
+            completedAt: 2,
+            updatedAt: 2,
+          },
+          {
+            version: 1,
+            id: "chunk-0011-0020",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-11"],
+            startOrder: 11,
+            endOrder: 20,
+            wordCount: 1000,
+            status: "completed",
+            attempts: 1,
+            resultPath: "b.result.json",
+            error: null,
+            startedAt: 1,
+            completedAt: 2,
+            updatedAt: 2,
+          },
+        ]}
+        progresses={{
+          "task-1:characters:aggregate": {
+            stageLabel: "正在合并角色候选…",
+            percentage: 93,
+          },
+        }}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+      />,
+    )
+
+    expect(html).toContain("正在合并角色候选…")
+    expect(html).toContain("区块进度 2/2")
+    expect(html).toContain("93%")
+    expect(html).not.toMatch(/aria-valuenow="100"/)
+  })
+
+  it("并发 running 区块的进度会累加进整体百分比", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={task}
+        chunks={[
+          {
+            version: 1,
+            id: "chunk-0001-0010",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-1"],
+            startOrder: 1,
+            endOrder: 10,
+            wordCount: 1000,
+            status: "running",
+            attempts: 1,
+            resultPath: null,
+            error: null,
+            startedAt: 1,
+            completedAt: null,
+            updatedAt: 1,
+          },
+          {
+            version: 1,
+            id: "chunk-0011-0020",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-11"],
+            startOrder: 11,
+            endOrder: 20,
+            wordCount: 1000,
+            status: "running",
+            attempts: 1,
+            resultPath: null,
+            error: null,
+            startedAt: 1,
+            completedAt: null,
+            updatedAt: 1,
+          },
+        ]}
+        progresses={{
+          "task-1:characters:chunk-0001-0010": { stageLabel: "区块 A", percentage: 50 },
+          "task-1:characters:chunk-0011-0020": { stageLabel: "区块 B", percentage: 50 },
+        }}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+      />,
+    )
+
+    // (0 + 0.5 + 0.5) / 2 * 90 = 45
+    expect(html).toContain("45%")
+  })
+
+  it("待选择角色时展示选型入口", () => {
+    const awaitingTask: BookAnalysisPipelineTask = {
+      ...task,
+      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"),
+      },
+    }
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={awaitingTask}
+        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("选择角色")
+    expect(html).toContain("取消任务")
+  })
+
+  it("角色识别中展示进度条", () => {
+    const awaitingTask: BookAnalysisPipelineTask = {
+      ...task,
+      status: "awaiting-character-selection",
+      currentSkill: null,
+      recognizedCharacters: undefined,
+      modules: {
+        characters: moduleState("characters", "pending"),
+        story: moduleState("story", "skipped"),
+        style: moduleState("style", "skipped"),
+      },
+    }
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={awaitingTask}
+        progresses={{
+          "task-1:characters:recognition": {
+            stageLabel: "读取章节中(3/20)",
+            percentage: 18,
+            currentItem: "第 3 章 · 风起",
+          },
+        }}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+        onCancelTask={vi.fn()}
+      />,
+    )
+
+    expect(html).toContain("正在识别角色")
+    expect(html).toContain("范围:第 3 章 · 风起")
+    expect(html).toContain("读取章节中(3/20)")
+    expect(html).toContain("18%")
+    expect(html).toContain('role="progressbar"')
+  })
+
+  it("暂停继续文案使用 currentSkill 计数而非当前 Tab", () => {
+    const pausedTask: BookAnalysisPipelineTask = {
+      ...task,
+      status: "paused",
+      currentSkill: "characters",
+    }
+    const html = renderToStaticMarkup(
+      <BookAnalysisModuleView
+        book={book}
+        task={pausedTask}
+        chunks={[
+          {
+            version: 1,
+            id: "chunk-0001-0010",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-1"],
+            startOrder: 1,
+            endOrder: 10,
+            wordCount: 1000,
+            status: "completed",
+            attempts: 1,
+            resultPath: "a.result.json",
+            error: null,
+            startedAt: 1,
+            completedAt: 2,
+            updatedAt: 2,
+          },
+          {
+            version: 1,
+            id: "chunk-0011-0020",
+            taskId: task.id,
+            skill: "characters",
+            chapterIds: ["ch-11"],
+            startOrder: 11,
+            endOrder: 20,
+            wordCount: 1000,
+            status: "pending",
+            attempts: 0,
+            resultPath: null,
+            error: null,
+            startedAt: null,
+            completedAt: null,
+            updatedAt: 1,
+          },
+        ]}
+        selectedCharacterId={null}
+        extractingStyle={false}
+        addingToSoul={false}
+        onSelectCharacter={vi.fn()}
+        onToggleStyle={vi.fn()}
+        onAddSelectedSkillsToSoul={vi.fn()}
+        onReextract={vi.fn()}
+        onContinueTask={vi.fn()}
+      />,
+    )
+
+    expect(html).toContain("从断点继续(已完成 1/2 区块)")
+  })
 })

+ 193 - 29
src/components/novel/book-analysis-module-view.tsx

@@ -1,7 +1,14 @@
 import { useState, type ReactNode } from "react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
-import type { AnalysisChunkRecord, AnalysisChunkStatus, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import {
+  analysisProgressKey,
+  type AnalysisChunkRecord,
+  type AnalysisChunkStatus,
+  type AnalysisRuntimeProgress,
+  type AnalysisSkill,
+  type BookAnalysisPipelineTask,
+} from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
 import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 
@@ -11,6 +18,7 @@ interface BookAnalysisModuleViewProps {
   book: BookAnalysisLibraryBook
   task?: BookAnalysisPipelineTask | null
   chunks?: AnalysisChunkRecord[]
+  progresses?: Record<string, AnalysisRuntimeProgress>
   selectedCharacterId: string | null
   storyContent?: ReactNode
   extractingStyle: boolean
@@ -23,6 +31,7 @@ interface BookAnalysisModuleViewProps {
   onOpenSkillSelection?: () => void
   onReextract: (skill: AnalysisSkill) => void
   onConfigureTask?: () => void
+  onSelectCharacters?: () => void
   onPauseTask?: () => void
   onContinueTask?: () => void
   onRetryTask?: () => void
@@ -30,6 +39,34 @@ interface BookAnalysisModuleViewProps {
   onCancelTask?: () => void
 }
 
+function RuntimeProgressBar({ progress, label }: { progress: AnalysisRuntimeProgress; label?: string }) {
+  const percentage = Math.max(0, Math.min(100, progress.percentage))
+  return (
+    <div className="mt-2 space-y-1.5" aria-label={label ?? "分析进度"}>
+      <div className="flex items-center justify-between gap-2 text-xs">
+        <span className="truncate text-foreground">{progress.stageLabel}</span>
+        <span className="shrink-0 text-muted-foreground">{percentage}%</span>
+      </div>
+      <div
+        role="progressbar"
+        aria-valuemin={0}
+        aria-valuemax={100}
+        aria-valuenow={percentage}
+        aria-label={progress.stageLabel}
+        className="h-2 overflow-hidden rounded-full bg-secondary"
+      >
+        <div
+          className="h-full bg-primary transition-all duration-300"
+          style={{ width: `${percentage}%` }}
+        />
+      </div>
+      {progress.currentItem && (
+        <div className="truncate text-xs text-muted-foreground">{progress.currentItem}</div>
+      )}
+    </div>
+  )
+}
+
 const TABS: Array<{ id: BookAnalysisModuleTab; label: string }> = [
   { id: "characters", label: "角色 Skill" },
   { id: "story", label: "故事 Skill" },
@@ -83,6 +120,8 @@ function taskStatusLabel(status: BookAnalysisPipelineTask["status"]): string {
       return "已完成"
     case "awaiting-range":
       return "待选择章节"
+    case "awaiting-character-selection":
+      return "待选择角色"
     default:
       return status
   }
@@ -117,6 +156,52 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
     : 0
   const currentSkill = props.task?.currentSkill
   const taskBusy = props.task && ["queued", "running", "paused", "failed"].includes(props.task.status)
+  const progresses = props.progresses ?? {}
+  const currentSkillChunks = props.task && currentSkill
+    ? (props.chunks ?? [])
+        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === currentSkill)
+        .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)
+    : 0
+  const phaseRuntimeProgress = (() => {
+    if (!props.task || !currentSkill) return null
+    for (const phase of ["aggregate", "publish"] as const) {
+      const phaseProgress = progresses[analysisProgressKey(props.task.id, currentSkill, phase)]
+      if (phaseProgress) return phaseProgress
+    }
+    return null
+  })()
+  const runningChunkProgresses = props.task && currentSkill
+    ? currentSkillChunks
+        .filter((chunk) => chunk.status === "running")
+        .map((chunk) => progresses[analysisProgressKey(props.task!.id, currentSkill, 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}:`
+      return Object.entries(progresses).find(([key]) => key.startsWith(prefix))?.[1] ?? null
+    })()
+  // chunk 阶段占 0–90%;aggregate/publish 直接使用 phase 百分比(适配器报 92–100)
+  const overallPercentage = (() => {
+    if (phaseRuntimeProgress) {
+      return Math.max(90, Math.min(100, Math.round(phaseRuntimeProgress.percentage)))
+    }
+    if (currentSkillTotal <= 0) return activeRuntimeProgress?.percentage ?? 0
+    const runningShare = runningChunkProgresses.reduce(
+      (sum, progress) => sum + Math.max(0, Math.min(100, progress.percentage)) / 100,
+      0,
+    )
+    return Math.min(
+      90,
+      Math.round(((currentSkillCompleted + runningShare) / currentSkillTotal) * 90),
+    )
+  })()
 
   const filteredEvidence = evidenceFilter === "all"
     ? props.book.evidence
@@ -175,19 +260,46 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                 失败原因:{props.task.error}
               </div>
             )}
+            {props.task.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>
+                    区块进度 {currentSkillCompleted}/{currentSkillTotal || "?"}
+                    {activeRuntimeProgress ? ` · ${activeRuntimeProgress.stageLabel}` : ""}
+                  </span>
+                  <span>{overallPercentage}%</span>
+                </div>
+                <div
+                  role="progressbar"
+                  aria-valuemin={0}
+                  aria-valuemax={100}
+                  aria-valuenow={overallPercentage}
+                  aria-label="任务整体进度"
+                  className="h-2 overflow-hidden rounded-full bg-secondary"
+                >
+                  <div
+                    className="h-full bg-primary transition-all duration-300"
+                    style={{ width: `${overallPercentage}%` }}
+                  />
+                </div>
+                {activeRuntimeProgress?.currentItem && (
+                  <div className="truncate text-xs text-muted-foreground">{activeRuntimeProgress.currentItem}</div>
+                )}
+              </div>
+            )}
             <div className="mt-3 flex flex-wrap gap-2">
               {props.task.status === "running" && props.onPauseTask && (
                 <Button size="sm" variant="outline" onClick={props.onPauseTask}>暂停</Button>
               )}
               {props.task.status === "paused" && props.onContinueTask && (
                 <Button size="sm" onClick={props.onContinueTask}>
-                  从断点继续(已完成 {completed}/{total || "?"} 区块)
+                  从断点继续(已完成 {currentSkillCompleted}/{currentSkillTotal || "?"} 区块)
                 </Button>
               )}
               {props.task.status === "failed" && props.onRetryTask && (
                 <Button size="sm" onClick={props.onRetryTask}>重试当前步骤</Button>
               )}
-              {["queued", "running", "paused", "failed"].includes(props.task.status) && props.onCancelTask && (
+              {["awaiting-character-selection", "queued", "running", "paused", "failed"].includes(props.task.status) && props.onCancelTask && (
                 <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
               )}
             </div>
@@ -204,6 +316,42 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
           </div>
         )}
 
+        {props.task?.status === "awaiting-character-selection" && (() => {
+          const recognitionProgress = progresses[analysisProgressKey(props.task.id, "characters", "recognition")]
+          const hasRecognized = (props.task.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">
+                <div className="min-w-0 flex-1">
+                  <div className="text-sm font-medium">
+                    {hasRecognized ? "待选择角色" : "正在识别角色"}
+                  </div>
+                  <div className="mt-1 text-xs text-muted-foreground">
+                    {hasRecognized
+                      ? `已识别 ${props.task.recognizedCharacters!.length} 个角色,请勾选后开始深度分析`
+                      : recognitionProgress
+                        ? (recognitionProgress.currentItem
+                          ? `范围:${recognitionProgress.currentItem}`
+                          : "识别进行中…")
+                        : "准备识别角色…"}
+                  </div>
+                  {recognitionProgress && !hasRecognized && (
+                    <RuntimeProgressBar progress={recognitionProgress} label="角色识别进度" />
+                  )}
+                </div>
+                <div className="flex shrink-0 flex-wrap gap-2">
+                  {props.onSelectCharacters && hasRecognized && (
+                    <Button size="sm" onClick={props.onSelectCharacters}>选择角色</Button>
+                  )}
+                  {props.onCancelTask && (
+                    <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
+                  )}
+                </div>
+              </div>
+            </div>
+          )
+        })()}
+
         {skill && (
           <div className="mb-4 space-y-3 border-b pb-3">
             <div className="flex items-start justify-between gap-3">
@@ -212,7 +360,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                 {moduleState && (
                   <div className="mt-1">
                     最近范围:第 {moduleState.range.startOrder}~{moduleState.range.endOrder} 章
-                    {total > 0 ? ` · 完成区块 ${completed}/{total}` : ""}
+                    {total > 0 ? ` · 完成区块 ${completed}/${total}` : ""}
                   </div>
                 )}
                 {moduleState?.summary && <div className="mt-1 break-words">{moduleState.summary}</div>}
@@ -226,6 +374,9 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                     当前区块:第 {tabActiveChunkIndex}/{skillChunks.length} 个(第 {tabActiveChunk.startOrder}~{tabActiveChunk.endOrder} 章)
                   </div>
                 )}
+                {skill && currentSkill === skill && activeRuntimeProgress && (
+                  <RuntimeProgressBar progress={activeRuntimeProgress} label={`${SKILL_LABELS[skill]} 当前阶段`} />
+                )}
               </div>
               <Button variant="outline" size="sm" onClick={() => props.onReextract(skill)}>重新提取</Button>
             </div>
@@ -237,33 +388,46 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
                   {moduleState?.resultPath ? " · 结果已发布" : ""}
                 </div>
                 <div className="space-y-1.5">
-                  {skillChunks.map((chunk) => (
-                    <div
-                      key={chunk.id}
-                      className="flex flex-wrap items-center justify-between gap-2 rounded border bg-background px-2.5 py-1.5 text-xs"
-                    >
-                      <div className="min-w-0">
-                        <span className="font-medium">第 {chunk.startOrder}~{chunk.endOrder} 章</span>
-                        <span className="ml-2 text-muted-foreground">
-                          {CHUNK_STATUS_LABELS[chunk.status]}
-                          {chunk.attempts > 0 ? ` · 尝试 ${chunk.attempts}` : ""}
-                        </span>
-                        {chunk.error && (
-                          <div className="mt-0.5 break-words text-destructive">{chunk.error}</div>
+                  {skillChunks.map((chunk) => {
+                    const chunkProgress = props.task
+                      ? progresses[analysisProgressKey(props.task.id, skill, chunk.id)]
+                      : undefined
+                    return (
+                      <div
+                        key={chunk.id}
+                        className="rounded border bg-background px-2.5 py-1.5 text-xs"
+                      >
+                        <div className="flex flex-wrap items-center justify-between gap-2">
+                          <div className="min-w-0">
+                            <span className="font-medium">第 {chunk.startOrder}~{chunk.endOrder} 章</span>
+                            <span className="ml-2 text-muted-foreground">
+                              {CHUNK_STATUS_LABELS[chunk.status]}
+                              {chunk.attempts > 0 ? ` · 尝试 ${chunk.attempts}` : ""}
+                            </span>
+                            {chunk.error && (
+                              <div className="mt-0.5 break-words text-destructive">{chunk.error}</div>
+                            )}
+                          </div>
+                          {chunk.status === "failed" && props.onRetryChunk && (
+                            <Button
+                              size="sm"
+                              variant="outline"
+                              className="h-7"
+                              onClick={() => props.onRetryChunk?.(skill, chunk.id)}
+                            >
+                              重试此区块
+                            </Button>
+                          )}
+                        </div>
+                        {chunk.status === "running" && chunkProgress && (
+                          <RuntimeProgressBar
+                            progress={chunkProgress}
+                            label={`第 ${chunk.startOrder}~${chunk.endOrder} 章进度`}
+                          />
                         )}
                       </div>
-                      {chunk.status === "failed" && props.onRetryChunk && (
-                        <Button
-                          size="sm"
-                          variant="outline"
-                          className="h-7"
-                          onClick={() => props.onRetryChunk?.(skill, chunk.id)}
-                        >
-                          重试此区块
-                        </Button>
-                      )}
-                    </div>
-                  ))}
+                    )
+                  })}
                 </div>
               </div>
             )}

+ 2 - 16
src/components/novel/dismantling-view.spec.ts → src/components/novel/book-analysis-routing.spec.ts

@@ -1,10 +1,10 @@
-import { readFileSync } from "node:fs"
+import { readFileSync } from "node:fs"
 import { resolve } from "node:path"
 import { describe, expect, it } from "vitest"
 
 const root = resolve(__dirname, "../../..")
 
-describe("dismantling library implementation", () => {
+describe("book analysis routing", () => {
   it("keeps the book analysis routing connected", () => {
     const storeSource = readFileSync(resolve(root, "src/stores/wiki-store.ts"), "utf8")
     const sidebarSource = readFileSync(resolve(root, "src/components/layout/icon-sidebar.tsx"), "utf8")
@@ -16,18 +16,4 @@ describe("dismantling library implementation", () => {
     expect(contentSource).toContain("BookAnalysisView")
     expect(contentSource).toContain("@/components/novel/book-analysis-view")
   })
-
-  it("keeps the underlying dismantling implementation intact", () => {
-    const viewSource = readFileSync(resolve(root, "src/components/novel/dismantling-view.tsx"), "utf8")
-
-    expect(viewSource).toContain("拆文结果")
-  })
-
-  it("keeps the dismantling sidebar helpers intact", () => {
-    const sidebarSource = readFileSync(resolve(root, "src/components/layout/sidebar-panel.tsx"), "utf8")
-
-    expect(sidebarSource).toContain("正在提取章节")
-    expect(sidebarSource).toContain("已存在相同拆文作品")
-    expect(sidebarSource).toContain("normalizeDismantlingProjectTitle")
-  })
 })

+ 216 - 38
src/components/novel/book-analysis-view.tsx

@@ -36,15 +36,22 @@ import {
 import { loadPlotFrameworkLibrary, upsertPlotFramework } from "@/lib/novel/plot-framework-library"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
-import type { AnalysisSkill } from "@/lib/novel/book-analysis/analysis-pipeline-types"
-import type { AnalysisChapterRange } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import {
+  analysisProgressKey,
+  type AnalysisChapterRange,
+  type AnalysisSkill,
+} from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { loadChapterList } from "@/lib/novel/book-analysis/analysis-engine"
 import { BookAnalysisRunDialog } from "./book-analysis-run-dialog"
 import { BookAnalysisCharacterSkillDialog } from "./book-analysis-character-skill-dialog"
+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 { readFile } from "@/commands/fs"
+import { joinPath } from "@/lib/path-utils"
+import { saveRecognizedCharacters } from "@/lib/novel/book-analysis/recognized-character-store"
 
 interface StoryFrameworkSelectionData {
   book: BookAnalysisLibraryBook
@@ -101,6 +108,11 @@ export function BookAnalysisView() {
   } | null>(null)
   const [characterSkillDialogOpen, setCharacterSkillDialogOpen] = useState(false)
   const [characterSkillGenerating, setCharacterSkillGenerating] = useState(false)
+  const [pipelineCharacterPicker, setPipelineCharacterPicker] = useState<{
+    taskId: string
+    selectedIds: string[]
+  } | null>(null)
+  const [pipelineCharacterConfirming, setPipelineCharacterConfirming] = useState(false)
 
   const currentProject = useWikiStore((s) => s.project)
   const storeSelectedBookId = useBookAnalysisStore((s) => s.selectedLibraryBookId)
@@ -127,9 +139,14 @@ export function BookAnalysisView() {
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const pipelineTasks = useBookAnalysisPipelineStore((s) => s.tasks)
   const pipelineChunks = useBookAnalysisPipelineStore((s) => s.chunks)
+  const pipelineProgresses = useBookAnalysisPipelineStore((s) => s.progresses)
   const initializePipelineProject = useBookAnalysisPipelineStore((s) => s.initializeProject)
   const createAwaitingRangeTask = useBookAnalysisPipelineStore((s) => s.createAwaitingRangeTask)
   const configureTaskRange = useBookAnalysisPipelineStore((s) => s.configureTaskRange)
+  const setTaskRecognizedCharacters = useBookAnalysisPipelineStore((s) => s.setTaskRecognizedCharacters)
+  const failPipelineTask = useBookAnalysisPipelineStore((s) => s.failTask)
+  const confirmCharacterSelection = useBookAnalysisPipelineStore((s) => s.confirmCharacterSelection)
+  const setRuntimeProgress = useBookAnalysisPipelineStore((s) => s.setRuntimeProgress)
   const startPipelineTask = useBookAnalysisPipelineStore((s) => s.startTask)
   const pausePipelineTask = useBookAnalysisPipelineStore((s) => s.pauseTask)
   const continuePipelineTask = useBookAnalysisPipelineStore((s) => s.continueTask)
@@ -583,6 +600,106 @@ export function BookAnalysisView() {
     })
   }, [selectedLibraryBook, selectedPipelineTask])
 
+  const openPipelineCharacterPicker = useCallback((taskId: string, characters: RecognizedCharacter[]) => {
+    const defaultIds = characters
+      .filter((character) => character.category === "主角" || character.category === "配角")
+      .map((character) => character.id)
+    setPipelineCharacterPicker({
+      taskId,
+      selectedIds: defaultIds.length > 0 ? defaultIds : characters.map((character) => character.id),
+    })
+  }, [])
+
+  const startPipelineWithErrorToast = useCallback((taskId: string) => {
+    void startPipelineTask(taskId).catch((error) => {
+      toast.error(`分析失败:${error instanceof Error ? error.message : String(error)}`)
+    })
+  }, [startPipelineTask])
+
+  const recognizeCharactersForPipelineTask = useCallback(async (
+    taskId: string,
+    bookPath: string,
+    range: AnalysisChapterRange,
+    chapters: Awaited<ReturnType<typeof loadChapterList>>,
+  ) => {
+    const progressKey = analysisProgressKey(taskId, "characters", "recognition")
+    const reportRecognition = (progress: { stageLabel: string; percentage: number; currentItem?: string }) => {
+      setRuntimeProgress(progressKey, progress)
+    }
+    toast.info("正在识别角色,完成后请勾选要深度分析的角色…")
+    reportRecognition({ stageLabel: "准备识别角色", percentage: 2 })
+    try {
+      if (!hasUsableLlm(llmConfig, providerConfigs)) {
+        throw new Error("未配置可用的模型,请先在设置中配置 LLM,再识别角色")
+      }
+      const selectedChapters = chapters
+        .filter((chapter) => chapter.order >= range.startOrder && chapter.order <= range.endOrder)
+        .sort((left, right) => left.order - right.order)
+      if (selectedChapters.length === 0) {
+        throw new Error("所选章节范围内没有可用章节")
+      }
+      const totalChapters = selectedChapters.length
+      const chapterContents: { index: number; content: string }[] = []
+      reportRecognition({
+        stageLabel: `读取章节中(0/${totalChapters})`,
+        percentage: 5,
+        currentItem: `第 ${range.startOrder}~${range.endOrder} 章,共 ${totalChapters} 章`,
+      })
+      // 让出一帧,避免读章过快时 UI 直接跳到 AI 阶段、看不到 x/y
+      await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+      for (let i = 0; i < totalChapters; i++) {
+        const chapter = selectedChapters[i]
+        const chapterPath = joinPath(bookPath, "chapters", `${chapter.chapterId}.md`)
+        const raw = await readFile(chapterPath)
+        const body = raw.replace(/^---[\s\S]*?---\n/, "")
+        chapterContents.push({ index: i, content: body.slice(0, 4000) })
+        const readCount = i + 1
+        reportRecognition({
+          stageLabel: `读取章节中(${readCount}/${totalChapters})`,
+          percentage: 5 + Math.floor((readCount / totalChapters) * 40),
+          currentItem: `第 ${chapter.order} 章 · ${chapter.title}`,
+        })
+        await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+      }
+      reportRecognition({
+        stageLabel: `正在用 AI 识别角色(已读 ${totalChapters}/${totalChapters} 章)`,
+        percentage: 55,
+        currentItem: `第 ${range.startOrder}~${range.endOrder} 章,共 ${totalChapters} 章`,
+      })
+      const { llmRecognizeCharacters } = await import(
+        "@/lib/novel/book-analysis/character-llm-recognizer"
+      )
+      const recognized = await llmRecognizeCharacters({
+        chapters: chapterContents,
+        llmConfig,
+        sourceBook: bookPath,
+      })
+      if (recognized.length === 0) {
+        throw new Error("AI 没有识别出角色,请确认所选章节包含人物,或更换模型后重试")
+      }
+      reportRecognition({
+        stageLabel: `保存识别结果(已读 ${totalChapters}/${totalChapters} 章)`,
+        percentage: 90,
+        currentItem: `${recognized.length} 个角色`,
+      })
+      await saveRecognizedCharacters(bookPath, recognized)
+      await setTaskRecognizedCharacters(taskId, recognized)
+      reportRecognition({
+        stageLabel: `识别出 ${recognized.length} 个角色(已读 ${totalChapters}/${totalChapters} 章)`,
+        percentage: 100,
+      })
+      openPipelineCharacterPicker(taskId, recognized)
+      toast.success(`识别完成:共 ${recognized.length} 个角色,请选择后开始深度分析`)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      notifiedPipelineTaskIdsRef.current.add(`${taskId}:failed`)
+      await failPipelineTask(taskId, message).catch(() => undefined)
+      toast.error(`角色识别失败:${message}`)
+    } finally {
+      setRuntimeProgress(progressKey, null)
+    }
+  }, [failPipelineTask, llmConfig, openPipelineCharacterPicker, providerConfigs, setRuntimeProgress, setTaskRecognizedCharacters])
+
   const runImportTaskAction = useCallback((action: () => Promise<void>, failureMessage: string) => {
     void action().catch((error) => {
       console.error(failureMessage, error)
@@ -660,6 +777,7 @@ export function BookAnalysisView() {
       }
       analysisTask={selectedPipelineTask}
       analysisChunks={pipelineChunks.filter((chunk) => chunk.taskId === selectedPipelineTask?.id)}
+      analysisProgresses={pipelineProgresses}
       onSelectBook={handleSelectBook}
       onSelectCharacter={setSelectedCharacterId}
       onImportNovel={() => setInputDialogOpen(true)}
@@ -672,8 +790,25 @@ export function BookAnalysisView() {
       onReextractCharacters={handleLibraryReextractCharacters}
       onReextractSkill={openPipelineDialog}
       onConfigureAnalysisTask={openExistingPipelineDialog}
-      onPauseAnalysisTask={selectedPipelineTask ? () => void pausePipelineTask(selectedPipelineTask.id) : undefined}
-      onContinueAnalysisTask={selectedPipelineTask ? () => void continuePipelineTask(selectedPipelineTask.id) : undefined}
+      onSelectAnalysisCharacters={
+        selectedPipelineTask?.status === "awaiting-character-selection"
+          && (selectedPipelineTask.recognizedCharacters?.length ?? 0) > 0
+          ? () => openPipelineCharacterPicker(
+              selectedPipelineTask.id,
+              selectedPipelineTask.recognizedCharacters ?? [],
+            )
+          : 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
@@ -682,7 +817,16 @@ export function BookAnalysisView() {
           : continuePipelineTask(selectedPipelineTask.id)
         void retry.catch((error) => toast.error(`重试失败:${error instanceof Error ? error.message : String(error)}`))
       } : undefined}
-      onCancelAnalysisTask={selectedPipelineTask ? () => void cancelPipelineTask(selectedPipelineTask.id) : undefined}
+      onRetryAnalysisChunk={selectedPipelineTask ? (skill, chunkId) => {
+        void retryPipelineChunk(selectedPipelineTask.id, skill, chunkId).catch((error) => {
+          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 && (
@@ -694,41 +838,20 @@ export function BookAnalysisView() {
         initialRange={pipelineDialog.initialRange}
         onOpenChange={(open) => !open && setPipelineDialog(null)}
         onSubmit={async ({ range, selectedSkills }) => {
-          await configureTaskRange(pipelineDialog.taskId, range, selectedSkills)
+          const taskId = pipelineDialog.taskId
+          const chapters = pipelineDialog.chapters
+          const bookPath = useBookAnalysisPipelineStore.getState().tasks.find((t) => t.id === taskId)?.bookPath
+          await configureTaskRange(taskId, range, selectedSkills)
           setPipelineDialog(null)
-          void startPipelineTask(pipelineDialog.taskId).then(async () => {
-            await reloadLibraryState()
-            const completedTask = useBookAnalysisPipelineStore.getState().tasks.find(t => t.id === pipelineDialog.taskId)
-            if (completedTask?.status === "completed") {
-              if (selectedSkills.includes("characters")) {
-                toast.success("角色信息已提取完成,可选择角色生成 Skill。", {
-                  persistent: true,
-                  action: { label: "打开角色信息", onClick: handleOpenCharacterSkillSelection },
-                })
-              } else {
-                toast.success("拆书任务已完成,结果已更新。", {
-                  persistent: true,
-                  action: {
-                    label: "查看结果",
-                    onClick: async () => {
-                      if (completedTask) {
-                        const loadedResult = await loadBookAnalysisResult(completedTask.projectPath, completedTask.bookId)
-                        if (loadedResult) {
-                          useBookAnalysisStore.getState().setCurrentResult(loadedResult)
-                        }
-                        setViewingResultPath(completedTask.projectPath)
-                      }
-                      setShowResultViewer(true)
-                    },
-                  },
-                })
-              }
-            } else if (completedTask?.status === "failed") {
-              toast.error(`分析失败:${completedTask.error || "未知错误"}`)
+          if (selectedSkills.includes("characters")) {
+            if (!bookPath) {
+              toast.error("未找到分析任务作品路径")
+              return
             }
-          }).catch((error) => {
-            toast.error(`分析失败:${error instanceof Error ? error.message : String(error)}`)
-          })
+            await recognizeCharactersForPipelineTask(taskId, bookPath, range, chapters)
+            return
+          }
+          startPipelineWithErrorToast(taskId)
         }}
       />
     )}
@@ -741,6 +864,61 @@ export function BookAnalysisView() {
         onSubmit={handleGenerateSelectedCharacterSkills}
       />
     )}
+    {(() => {
+      const pickerTask = pipelineCharacterPicker
+        ? pipelineTasks.find((task) => task.id === pipelineCharacterPicker.taskId)
+        : null
+      const pickerCharacters = pickerTask?.recognizedCharacters ?? []
+      if (!pipelineCharacterPicker || pickerCharacters.length === 0) return null
+      return (
+        <CharacterSelectionPanel
+          characters={pickerCharacters}
+          selectedIds={pipelineCharacterPicker.selectedIds}
+          onToggle={(id) => {
+            setPipelineCharacterPicker((current) => {
+              if (!current) return current
+              const selected = current.selectedIds.includes(id)
+                ? current.selectedIds.filter((item) => item !== id)
+                : [...current.selectedIds, id]
+              return { ...current, selectedIds: selected }
+            })
+          }}
+          onSelectAllMain={() => {
+            setPipelineCharacterPicker((current) => {
+              if (!current) return current
+              return {
+                ...current,
+                selectedIds: pickerCharacters
+                  .filter((character) => character.category === "主角" || character.category === "配角")
+                  .map((character) => character.id),
+              }
+            })
+          }}
+          onClear={() => {
+            setPipelineCharacterPicker((current) => current ? { ...current, selectedIds: [] } : current)
+          }}
+          onCancel={() => setPipelineCharacterPicker(null)}
+          onClose={() => setPipelineCharacterPicker(null)}
+          confirming={pipelineCharacterConfirming}
+          onConfirm={() => {
+            const taskId = pipelineCharacterPicker.taskId
+            const selectedIds = pipelineCharacterPicker.selectedIds
+            setPipelineCharacterConfirming(true)
+            void confirmCharacterSelection(taskId, selectedIds)
+              .then(() => {
+                setPipelineCharacterPicker(null)
+                startPipelineWithErrorToast(taskId)
+              })
+              .catch((error) => {
+                toast.error(`启动深度分析失败:${error instanceof Error ? error.message : String(error)}`)
+              })
+              .finally(() => {
+                setPipelineCharacterConfirming(false)
+              })
+          }}
+        />
+      )
+    })()}
     </>
   )
 

+ 0 - 107
src/components/novel/character-profile.tsx

@@ -1,107 +0,0 @@
-import { useEffect, useState } from "react"
-import { useTranslation } from "react-i18next"
-import { User, Loader2, MapPin, Swords, Package } from "lucide-react"
-import { loadCharacterStates, type CharacterState } from "@/lib/novel/character-state"
-import { loadCognitionState, type CognitionState } from "@/lib/novel/character-cognition"
-
-export function CharacterProfile({ characterName, projectPath }: { characterName: string; projectPath: string }) {
-  const { t } = useTranslation()
-  const [charState, setCharState] = useState<CharacterState | null>(null)
-  const [cognition, setCognition] = useState<CognitionState | null>(null)
-  const [loading, setLoading] = useState(true)
-
-  useEffect(() => {
-    setLoading(true)
-    Promise.all([
-      loadCharacterStates(projectPath),
-      loadCognitionState(projectPath),
-    ]).then(([chars, cog]) => {
-      setCharState(chars.characters.find(c => c.characterName === characterName) ?? null)
-      setCognition(cog)
-    }).catch(() => {
-      setCharState(null)
-      setCognition(null)
-    }).finally(() => setLoading(false))
-  }, [characterName, projectPath])
-
-  const charCognition = cognition?.characters.find(c => c.character === characterName)
-
-  if (loading) {
-    return (
-      <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
-        <Loader2 className="h-4 w-4 animate-spin" />
-      </div>
-    )
-  }
-
-  return (
-    <div className="space-y-4 p-3 text-sm">
-      <div className="flex items-center gap-2">
-        <User className="h-5 w-5 text-primary" />
-        <h3 className="text-lg font-semibold">{characterName}</h3>
-      </div>
-
-      {charState ? (
-        <div className="space-y-3">
-          <div className="grid grid-cols-2 gap-2">
-            <div className="rounded-md border p-2">
-              <div className="flex items-center gap-1 text-xs text-muted-foreground">
-                <MapPin className="h-3 w-3" />
-                {t("novel.character.location")}
-              </div>
-              <p className="mt-1 font-medium">{charState.currentLocation || t("novel.character.unknown")}</p>
-            </div>
-            <div className="rounded-md border p-2">
-              <div className="text-xs text-muted-foreground">{t("novel.character.status")}</div>
-              <p className="mt-1 font-medium">{charState.status || t("novel.character.unknown")}</p>
-            </div>
-          </div>
-
-          {charState.equipment.length > 0 && (
-            <div className="rounded-md border p-2">
-              <div className="flex items-center gap-1 text-xs text-muted-foreground">
-                <Package className="h-3 w-3" />
-                {t("novel.character.equipment")}
-              </div>
-              <p className="mt-1">{charState.equipment.join("、")}</p>
-            </div>
-          )}
-
-          {charState.abilities.length > 0 && (
-            <div className="rounded-md border p-2">
-              <div className="flex items-center gap-1 text-xs text-muted-foreground">
-                <Swords className="h-3 w-3" />
-                {t("novel.character.abilities")}
-              </div>
-              <p className="mt-1">{charState.abilities.join("、")}</p>
-            </div>
-          )}
-        </div>
-      ) : (
-        <p className="text-sm text-muted-foreground">{t("novel.character.noStateData")}</p>
-      )}
-
-      {charCognition ? (
-        <div className="space-y-2">
-          <h4 className="text-xs font-semibold text-muted-foreground">{t("novel.cognition.title")}</h4>
-          {charCognition.knows.length > 0 && (
-            <div className="rounded-md border border-green-200 bg-green-50 p-2 dark:border-green-800 dark:bg-green-950">
-              <p className="text-xs font-medium text-green-700 dark:text-green-400">{t("novel.cognition.knows")}</p>
-              <ul className="mt-1 list-inside list-disc text-xs text-muted-foreground">
-                {charCognition.knows.map((item, i) => <li key={i}>{item}</li>)}
-              </ul>
-            </div>
-          )}
-          {charCognition.doesNotKnow.length > 0 && (
-            <div className="rounded-md border border-red-200 bg-red-50 p-2 dark:border-red-800 dark:bg-red-950">
-              <p className="text-xs font-medium text-red-700 dark:text-red-400">{t("novel.cognition.doesNotKnow")}</p>
-              <ul className="mt-1 list-inside list-disc text-xs text-muted-foreground">
-                {charCognition.doesNotKnow.map((item, i) => <li key={i}>{item}</li>)}
-              </ul>
-            </div>
-          )}
-        </div>
-      ) : null}
-    </div>
-  )
-}

+ 47 - 1
src/components/novel/character-selection-panel.spec.tsx

@@ -117,7 +117,7 @@ describe("CharacterSelectionPanel", () => {
     cleanup()
   })
 
-  it("不再显示简单提取和六维提取入口", async () => {
+  it("未提供 onConfirm 时不显示深度分析按钮", async () => {
     const { cleanup } = renderPanel({
       characters,
       selectedIds: ["1"],
@@ -128,11 +128,57 @@ describe("CharacterSelectionPanel", () => {
     })
     await flushAsync()
     const html = getAllBodyHtml()
+    expect(html).not.toContain("开始深度分析")
     expect(html).not.toContain("深度 6 维提取")
     expect(html).not.toContain("简单提取")
     cleanup()
   })
 
+  it("提供 onConfirm 时显示开始深度分析,未选角色时禁用", async () => {
+    const onConfirm = vi.fn()
+    const { cleanup } = renderPanel({
+      characters,
+      selectedIds: [],
+      onToggle: vi.fn(),
+      onSelectAllMain: vi.fn(),
+      onClear: vi.fn(),
+      onCancel: vi.fn(),
+      onConfirm,
+    })
+    await flushAsync()
+    const btn = document.body.querySelector(
+      '[data-testid="confirm-character-selection"]',
+    ) as HTMLButtonElement | null
+    expect(btn).toBeTruthy()
+    expect(btn?.textContent).toContain("开始深度分析")
+    expect(btn?.disabled).toBe(true)
+    cleanup()
+  })
+
+  it("已选角色时可点击开始深度分析", async () => {
+    const onConfirm = vi.fn()
+    const { cleanup } = renderPanel({
+      characters,
+      selectedIds: ["1"],
+      onToggle: vi.fn(),
+      onSelectAllMain: vi.fn(),
+      onClear: vi.fn(),
+      onCancel: vi.fn(),
+      onConfirm,
+    })
+    await flushAsync()
+    const btn = document.body.querySelector(
+      '[data-testid="confirm-character-selection"]',
+    ) as HTMLButtonElement
+    expect(btn.disabled).toBe(false)
+    await act(async () => {
+      btn.click()
+      await new Promise((r) => setTimeout(r, 0))
+    })
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+    cleanup()
+  })
+
   it("点击全选主角配角回调被调用", async () => {
     const onSelectAllMain = vi.fn()
     const { cleanup } = renderPanel({

+ 17 - 1
src/components/novel/character-selection-panel.tsx

@@ -22,6 +22,10 @@ export interface CharacterSelectionPanelProps {
   onCancel: () => void
   /** 关闭弹窗(X / 返回):应回到章节选择页,而不是取消整个任务。默认回退到 onCancel。 */
   onClose?: () => void
+  /** 确认勾选并开始深度分析 */
+  onConfirm?: () => void
+  confirmLabel?: string
+  confirming?: boolean
   // 受控搜索词和排序(默认内部 state)
   search?: string
   sortBy?: "importance" | "appearances"
@@ -36,6 +40,9 @@ export function CharacterSelectionPanel(props: CharacterSelectionPanelProps) {
     onClear,
     onCancel,
     onClose,
+    onConfirm,
+    confirmLabel = "开始深度分析",
+    confirming = false,
   } = props
   const dismiss = onClose ?? onCancel
   const [search, setSearch] = useState("")
@@ -134,7 +141,16 @@ export function CharacterSelectionPanel(props: CharacterSelectionPanelProps) {
         </div>
 
         <DialogFooter className="gap-2">
-          <Button variant="ghost" onClick={dismiss}>返回章节</Button>
+          <Button variant="ghost" onClick={dismiss} disabled={confirming}>返回章节</Button>
+          {onConfirm && (
+            <Button
+              onClick={onConfirm}
+              disabled={selectedCount === 0 || confirming}
+              data-testid="confirm-character-selection"
+            >
+              {confirming ? "启动中…" : confirmLabel}
+            </Button>
+          )}
         </DialogFooter>
       </DialogContent>
     </Dialog>

+ 2 - 15
src/components/novel/de-ai-batch-entry.spec.ts

@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"
 
 const appLayout = readFileSync("src/components/layout/app-layout.tsx", "utf8")
 const previewPanel = readFileSync("src/components/layout/preview-panel.tsx", "utf8")
-const batchWorkspace = readFileSync("src/components/novel/de-ai-batch-workspace.tsx", "utf8")
 const wikiStore = readFileSync("src/stores/wiki-store.ts", "utf8")
 const novelSection = readFileSync("src/components/settings/sections/novel-section.tsx", "utf8")
 
@@ -21,17 +20,6 @@ describe("de-ai batch entry and settings", () => {
     expect(previewPanel).toContain("writeFileIfAbsent")
     expect(previewPanel).toContain("deAiDraftSaving")
     expect(previewPanel).toContain("chapterExternalUpdateCoordinator.flushBeforeLeave(path")
-    expect(batchWorkspace).toContain("resolveDeAiBatchModelKey")
-    expect(batchWorkspace).not.toContain("`${llmConfig.provider}/${llmConfig.model}`")
-    expect(batchWorkspace).toContain("}, [novelConfig.deAiBatchConcurrency])") // CONCURRENCY_EFFECT_MARKER
-    expect(batchWorkspace).toContain("startingRef.current")
-    expect(batchWorkspace).toContain("runWorkspaceAction")
-    expect(batchWorkspace).toContain("confirmChapter(taskId, chapterId, candidateContent)")
-    expect(batchWorkspace).toContain("createLatestValueSaveQueue")
-    expect(batchWorkspace).not.toContain('runWorkspaceAction("config:concurrency"')
-    expect(batchWorkspace).toContain("pendingTaskIds.has(reviewTaskId)")
-    expect(batchWorkspace).toContain("操作失败")
-    expect(batchWorkspace).not.toMatch(/void useDeAiBatchStore\.getState\(\)\.(?:continueTask|cancelTask|confirmChapter|regenerateChapter|cancelChapter)/)
   })
 
   it("keeps review tasks project-scoped and reuses the selected skill when regenerating", () => {
@@ -42,9 +30,8 @@ describe("de-ai batch entry and settings", () => {
 
   it("小说设置包含默认 3、范围 1–5 的批量并发设置", () => {
     expect(wikiStore).toContain("deAiBatchConcurrency: 3")
-    expect(novelSection).toContain('min={1}')
-    expect(novelSection).toContain('max={5}')
+    expect(novelSection).toContain("min={1}")
+    expect(novelSection).toContain("max={5}")
     expect(novelSection).toContain("deAiBatchConcurrency")
   })
 })
-

+ 0 - 92
src/components/novel/de-ai-batch-start-dialog.spec.tsx

@@ -1,92 +0,0 @@
-// @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 { DeAiBatchStartDialog } from "./de-ai-batch-start-dialog"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
-
-const works = [
-  { id: "work-a", title: "作品甲", chapters: [{ id: "a-1", title: "第一章", order: 1, sourcePath: "C:/a/1.md" }] },
-  { id: "work-b", title: "作品乙", chapters: [{ id: "b-1", title: "第一章", order: 1, sourcePath: "C:/b/1.md" }] },
-]
-
-let host: HTMLDivElement
-let root: Root
-beforeEach(() => {
-  host = document.createElement("div")
-  document.body.appendChild(host)
-  root = createRoot(host)
-})
-afterEach(() => {
-  act(() => root.unmount())
-  host.remove()
-})
-
-describe("DeAiBatchStartDialog", () => {
-  it("可选择多个作品/章节并设置 1–5 并发", () => {
-    const onStart = vi.fn()
-    const onConcurrencyChange = vi.fn()
-    act(() => root.render(
-      <DeAiBatchStartDialog
-        open
-        works={works}
-        concurrency={3}
-        onConcurrencyChange={onConcurrencyChange}
-        onStart={onStart}
-        onClose={vi.fn()}
-      />,
-    ))
-
-    const workCheckboxes = Array.from(document.body.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-work-id]'))
-    act(() => workCheckboxes.forEach((checkbox) => checkbox.click()))
-    const concurrency = document.body.querySelector<HTMLInputElement>('input[aria-label="批量去 AI 味并发数"]')
-    expect(concurrency?.min).toBe("1")
-    expect(concurrency?.max).toBe("5")
-    act(() => {
-      if (concurrency) {
-        Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(concurrency, "5")
-        concurrency.dispatchEvent(new Event("input", { bubbles: true }))
-      }
-    })
-    const start = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("开始处理"))
-    act(() => start?.click())
-
-    expect(onConcurrencyChange).toHaveBeenCalledWith(5)
-    expect(onStart).toHaveBeenCalledWith([
-      { workId: "work-a", chapterIds: ["a-1"] },
-      { workId: "work-b", chapterIds: ["b-1"] },
-    ])
-  })
-  it("同步锁阻止开始按钮双击重复提交", async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const onStart = vi.fn(async () => gate)
-    act(() => root.render(
-      <DeAiBatchStartDialog open works={works} concurrency={3} onConcurrencyChange={vi.fn()} onStart={onStart} onClose={vi.fn()} />,
-    ))
-    const workCheckbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"][data-work-id="work-a"]')
-    act(() => workCheckbox?.click())
-    const start = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("开始处理"))
-    act(() => {
-      start?.click()
-      start?.click()
-    })
-    expect(onStart).toHaveBeenCalledTimes(1)
-    release()
-    await act(async () => { await gate })
-  })
-
-  it("starting 时禁用选择、并发、提交和关闭", () => {
-    const onClose = vi.fn()
-    act(() => root.render(
-      <DeAiBatchStartDialog open starting works={works} concurrency={3} onConcurrencyChange={vi.fn()} onStart={vi.fn()} onClose={onClose} />,
-    ))
-    const inputs = Array.from(document.body.querySelectorAll<HTMLInputElement>("input"))
-    expect(inputs.length).toBeGreaterThan(0)
-    expect(inputs.every((input) => input.disabled)).toBe(true)
-    const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
-    expect(buttons.filter((button) => button.textContent?.includes("开始处理") || button.textContent === "取消").every((button) => button.disabled)).toBe(true)
-  })
-})

+ 0 - 154
src/components/novel/de-ai-batch-start-dialog.tsx

@@ -1,154 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react"
-import { Button } from "@/components/ui/button"
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog"
-import { Input } from "@/components/ui/input"
-import type { DeAiBatchCatalogWork } from "@/lib/novel/de-ai-batch/catalog"
-import { clampDeAiBatchConcurrency } from "@/lib/novel/de-ai-batch/scheduler"
-
-export interface DeAiBatchSelection {
-  workId: string
-  chapterIds: string[]
-}
-
-export interface DeAiBatchStartDialogProps {
-  open: boolean
-  works: DeAiBatchCatalogWork[]
-  concurrency: number
-  starting?: boolean
-  onConcurrencyChange(value: number): void
-  onStart(selection: DeAiBatchSelection[]): void | Promise<void>
-  onClose(): void
-}
-
-export function DeAiBatchStartDialog({
-  open,
-  works,
-  concurrency,
-  starting = false,
-  onConcurrencyChange,
-  onStart,
-  onClose,
-}: DeAiBatchStartDialogProps) {
-  const [selected, setSelected] = useState<Record<string, Set<string>>>({})
-  const startLockRef = useRef(false)
-
-  useEffect(() => {
-    if (open) {
-      setSelected({})
-      startLockRef.current = false
-    }
-  }, [open])
-
-  const selection = useMemo(() => works.flatMap((work) => {
-    const chapterIds = Array.from(selected[work.id] ?? [])
-    return chapterIds.length > 0 ? [{ workId: work.id, chapterIds }] : []
-  }), [selected, works])
-
-  async function handleStart(): Promise<void> {
-    if (starting || startLockRef.current || selection.length === 0) return
-    startLockRef.current = true
-    try {
-      await onStart(selection)
-    } finally {
-      startLockRef.current = false
-    }
-  }
-  function toggleWork(work: DeAiBatchCatalogWork): void {
-    setSelected((current) => {
-      const next = { ...current }
-      const selectedCount = next[work.id]?.size ?? 0
-      next[work.id] = selectedCount === work.chapters.length
-        ? new Set()
-        : new Set(work.chapters.map((chapter) => chapter.id))
-      return next
-    })
-  }
-
-  function toggleChapter(workId: string, chapterId: string): void {
-    setSelected((current) => {
-      const chapters = new Set(current[workId] ?? [])
-      if (chapters.has(chapterId)) chapters.delete(chapterId)
-      else chapters.add(chapterId)
-      return { ...current, [workId]: chapters }
-    })
-  }
-
-  return (
-    <Dialog open={open} onOpenChange={(next) => { if (!next && !starting) onClose() }}>
-      <DialogContent
-        showCloseButton={false}
-        className="flex max-h-[calc(100dvh-2rem)] w-[min(48rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex-col overflow-hidden sm:max-w-[calc(100vw-2rem)]"
-      >
-        <DialogHeader>
-          <DialogTitle>新建批量去 AI 味任务</DialogTitle>
-          <DialogDescription>可同时选择多个作品和章节。每个作品使用独立 Agent,超出并发数后按顺序排队。</DialogDescription>
-        </DialogHeader>
-        <div className="flex shrink-0 items-center gap-3 rounded-md border p-3">
-          <label className="text-sm font-medium" htmlFor="de-ai-batch-concurrency">并发作品数</label>
-          <Input
-            id="de-ai-batch-concurrency"
-            aria-label="批量去 AI 味并发数"
-            type="number"
-            min={1}
-            max={5}
-            value={concurrency}
-            disabled={starting}
-            onChange={(event) => onConcurrencyChange(clampDeAiBatchConcurrency(Number(event.target.value)))}
-            className="w-20"
-          />
-          <span className="text-xs text-muted-foreground">范围 1–5,默认 3</span>
-        </div>
-        <div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
-          {works.length === 0 ? (
-            <div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
-              当前项目的章节库中没有可处理章节
-            </div>
-          ) : works.map((work) => {
-            const selectedIds = selected[work.id] ?? new Set<string>()
-            return (
-              <section key={work.id} className="rounded-md border" aria-label={work.title}>
-                <label className="flex cursor-pointer items-center gap-2 border-b px-3 py-2 font-medium">
-                  <input
-                    type="checkbox"
-                    data-work-id={work.id}
-                    checked={selectedIds.size === work.chapters.length && work.chapters.length > 0}
-                    disabled={starting}
-                    onChange={() => toggleWork(work)}
-                  />
-                  <span className="min-w-0 flex-1 truncate">{work.title}</span>
-                  <span className="text-xs text-muted-foreground">{selectedIds.size} / {work.chapters.length} 章</span>
-                </label>
-                <div className="max-h-52 overflow-y-auto p-2">
-                  {work.chapters.map((chapter) => (
-                    <label key={chapter.id} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted">
-                      <input
-                        type="checkbox"
-                        checked={selectedIds.has(chapter.id)}
-                        disabled={starting}
-                        onChange={() => toggleChapter(work.id, chapter.id)}
-                      />
-                      <span className="truncate">{chapter.title}</span>
-                    </label>
-                  ))}
-                </div>
-              </section>
-            )
-          })}
-        </div>
-        <DialogFooter>
-          <Button type="button" variant="outline" disabled={starting} onClick={onClose}>取消</Button>
-          <Button type="button" disabled={starting || selection.length === 0} onClick={() => { void handleStart() }}>
-            {starting ? "正在创建..." : `开始处理(${selection.reduce((sum, work) => sum + work.chapterIds.length, 0)} 章)`}
-          </Button>
-        </DialogFooter>
-      </DialogContent>
-    </Dialog>
-  )
-}

+ 0 - 157
src/components/novel/de-ai-batch-task-panel.spec.tsx

@@ -1,157 +0,0 @@
-// @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 { DeAiBatchTaskRecord, DeAiBatchTaskStatus } from "@/lib/novel/de-ai-batch/types"
-import { DeAiBatchTaskPanel } from "./de-ai-batch-task-panel"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
-
-function record(status: DeAiBatchTaskStatus, id = `task-${status}`): DeAiBatchTaskRecord {
-  return {
-    task: {
-      version: 1,
-      id,
-      projectPath: "C:/project",
-      workId: id,
-      workTitle: `作品-${status}`,
-      modelKey: "openai/test",
-      skillId: null,
-      skillName: "默认",
-      skillContent: "规则",
-      status,
-      chapterIds: ["c1", "c2", "c3"],
-      error: status === "failed" ? "模型失败" : null,
-      createdAt: 1,
-      startedAt: null,
-      completedAt: null,
-      updatedAt: 1,
-    },
-    chapters: ["ready", "confirmed", "pending"].map((chapterStatus, index) => ({
-      version: 1 as const,
-      id: `c${index + 1}`,
-      taskId: id,
-      title: `第${index + 1}章`,
-      order: index + 1,
-      sourcePath: `C:/project/c${index + 1}.md`,
-      sourceContent: "原文",
-      candidateContent: index < 2 ? "候选" : null,
-      status: chapterStatus as "ready" | "confirmed" | "pending",
-      runId: null,
-      generation: 1,
-      error: null,
-      createdAt: 1,
-      updatedAt: 1,
-    })),
-  }
-}
-
-function callbacks() {
-  return {
-    onCollapsedChange: vi.fn(),
-    onContinue: vi.fn(),
-    onReview: vi.fn(),
-    onCancel: 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 render(records: DeAiBatchTaskRecord[], collapsed = false, props: ReturnType<typeof callbacks> & { pendingTaskIds?: ReadonlySet<string> } = callbacks()) {
-  act(() => {
-    root.render(
-      <DeAiBatchTaskPanel
-        records={records}
-        collapsed={collapsed}
-        {...props}
-      />,
-    )
-  })
-  return props
-}
-
-function click(label: string): void {
-  const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
-  expect(button, `找不到按钮:${label}`).toBeTruthy()
-  act(() => button?.click())
-}
-
-describe("DeAiBatchTaskPanel", () => {
-  it("显示中文状态、已处理章节计数和独立滚动任务区", () => {
-    render([record("running"), record("failed")])
-
-    expect(host.textContent).toContain("批量去 AI 味")
-    expect(host.textContent).toContain("处理中")
-    expect(host.textContent).toContain("失败")
-    expect(host.textContent).toContain("2 / 3 章")
-    const list = host.querySelector('[data-testid="de-ai-batch-task-list"]')
-    expect(list?.className).toContain("overflow-y-auto")
-    expect(list?.className).toContain("max-h-")
-  })
-
-  it("折叠状态完全受控", () => {
-    const props = render([record("running")])
-    click("收起任务")
-    expect(props.onCollapsedChange).toHaveBeenCalledWith(true)
-
-    render([record("running")], true, props)
-    expect(host.textContent).not.toContain("作品-running")
-    click("展开任务")
-    expect(props.onCollapsedChange).toHaveBeenLastCalledWith(false)
-  })
-
-  it("interrupted 和 failed 可继续,reviewing 可审核", () => {
-    const props = render([record("interrupted"), record("failed"), record("reviewing")])
-    const continueButtons = Array.from(host.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "继续")
-    act(() => continueButtons[0]?.click())
-    act(() => continueButtons[1]?.click())
-    const reviewingArticle = Array.from(host.querySelectorAll("article")).find((item) => item.textContent?.includes("作品-reviewing"))
-    const reviewButton = Array.from(reviewingArticle?.querySelectorAll("button") ?? []).find((item) => item.textContent?.trim() === "审核")
-    act(() => reviewButton?.click())
-
-    expect(props.onContinue).toHaveBeenNthCalledWith(1, "task-interrupted")
-    expect(props.onContinue).toHaveBeenNthCalledWith(2, "task-failed")
-    expect(props.onReview).toHaveBeenCalledWith("task-reviewing")
-  })
-
-  it("取消使用中文确认且只传递当前任务", () => {
-    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
-    const props = render([record("queued")])
-
-    click("取消")
-
-    expect(confirm).toHaveBeenCalledWith("确定取消“作品-queued”的批量去 AI 味任务吗?")
-    expect(props.onCancel).toHaveBeenCalledWith("task-queued")
-  })
-
-  it("拒绝确认时不取消任务", () => {
-    vi.spyOn(window, "confirm").mockReturnValue(false)
-    const props = render([record("running")])
-
-    click("取消")
-
-    expect(props.onCancel).not.toHaveBeenCalled()
-  })
-
-  it("任务异步操作 pending 时禁用继续和取消", () => {
-    render([record("failed")], false, { ...callbacks(), pendingTaskIds: new Set(["task-failed"]) })
-    const buttons = Array.from(host.querySelectorAll<HTMLButtonElement>("button"))
-    expect(buttons.find((button) => button.textContent?.trim() === "继续")?.disabled).toBe(true)
-    expect(buttons.find((button) => button.textContent?.trim() === "取消")?.disabled).toBe(true)
-    expect(buttons.find((button) => button.textContent?.trim() === "审核")?.disabled).toBe(true)
-  })
-})

+ 0 - 132
src/components/novel/de-ai-batch-task-panel.tsx

@@ -1,132 +0,0 @@
-import { ChevronDown, ChevronUp, Layers3 } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import type { DeAiBatchTaskRecord, DeAiBatchTaskStatus } from "@/lib/novel/de-ai-batch/types"
-
-const STATUS_LABELS: Record<DeAiBatchTaskStatus, string> = {
-  queued: "等待中",
-  running: "处理中",
-  reviewing: "待审核",
-  partial: "部分完成",
-  interrupted: "已中断",
-  failed: "失败",
-  cancelled: "已取消",
-  completed: "已完成",
-}
-
-const CONTINUE_STATUSES = new Set<DeAiBatchTaskStatus>(["interrupted", "failed", "partial"])
-const CANCELLABLE_STATUSES = new Set<DeAiBatchTaskStatus>([
-  "queued",
-  "running",
-  "reviewing",
-  "partial",
-  "interrupted",
-  "failed",
-])
-
-export interface DeAiBatchTaskPanelProps {
-  records: DeAiBatchTaskRecord[]
-  collapsed: boolean
-  pendingTaskIds?: ReadonlySet<string>
-  onCollapsedChange(collapsed: boolean): void
-  onContinue(taskId: string): void
-  onReview(taskId: string): void
-  onCancel(taskId: string): void
-}
-
-function completedCount(record: DeAiBatchTaskRecord): number {
-  return record.chapters.filter((chapter) => chapter.status !== "pending" && chapter.status !== "generating").length
-}
-
-function canReview(record: DeAiBatchTaskRecord): boolean {
-  return record.chapters.some((chapter) => chapter.status === "ready" || chapter.status === "confirmed")
-}
-
-export function DeAiBatchTaskPanel({
-  records,
-  collapsed,
-  pendingTaskIds = new Set(),
-  onCollapsedChange,
-  onContinue,
-  onReview,
-  onCancel,
-}: DeAiBatchTaskPanelProps) {
-  if (records.length === 0) return null
-
-  return (
-    <section className="rounded-lg border bg-card" aria-label="批量去 AI 味任务">
-      <div className="flex items-center justify-between gap-3 border-b px-3 py-2">
-        <div className="flex min-w-0 items-center gap-2 text-sm font-medium">
-          <Layers3 className="h-4 w-4 shrink-0 text-primary" />
-          <span>批量去 AI 味</span>
-          <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{records.length}</span>
-        </div>
-        <Button
-          type="button"
-          size="sm"
-          variant="ghost"
-          aria-controls="de-ai-batch-task-list"
-          aria-expanded={!collapsed}
-          onClick={() => onCollapsedChange(!collapsed)}
-        >
-          {collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
-          {collapsed ? "展开任务" : "收起任务"}
-        </Button>
-      </div>
-      {!collapsed ? (
-        <div
-          id="de-ai-batch-task-list"
-          data-testid="de-ai-batch-task-list"
-          className="max-h-72 space-y-2 overflow-y-auto p-3"
-        >
-          {records.map((record) => {
-            const task = record.task
-            const finished = completedCount(record)
-            return (
-              <article key={task.id} className="rounded-md border p-3" aria-label={`${task.workTitle}任务`}>
-                <div className="flex items-start justify-between gap-3">
-                  <div className="min-w-0">
-                    <div className="truncate text-sm font-medium" title={task.workTitle}>{task.workTitle}</div>
-                    <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
-                      <span>{STATUS_LABELS[task.status]}</span>
-                      <span>{finished} / {record.chapters.length} 章</span>
-                    </div>
-                    {task.error ? (
-                      <p className="mt-1 truncate text-xs text-destructive" title={task.error}>{task.error}</p>
-                    ) : null}
-                  </div>
-                  <div className="flex shrink-0 flex-wrap justify-end gap-1">
-                    {CONTINUE_STATUSES.has(task.status) ? (
-                      <Button type="button" size="sm" variant="outline" disabled={pendingTaskIds.has(task.id)} onClick={() => onContinue(task.id)}>
-                        继续
-                      </Button>
-                    ) : null}
-                    {canReview(record) ? (
-                      <Button type="button" size="sm" variant="outline" disabled={pendingTaskIds.has(task.id)} onClick={() => onReview(task.id)}>
-                        审核
-                      </Button>
-                    ) : null}
-                    {CANCELLABLE_STATUSES.has(task.status) ? (
-                      <Button
-                        type="button"
-                        size="sm"
-                        variant="ghost"
-                        disabled={pendingTaskIds.has(task.id)}
-                        onClick={() => {
-                          if (window.confirm(`确定取消“${task.workTitle}”的批量去 AI 味任务吗?`)) {
-                            onCancel(task.id)
-                          }
-                        }}
-                      >
-                        取消
-                      </Button>
-                    ) : null}
-                  </div>
-                </div>
-              </article>
-            )
-          })}
-        </div>
-      ) : null}
-    </section>
-  )
-}

+ 0 - 206
src/components/novel/de-ai-batch-workspace.tsx

@@ -1,206 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react"
-import { WandSparkles } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { DeAiBatchReviewDialog } from "@/components/novel/de-ai-batch-review-dialog"
-import { DeAiBatchStartDialog, type DeAiBatchSelection } from "@/components/novel/de-ai-batch-start-dialog"
-import { DeAiBatchTaskPanel } from "@/components/novel/de-ai-batch-task-panel"
-import { readFile } from "@/commands/fs"
-import { toast } from "@/lib/toast"
-import { buildDeAiSystemPrompt } from "@/lib/novel/de-ai-adapter"
-import { loadEffectiveDeAiSkillSafely } from "@/lib/novel/de-ai-skill-library"
-import { buildDeAiBatchCatalog } from "@/lib/novel/de-ai-batch/catalog"
-import { resolveDeAiBatchModelKey } from "@/lib/novel/de-ai-batch/llm-runner"
-import { createLatestValueSaveQueue } from "@/lib/novel/de-ai-batch/latest-value-save-queue"
-import { saveNovelConfig } from "@/lib/project-store"
-import { useDeAiBatchStore } from "@/stores/de-ai-batch-store"
-import { useWikiStore } from "@/stores/wiki-store"
-import type { CreateDeAiBatchTaskInput } from "@/lib/novel/de-ai-batch/types"
-
-export function DeAiBatchWorkspace() {
-  const project = useWikiStore((state) => state.project)
-  const fileTree = useWikiStore((state) => state.fileTree)
-  const novelConfig = useWikiStore((state) => state.novelConfig)
-  const records = useDeAiBatchStore((state) => state.records)
-  const concurrency = useDeAiBatchStore((state) => state.concurrency)
-  const panelCollapsed = useDeAiBatchStore((state) => state.panelCollapsed)
-  const reviewOpen = useDeAiBatchStore((state) => state.reviewOpen)
-  const reviewTaskId = useDeAiBatchStore((state) => state.reviewTaskId)
-  const reviewChapterId = useDeAiBatchStore((state) => state.reviewChapterId)
-  const [startOpen, setStartOpen] = useState(false)
-  const [starting, setStarting] = useState(false)
-  const startingRef = useRef(false)
-  const pendingActionKeysRef = useRef(new Set<string>())
-  const [pendingActionKeys, setPendingActionKeys] = useState<ReadonlySet<string>>(new Set())
-
-  const catalog = useMemo(
-    () => project ? buildDeAiBatchCatalog(project, fileTree) : [],
-    [fileTree, project],
-  )
-  const concurrencySaveQueue = useMemo(() => project
-    ? createLatestValueSaveQueue((config: typeof novelConfig) => saveNovelConfig(config, project.id, project.path))
-    : null, [project?.id, project?.path])
-  const reviewRecord = records.find((record) => record.task.id === reviewTaskId) ?? null
-  const pendingTaskIds = useMemo(() => new Set(Array.from(pendingActionKeys).flatMap((key) => key.startsWith("task:") ? [key.slice(5)] : [])), [pendingActionKeys])
-  const reviewPending = (!!reviewTaskId && pendingTaskIds.has(reviewTaskId))
-    || (!!reviewTaskId && !!reviewChapterId && pendingActionKeys.has(`chapter:${reviewTaskId}:${reviewChapterId}`))
-
-  useEffect(() => {
-    if (!project) return
-    const store = useDeAiBatchStore.getState()
-    void store.initializeProject(project.path).catch((error) => {
-      console.error("加载批量去 AI 味任务失败", error)
-      toast.error("加载批量去 AI 味任务失败,请稍后重试。")
-    })
-    return () => store.dispose()
-  }, [project?.id, project?.path])
-
-  useEffect(() => {
-    useDeAiBatchStore.getState().setConcurrency(novelConfig.deAiBatchConcurrency)
-  }, [novelConfig.deAiBatchConcurrency])
-
-  if (!project) return null
-  const activeProject = project
-
-  async function runWorkspaceAction(
-    key: string,
-    label: string,
-    action: () => Promise<unknown>,
-  ): Promise<void> {
-    if (pendingActionKeysRef.current.has(key)) return
-    pendingActionKeysRef.current.add(key)
-    setPendingActionKeys(new Set(pendingActionKeysRef.current))
-    try {
-      await action()
-    } catch (error) {
-      console.error(`${label}失败`, error)
-      toast.error(`操作失败:${label}。${error instanceof Error ? error.message : String(error)}`)
-    } finally {
-      pendingActionKeysRef.current.delete(key)
-      setPendingActionKeys(new Set(pendingActionKeysRef.current))
-    }
-  }
-  async function handleStart(selection: DeAiBatchSelection[]): Promise<void> {
-    if (startingRef.current) return
-    startingRef.current = true
-    setStarting(true)
-    try {
-      const wikiState = useWikiStore.getState()
-      const modelKey = resolveDeAiBatchModelKey({
-        taskModel: wikiState.novelConfig.deAiModel,
-        defaultModel: wikiState.novelConfig.defaultLlmModel || wikiState.defaultLlmModel,
-        chatModel: wikiState.aiChatModel,
-        baseConfig: wikiState.llmConfig,
-        providerConfigs: wikiState.providerConfigs,
-      })
-      if (!modelKey) {
-        toast.error("未配置可用的去 AI 味模型,请先到小说设置中配置。")
-        return
-      }
-      const effective = await loadEffectiveDeAiSkillSafely(activeProject.path)
-      if (effective.warning) toast.info(effective.warning)
-      const skillContent = effective.skill?.content || buildDeAiSystemPrompt()
-      const skillName = effective.skill?.name || "系统默认去 AI 味规则"
-      const inputs: CreateDeAiBatchTaskInput[] = []
-      for (const selectedWork of selection) {
-        const work = catalog.find((item) => item.id === selectedWork.workId)
-        if (!work) continue
-        const selectedIds = new Set(selectedWork.chapterIds)
-        const chapters = []
-        for (const chapter of work.chapters) {
-          if (!selectedIds.has(chapter.id)) continue
-          chapters.push({
-            ...chapter,
-            sourceContent: await readFile(chapter.sourcePath),
-          })
-        }
-        if (chapters.length === 0) continue
-        inputs.push({
-          projectPath: activeProject.path,
-          workId: work.id,
-          workTitle: work.title,
-          modelKey,
-          skillId: effective.skill?.id ?? null,
-          skillName,
-          skillContent,
-          chapters,
-        })
-      }
-      if (inputs.length === 0) {
-        toast.error("请选择至少一个可读取的章节。")
-        return
-      }
-      await useDeAiBatchStore.getState().createBatch(inputs)
-      setStartOpen(false)
-      toast.success(`已创建 ${inputs.length} 个作品的批量去 AI 味任务。`)
-    } catch (error) {
-      console.error("创建批量去 AI 味任务失败", error)
-      toast.error(`创建批量去 AI 味任务失败:${error instanceof Error ? error.message : String(error)}`)
-    } finally {
-      startingRef.current = false
-      setStarting(false)
-    }
-  }
-
-  function handleConcurrencyChange(value: number): void {
-    useDeAiBatchStore.getState().setConcurrency(value)
-    const wikiState = useWikiStore.getState()
-    const nextConfig = { ...wikiState.novelConfig, deAiBatchConcurrency: value }
-    wikiState.setNovelConfig(nextConfig)
-    const saving = concurrencySaveQueue?.enqueue(nextConfig)
-    if (saving) {
-      void saving.catch((error) => {
-        console.error("保存批量去 AI 味并发设置失败", error)
-        toast.error(`保存批量去 AI 味并发设置失败:${error instanceof Error ? error.message : String(error)}`)
-      })
-    }
-  }
-
-  return (
-    <>
-      <aside className="fixed right-4 top-16 z-40 flex w-[min(26rem,calc(100vw-2rem))] max-h-[calc(100dvh-5rem)] flex-col gap-2" aria-label="批量去 AI 味工作区">
-        <div className="flex justify-end">
-          <Button type="button" size="sm" onClick={() => setStartOpen(true)} disabled={starting}>
-            <WandSparkles className="h-4 w-4" />
-            {starting ? "正在创建..." : "批量去 AI 味"}
-          </Button>
-        </div>
-        <DeAiBatchTaskPanel
-          records={records}
-          collapsed={panelCollapsed}
-          pendingTaskIds={pendingTaskIds}
-          onCollapsedChange={(collapsed) => useDeAiBatchStore.getState().setPanelCollapsed(collapsed)}
-          onContinue={(taskId) => runWorkspaceAction(`task:${taskId}`, "继续批量去 AI 味任务", () => useDeAiBatchStore.getState().continueTask(taskId))}
-          onReview={(taskId) => {
-            const record = useDeAiBatchStore.getState().records.find((item) => item.task.id === taskId)
-            const chapterId = record?.chapters.find((chapter) => chapter.status === "ready")?.id
-              ?? record?.chapters[0]?.id
-            useDeAiBatchStore.getState().openReview(taskId, chapterId)
-          }}
-          onCancel={(taskId) => runWorkspaceAction(`task:${taskId}`, "取消批量去 AI 味任务", () => useDeAiBatchStore.getState().cancelTask(taskId))}
-        />
-      </aside>
-
-      <DeAiBatchStartDialog
-        open={startOpen}
-        works={catalog}
-        concurrency={concurrency}
-        starting={starting}
-        onConcurrencyChange={handleConcurrencyChange}
-        onStart={handleStart}
-        onClose={() => setStartOpen(false)}
-      />
-
-      <DeAiBatchReviewDialog
-        open={reviewOpen}
-        record={reviewRecord}
-        currentChapterId={reviewChapterId}
-        pending={reviewPending}
-        onSelectChapter={(chapterId) => useDeAiBatchStore.getState().setReviewChapter(chapterId)}
-        onConfirm={(taskId, chapterId, candidateContent) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "确认当前章节", () => useDeAiBatchStore.getState().confirmChapter(taskId, chapterId, candidateContent))}
-        onRegenerate={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "重新生成当前章节", () => useDeAiBatchStore.getState().regenerateChapter(taskId, chapterId))}
-        onCancelChapter={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "取消当前章节", () => useDeAiBatchStore.getState().cancelChapter(taskId, chapterId))}
-        onClose={() => useDeAiBatchStore.getState().closeReview()}
-      />
-    </>
-  )
-}

+ 0 - 51
src/components/novel/de-ai-preview-dialog.tsx

@@ -1,51 +0,0 @@
-import { useEffect, useState } from "react"
-import { TextTransformPreviewDialog } from "./text-transform-preview-dialog"
-
-export interface DeAiPreviewDialogProps {
-  open: boolean
-  sourceContent: string
-  candidateContent: string
-  skillName?: string
-  modelName?: string
-  onApply: (candidateContent?: string) => void
-  onSaveDraft: (candidateContent?: string) => void
-  onClose: () => void
-}
-
-export function DeAiPreviewDialog({
-  open,
-  sourceContent,
-  candidateContent,
-  skillName,
-  modelName,
-  onApply,
-  onSaveDraft,
-  onClose,
-}: DeAiPreviewDialogProps) {
-  const [draftContent, setDraftContent] = useState(candidateContent)
-
-  useEffect(() => {
-    if (open) setDraftContent(candidateContent)
-  }, [candidateContent, open])
-
-  return (
-    <TextTransformPreviewDialog
-      open={open}
-      title={"去AI味预览"}
-      description={skillName
-        ? `本次使用 Skill:${skillName}${modelName ? `,模型:${modelName}` : ""}`
-        : undefined}
-      sourceLabel={"原文"}
-      candidateLabel={"去AI味稿"}
-      sourceContent={sourceContent}
-      candidateContent={draftContent}
-      comparisonMode
-      onCandidateContentChange={setDraftContent}
-      applyLabel={"替换正文"}
-      secondaryActionLabel={"另存草稿"}
-      onApply={() => onApply(draftContent)}
-      onSecondaryAction={() => onSaveDraft(draftContent)}
-      onClose={onClose}
-    />
-  )
-}

+ 0 - 101
src/components/novel/de-ai-skill-editor.tsx

@@ -1,101 +0,0 @@
-import { useState, useEffect } from "react"
-import { Button } from "@/components/ui/button"
-import { Label } from "@/components/ui/label"
-import { Textarea } from "@/components/ui/textarea"
-import { useWikiStore } from "@/stores/wiki-store"
-import { readFile, writeFile } from "@/commands/fs"
-import { join } from "@tauri-apps/api/path"
-
-// 从新的skill文件导入默认规则
-import defaultDeAiSkill from "../../../skills/de-ai-writing/SKILL.md?raw"
-
-const DEFAULT_DE_AI_SKILL = defaultDeAiSkill.trim()
-
-export function DeAiSkillEditor() {
-  const project = useWikiStore((s) => s.project)
-  const [content, setContent] = useState("")
-  const [saving, setSaving] = useState(false)
-  const [message, setMessage] = useState("")
-  const [isDefault, setIsDefault] = useState(true)
-
-  useEffect(() => {
-    if (!project) return
-    loadSkill()
-  }, [project?.path])
-
-  async function loadSkill() {
-    if (!project) return
-    try {
-      const skillPath = await join(project.path, "de-ai-skill.txt")
-      const skillContent = await readFile(skillPath)
-      setContent(skillContent)
-      setIsDefault(false)
-    } catch {
-      setContent(DEFAULT_DE_AI_SKILL)
-      setIsDefault(true)
-    }
-  }
-
-  async function handleSave() {
-    if (!project) return
-    setSaving(true)
-    try {
-      const skillPath = await join(project.path, "de-ai-skill.txt")
-      await writeFile(skillPath, content)
-      setMessage("保存成功")
-      setIsDefault(false)
-    } catch {
-      setMessage("保存失败,请稍后重试")
-    } finally {
-      setSaving(false)
-      setTimeout(() => setMessage(""), 2000)
-    }
-  }
-
-  async function handleReset() {
-    setContent(DEFAULT_DE_AI_SKILL)
-    setMessage("已重置为默认规则")
-    setTimeout(() => setMessage(""), 2000)
-  }
-
-  return (
-    <div className="flex flex-col gap-4 p-4">
-      <div>
-        <Label>去AI味Skill</Label>
-        <p className="text-sm text-muted-foreground mt-1">
-          自定义去AI味规则,将应用到全局所有去AI味功能(章节去AI味、选中文本去AI味、AI会话深度思考阶段6)
-        </p>
-        {isDefault && (
-          <p className="text-xs text-amber-600 mt-2">
-            当前使用系统默认skill(de-AI-writing - 12项硬门槛 + 24项AI痕迹检测)。保存后将创建项目自定义规则文件,优先级最高。
-          </p>
-        )}
-      </div>
-      <Textarea
-        className="min-h-[400px] font-mono text-sm"
-        placeholder="在此输入你的去AI味规则..."
-        value={content}
-        onChange={(e) => setContent(e.target.value)}
-      />
-      <div className="flex items-center gap-2">
-        <Button onClick={handleSave} disabled={saving || content.trim() === ""}>
-          {saving ? "保存中..." : "保存"}
-        </Button>
-        <Button onClick={handleReset} variant="outline" disabled={saving}>
-          重置为默认
-        </Button>
-        {message && <span className="text-sm text-muted-foreground">{message}</span>}
-      </div>
-      <div className="rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
-        <div className="font-medium mb-2">使用提示:</div>
-        <ul className="space-y-1 list-disc list-inside">
-          <li>编辑规则后点击"保存",将自动应用到所有去AI味功能</li>
-          <li>系统默认使用 de-AI-writing skill(保真改写,适合网络小说)</li>
-          <li>支持多行文本,可以使用分点、分段的形式组织规则</li>
-          <li>规则会保存为项目根目录下的 de-ai-skill.txt 文件(优先级最高)</li>
-          <li>完整skill系统位于软件安装目录的 skills/ 文件夹,包含 references/ 详细规则</li>
-        </ul>
-      </div>
-    </div>
-  )
-}

+ 0 - 836
src/components/novel/dismantling-view.tsx

@@ -1,836 +0,0 @@
-import { useEffect, useState } from "react";
-import {
-  BookOpenCheck,
-  CheckCircle2,
-  Loader2,
-  Play,
-  RefreshCw,
-} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { useWikiStore } from "@/stores/wiki-store";
-import { streamChat, type ChatMessage } from "@/lib/llm-client";
-import { resolveNovelModel } from "@/lib/novel/model-resolver";
-import {
-  buildDismantlingAnalysisPrompt,
-  buildDismantlingWebResearchPrompt,
-  buildPlotFrameworkDraftFromAnalysis,
-  extractStructureMemoryFromAnalysis,
-  loadDismantlingLibrary,
-  saveDismantlingLibrary,
-  selectNextDismantlingBatch,
-  type DismantlingAnalysis,
-  type DismantlingChapter,
-  type DismantlingProject,
-} from "@/lib/novel/dismantling";
-import {
-  buildWebResearchContext,
-  collectWebResearch,
-} from "@/lib/web-research";
-import { loadPlotFrameworkLibrary, upsertPlotFramework } from "@/lib/novel/plot-framework-library";
-import { PlotFrameworkExtractDialog } from "@/components/novel/plot-framework-extract-dialog";
-import { OutlineCreatorDialog } from "@/components/novel/outline-editor";
-import {
-  listMainLineFrameworks,
-  listSubLineFrameworks,
-  type PlotFramework,
-} from "@/lib/novel/plot-framework";
-
-const DEFAULT_BATCH_SIZE = 3;
-
-export function DismantlingView() {
-  const project = useWikiStore((state) => state.project);
-  const llmConfig = useWikiStore((state) => state.llmConfig);
-  const novelConfig = useWikiStore((state) => state.novelConfig);
-  const dataVersion = useWikiStore((state) => state.dataVersion);
-  const selectedDismantlingProjectId = useWikiStore(
-    (state) => state.selectedDismantlingProjectId,
-  );
-  const bumpDataVersion = useWikiStore((state) => state.bumpDataVersion);
-  const [selectedProject, setSelectedProject] =
-    useState<DismantlingProject | null>(null);
-  const [selectedChapterIds, setSelectedChapterIds] = useState<string[]>([]);
-  const [batchSize, setBatchSize] = useState(DEFAULT_BATCH_SIZE);
-  const [running, setRunning] = useState(false);
-  const [status, setStatus] = useState("");
-  const [webResearchInput, setWebResearchInput] = useState("");
-  const [mainFrameworks, setMainFrameworks] = useState<PlotFramework[]>([]);
-  const [subFrameworks, setSubFrameworks] = useState<PlotFramework[]>([]);
-
-  const [extractDialogState, setExtractDialogState] = useState<{
-    open: boolean;
-    analysis: DismantlingAnalysis | null;
-  }>({ open: false, analysis: null });
-
-  const [outlineCreatorState, setOutlineCreatorState] = useState<{
-    open: boolean;
-    frameworkId: string | undefined;
-  }>({ open: false, frameworkId: undefined });
-
-  useEffect(() => {
-    if (!project || !selectedDismantlingProjectId) {
-      setSelectedProject(null);
-      return;
-    }
-    let cancelled = false;
-    void loadDismantlingLibrary(project.path).then((value) => {
-      if (cancelled) return;
-      const found = value.projects.find(
-        (item) => item.id === selectedDismantlingProjectId,
-      );
-      setSelectedProject(found ?? null);
-      setSelectedChapterIds(found?.chapters.map((chapter) => chapter.id) ?? []);
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, [project, dataVersion, selectedDismantlingProjectId]);
-
-  // 加载剧情框架库
-  useEffect(() => {
-    if (!project) {
-      setMainFrameworks([]);
-      setSubFrameworks([]);
-      return;
-    }
-    let cancelled = false;
-    void loadPlotFrameworkLibrary(project.path).then((library) => {
-      if (cancelled) return;
-      setMainFrameworks(listMainLineFrameworks(library));
-      setSubFrameworks(listSubLineFrameworks(library));
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, [project, dataVersion]);
-
-  const upsertSelectedProject = async (
-    updater: (current: DismantlingProject) => DismantlingProject,
-  ) => {
-    if (!selectedProject || !project) return;
-    const updated = updater(selectedProject);
-    const fullLibrary = await loadDismantlingLibrary(project.path);
-    const nextProjects = fullLibrary.projects.map((item) =>
-      item.id === selectedProject.id ? updated : item,
-    );
-    await saveDismantlingLibrary(project.path, {
-      ...fullLibrary,
-      projects: nextProjects,
-    });
-    setSelectedProject(updated);
-    bumpDataVersion();
-  };
-
-  const handleRunDismantling = async () => {
-    if (!project || !selectedProject || running) return;
-    const batch = selectNextDismantlingBatch(selectedProject, {
-      selectedChapterIds,
-      batchSize,
-    });
-    if (batch.length === 0) {
-      setStatus("当前选择范围内没有待拆章节。");
-      return;
-    }
-
-    setRunning(true);
-    setStatus(`正在拆文:本批 ${batch.length} 章。`);
-    const runningIds = new Set(batch.map((chapter) => chapter.id));
-    await upsertSelectedProject((current) => ({
-      ...current,
-      updatedAt: Date.now(),
-      chapters: current.chapters.map((chapter) =>
-        runningIds.has(chapter.id)
-          ? { ...chapter, status: "running" }
-          : chapter,
-      ),
-    }));
-
-    let output = "";
-    try {
-      const messages: ChatMessage[] = [
-        {
-          role: "system",
-          content: "你是严谨的小说拆文分析助手,必须用中文输出结构化结果。",
-        },
-        {
-          role: "user",
-          content: buildDismantlingAnalysisPrompt({
-            projectTitle: selectedProject.title,
-            chapters: batch,
-          }),
-        },
-      ];
-      await new Promise<void>((resolve, reject) => {
-        void streamChat(
-          resolveNovelModel(llmConfig, novelConfig, "extract"),
-          messages,
-          {
-            onToken: (token) => {
-              output += token;
-            },
-            onDone: resolve,
-            onError: reject,
-          },
-        );
-      });
-      const memory = extractStructureMemoryFromAnalysis(output);
-      const analysis: DismantlingAnalysis = {
-        id: `analysis-${Date.now()}`,
-        chapterIds: batch.map((chapter) => chapter.id),
-        title: `第 ${batch[0].chapterNumber}-${batch[batch.length - 1].chapterNumber} 章拆文`,
-        createdAt: Date.now(),
-        markdown: output,
-        structureMemory: memory,
-      };
-      await upsertSelectedProject((current) => ({
-        ...current,
-        updatedAt: Date.now(),
-        chapters: current.chapters.map((chapter) =>
-          runningIds.has(chapter.id)
-            ? { ...chapter, status: "done", error: undefined }
-            : chapter,
-        ),
-        analyses: [analysis, ...current.analyses],
-        structureMemory: mergeUnique([
-          ...memory,
-          ...current.structureMemory,
-        ]).slice(0, 120),
-      }));
-      const frameworkDraft = buildPlotFrameworkDraftFromAnalysis({
-        analysisId: analysis.id,
-        markdown: output,
-        rangeChapterIds: analysis.chapterIds,
-        sourceDismantlingProjectId: selectedProject.id,
-        sourceDismantlingProjectTitle: selectedProject.title,
-        createdAt: analysis.createdAt,
-      });
-      if (frameworkDraft) {
-        await upsertPlotFramework(project.path, frameworkDraft);
-        bumpDataVersion();
-        setStatus(`本批拆文完成,新增 ${memory.length} 条结构记忆,并已自动生成基础剧情框架。`);
-      } else {
-        setStatus(`本批拆文完成,新增 ${memory.length} 条结构记忆;四段框架不完整,未自动入库。`);
-      }
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error);
-      await upsertSelectedProject((current) => ({
-        ...current,
-        updatedAt: Date.now(),
-        chapters: current.chapters.map((chapter) =>
-          runningIds.has(chapter.id)
-            ? { ...chapter, status: "failed", error: message }
-            : chapter,
-        ),
-      }));
-      setStatus(`拆文失败:${message}`);
-    } finally {
-      setRunning(false);
-    }
-  };
-
-  const handleRunWebDismantlingResearch = async () => {
-    if (!project || !selectedProject || running) return;
-    const request = webResearchInput.trim();
-    if (!request) {
-      setStatus("请先输入要搜索的热门方向、榜单关键词或网页地址。");
-      return;
-    }
-
-    setRunning(true);
-    setStatus("正在联网读取网页资料并进行热门分析,请稍候。");
-    let output = "";
-    try {
-      const webResearch = await collectWebResearch({
-        text: request,
-        searchApiConfig: useWikiStore.getState().searchApiConfig,
-        maxSearchResults: 6,
-        maxImportedDocuments: 4,
-      });
-      const webResearchContext = buildWebResearchContext(webResearch);
-      const messages: ChatMessage[] = [
-        {
-          role: "system",
-          content:
-            "你是严谨的小说拆文和热门趋势分析助手,必须用中文输出结构化结果。",
-        },
-        {
-          role: "user",
-          content: buildDismantlingWebResearchPrompt({
-            projectTitle: selectedProject.title,
-            userRequest: request,
-            webResearchContext: webResearchContext.markdown,
-          }),
-        },
-      ];
-      await new Promise<void>((resolve, reject) => {
-        void streamChat(
-          resolveNovelModel(llmConfig, novelConfig, "extract"),
-          messages,
-          {
-            onToken: (token) => {
-              output += token;
-            },
-            onDone: resolve,
-            onError: reject,
-          },
-        );
-      });
-      const memory = extractStructureMemoryFromAnalysis(output);
-      const analysis: DismantlingAnalysis = {
-        id: `web-analysis-${Date.now()}`,
-        chapterIds: [],
-        title: "网页热门分析",
-        createdAt: Date.now(),
-        markdown: output,
-        structureMemory: memory,
-      };
-      await upsertSelectedProject((current) => ({
-        ...current,
-        updatedAt: Date.now(),
-        analyses: [analysis, ...current.analyses],
-        structureMemory: mergeUnique([
-          ...memory,
-          ...current.structureMemory,
-        ]).slice(0, 120),
-      }));
-      setStatus(
-        `网页热门分析完成,参考来源 ${webResearchContext.sources.length} 条,新增 ${memory.length} 条结构记忆。`,
-      );
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error);
-      setStatus(`网页热门分析失败:${message}`);
-    } finally {
-      setRunning(false);
-    }
-  };
-
-  const toggleUseInChat = async (checked: boolean) => {
-    await upsertSelectedProject((current) => ({
-      ...current,
-      useInChat: checked,
-      updatedAt: Date.now(),
-    }));
-    setStatus(
-      checked
-        ? "已启用:AI 会话会在用户写作时参考拆文结构。"
-        : "已关闭:AI 会话不会读取该拆文结构。",
-    );
-  };
-
-  const toggleChapter = (id: string, checked: boolean) => {
-    setSelectedChapterIds((current) =>
-      checked
-        ? [...new Set([...current, id])]
-        : current.filter((item) => item !== id),
-    );
-  };
-
-  if (!project) {
-    return (
-      <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-        请先打开小说项目。
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex h-full min-h-0 flex-col bg-background">
-      <header className="border-b px-5 py-3">
-        <div className="flex items-center gap-2 text-sm text-primary">
-          <BookOpenCheck className="h-4 w-4" />
-          <span>拆文库 · 独立拆文记忆库</span>
-          <span className="text-muted-foreground">
-            — 拆文结果独立保存,不会写入小说记忆、章节记忆或大纲记忆。
-          </span>
-        </div>
-      </header>
-
-      <div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_380px]">
-        {/* 第三栏:作品详情、自动章节识别与拆文操作 */}
-        <main className="min-h-0 flex flex-col overflow-hidden border-r">
-          {!selectedProject ? (
-            <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-              请从左侧选择拆文作品
-            </div>
-          ) : (
-            <>
-              <div className="border-b px-4 py-3">
-                <div className="flex items-center justify-between">
-                  <div>
-                    <div className="text-xs font-medium text-muted-foreground">
-                      作品详情
-                    </div>
-                    <h2 className="mt-1 text-base font-semibold">
-                      {selectedProject.title}
-                    </h2>
-                    <p className="mt-0.5 text-xs text-muted-foreground">
-                      已自动识别章节结构:{selectedProject.chapters.length} 章 ·{" "}
-                      {selectedProject.structureMemory.length} 条结构记忆
-                    </p>
-                  </div>
-                </div>
-              </div>
-
-              <div className="min-h-0 flex-1 overflow-y-auto p-4 space-y-4">
-                <section className="rounded-lg border bg-card p-3">
-                  <div className="mb-2 flex items-center gap-2 text-sm font-medium">
-                    <CheckCircle2 className="h-4 w-4 text-primary" />
-                    使用拆文结构
-                  </div>
-                  <label className="flex items-start gap-3 text-sm">
-                    <input
-                      type="checkbox"
-                      checked={Boolean(selectedProject.useInChat)}
-                      onChange={(event) =>
-                        void toggleUseInChat(event.target.checked)
-                      }
-                    />
-                    <span>在 AI 会话写作时参考当前拆文作品的结构记忆。</span>
-                  </label>
-                  <p className="mt-2 text-xs leading-5 text-muted-foreground">
-                    只学习节奏、冲突推进、爽点安排和章节钩子;不得复用原作人物、设定、剧情和具体表达。
-                  </p>
-                </section>
-
-                <section className="rounded-lg border bg-card p-3">
-                  <div className="mb-2 text-sm font-medium">网页热门分析</div>
-                  <p className="mb-2 text-xs leading-5 text-muted-foreground">
-                    输入榜单关键词、题材方向或网页地址,AI
-                    会联网读取资料并生成拆文结构分析;结果只写入独立拆文记忆库。
-                  </p>
-                  <textarea
-                    value={webResearchInput}
-                    onChange={(event) =>
-                      setWebResearchInput(event.target.value)
-                    }
-                    placeholder="例如:搜索番茄都市脑洞热门开篇套路;或粘贴需要分析的网页地址"
-                    className="min-h-20 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
-                  />
-                  <Button
-                    size="sm"
-                    variant="outline"
-                    className="mt-2 w-full"
-                    onClick={handleRunWebDismantlingResearch}
-                    disabled={running || !webResearchInput.trim()}
-                  >
-                    {running ? (
-                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                    ) : (
-                      <Play className="mr-2 h-4 w-4" />
-                    )}
-                    {running ? "分析中..." : "开始网页热门分析"}
-                  </Button>
-                </section>
-
-                <section className="rounded-lg border bg-card">
-                  <div className="flex items-center justify-between border-b px-4 py-2.5">
-                    <div>
-                      <div className="text-sm font-medium">拆分章节</div>
-                      <div className="mt-0.5 text-xs text-muted-foreground">
-                        AI
-                        会按导入内容自动拆分或识别章节结构,请选择本次要拆文的章节范围。
-                      </div>
-                    </div>
-                    <div className="flex items-center gap-3">
-                      <div className="flex gap-1">
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          className="h-7 text-xs"
-                          onClick={() =>
-                            setSelectedChapterIds(
-                              selectedProject.chapters.map(
-                                (chapter) => chapter.id,
-                              ),
-                            )
-                          }
-                        >
-                          全选
-                        </Button>
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          className="h-7 text-xs"
-                          onClick={() => setSelectedChapterIds([])}
-                        >
-                          清空
-                        </Button>
-                      </div>
-                      <label className="flex items-center gap-1.5 text-xs">
-                        <span className="text-muted-foreground">每批</span>
-                        <select
-                          value={batchSize}
-                          onChange={(event) =>
-                            setBatchSize(Number(event.target.value))
-                          }
-                          className="rounded border bg-background px-1.5 py-0.5 text-xs"
-                        >
-                          {[1, 3, 5, 10].map((size) => (
-                            <option key={size} value={size}>
-                              {size}章
-                            </option>
-                          ))}
-                        </select>
-                      </label>
-                    </div>
-                  </div>
-                  <div className="divide-y max-h-[calc(100vh-420px)] min-h-[120px] overflow-y-auto">
-                    {selectedProject.chapters.map((chapter) => (
-                      <label
-                        key={chapter.id}
-                        className="flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm hover:bg-muted/40"
-                      >
-                        <input
-                          type="checkbox"
-                          checked={selectedChapterIds.includes(chapter.id)}
-                          onChange={(event) =>
-                            toggleChapter(chapter.id, event.target.checked)
-                          }
-                        />
-                        <span className="w-14 shrink-0 text-muted-foreground">
-                          第{chapter.chapterNumber}章
-                        </span>
-                        <span className="min-w-0 flex-1 truncate">
-                          {chapter.title}
-                        </span>
-                        <StatusBadge status={chapter.status} />
-                      </label>
-                    ))}
-                  </div>
-                  <div className="border-t px-4 py-3">
-                    <Button
-                      size="sm"
-                      className="w-full"
-                      onClick={handleRunDismantling}
-                      disabled={
-                        running ||
-                        !selectedProject ||
-                        selectedChapterIds.length === 0
-                      }
-                    >
-                      {running ? (
-                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                      ) : (
-                        <Play className="mr-2 h-4 w-4" />
-                      )}
-                      {running
-                        ? "拆文中..."
-                        : selectedChapterIds.length === 0
-                          ? "请先选择章节"
-                          : `开始拆文(${selectedChapterIds.length} 章)`}
-                    </Button>
-                  </div>
-                </section>
-              </div>
-            </>
-          )}
-        </main>
-
-        {/* 第四栏:拆文结果 */}
-        <aside className="min-h-0 overflow-y-auto bg-muted/20 p-4">
-          {!selectedProject ? (
-            <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-              选择作品后,拆文结果将显示在此处。
-            </div>
-          ) : (
-            <div className="space-y-4">
-              <div>
-                <div className="text-sm font-semibold">拆文结果</div>
-                <p className="mt-1 text-xs text-muted-foreground">
-                  这里只展示拆文输出与结构记忆,不写入小说正文、大纲或小说记忆。
-                </p>
-              </div>
-
-              <section className="rounded-lg border bg-card p-3">
-                <div className="mb-2 text-sm font-medium">结构记忆</div>
-                {selectedProject.structureMemory.length === 0 ? (
-                  <div className="text-xs text-muted-foreground">
-                    拆文完成后,这里会显示可供 AI 引用的结构记忆。
-                  </div>
-                ) : (
-                  <ul className="space-y-1.5 text-xs leading-5">
-                    {selectedProject.structureMemory
-                      .slice(0, 30)
-                      .map((item) => (
-                        <li key={item}>- {item}</li>
-                      ))}
-                  </ul>
-                )}
-              </section>
-
-              {/* 剧情框架库展示 */}
-              {(mainFrameworks.length > 0 || subFrameworks.length > 0) && (
-                <section className="rounded-lg border bg-card p-3">
-                  <div className="mb-2 flex items-center gap-2 text-sm font-medium">
-                    <RefreshCw className="h-4 w-4" />
-                    剧情框架库
-                  </div>
-                  <p className="mb-2 text-xs text-muted-foreground">
-                    AI
-                    从拆文中提取的剧情框架(钩子→铺垫→爽点→结尾钩子),可按主线/支线查看。
-                  </p>
-
-                  {mainFrameworks.length > 0 && (
-                    <div className="mb-3">
-                      <div className="mb-1.5 text-xs font-semibold text-foreground">
-                        主线框架({mainFrameworks.length} 个)
-                      </div>
-                      <div className="space-y-2">
-                        {mainFrameworks.map((fw) => (
-                          <div
-                            key={fw.id}
-                            className="rounded border bg-background p-2.5 text-xs"
-                          >
-                            <div className="mb-1 flex items-center justify-between">
-                              <span className="font-medium text-foreground">
-                                {fw.title}
-                              </span>
-                              <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
-                                {fw.pacing === "tight"
-                                  ? "紧凑"
-                                  : fw.pacing === "loose"
-                                    ? "水型"
-                                    : "标准"}
-                              </span>
-                            </div>
-                            <div className="space-y-0.5 text-muted-foreground">
-                              <div>
-                                <span className="text-foreground/70">
-                                  🎣 钩子:
-                                </span>
-                                {fw.beats.hook.slice(0, 60)}
-                                {fw.beats.hook.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  📦 铺垫:
-                                </span>
-                                {fw.beats.buildup.slice(0, 60)}
-                                {fw.beats.buildup.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  💥 爽点:
-                                </span>
-                                {fw.beats.payoff.slice(0, 60)}
-                                {fw.beats.payoff.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  🔄 结尾钩子:
-                                </span>
-                                {fw.beats.endingHook.slice(0, 60)}
-                                {fw.beats.endingHook.length > 60 ? "…" : ""}
-                              </div>
-                            </div>
-                            {fw.reusableTemplate && (
-                              <div className="mt-1 text-[10px] text-muted-foreground italic">
-                                模板:{fw.reusableTemplate}
-                              </div>
-                            )}
-                            {fw.characters.length > 0 && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                涉及角色:{fw.characters.map((c) => `${c.name}(${c.role || "未标注"})`).join("、")}
-                              </div>
-                            )}
-                            {fw.directionHints && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                方向指引:{fw.directionHints.slice(0, 80)}{fw.directionHints.length > 80 ? "…" : ""}
-                              </div>
-                            )}
-                            {fw.handcraftHints && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                发挥空间:{fw.handcraftHints.slice(0, 80)}{fw.handcraftHints.length > 80 ? "…" : ""}
-                              </div>
-                            )}
-                            <Button
-                              size="sm"
-                              variant="outline"
-                              className="mt-2 w-full h-7 text-xs"
-                              onClick={() => setOutlineCreatorState({ open: true, frameworkId: fw.id })}
-                            >
-                              基于此框架创建章纲
-                            </Button>
-                          </div>
-                        ))}
-                      </div>
-                    </div>
-                  )}
-
-                  {subFrameworks.length > 0 && (
-                    <div>
-                      <div className="mb-1.5 text-xs font-semibold text-foreground">
-                        支线框架({subFrameworks.length} 个)
-                      </div>
-                      <div className="space-y-2">
-                        {subFrameworks.map((fw) => (
-                          <div
-                            key={fw.id}
-                            className="rounded border bg-background p-2.5 text-xs"
-                          >
-                            <div className="mb-1 flex items-center justify-between">
-                              <span className="font-medium text-foreground">
-                                {fw.title}
-                              </span>
-                              <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
-                                {fw.pacing === "tight"
-                                  ? "紧凑"
-                                  : fw.pacing === "loose"
-                                    ? "水型"
-                                    : "标准"}
-                              </span>
-                            </div>
-                            <div className="space-y-0.5 text-muted-foreground">
-                              <div>
-                                <span className="text-foreground/70">
-                                  🎣 钩子:
-                                </span>
-                                {fw.beats.hook.slice(0, 60)}
-                                {fw.beats.hook.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  📦 铺垫:
-                                </span>
-                                {fw.beats.buildup.slice(0, 60)}
-                                {fw.beats.buildup.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  💥 爽点:
-                                </span>
-                                {fw.beats.payoff.slice(0, 60)}
-                                {fw.beats.payoff.length > 60 ? "…" : ""}
-                              </div>
-                              <div>
-                                <span className="text-foreground/70">
-                                  🔄 结尾钩子:
-                                </span>
-                                {fw.beats.endingHook.slice(0, 60)}
-                                {fw.beats.endingHook.length > 60 ? "…" : ""}
-                              </div>
-                            </div>
-                            {fw.reusableTemplate && (
-                              <div className="mt-1 text-[10px] text-muted-foreground italic">
-                                模板:{fw.reusableTemplate}
-                              </div>
-                            )}
-                            {fw.characters.length > 0 && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                涉及角色:{fw.characters.map((c) => `${c.name}(${c.role || "未标注"})`).join("、")}
-                              </div>
-                            )}
-                            {fw.directionHints && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                方向指引:{fw.directionHints.slice(0, 80)}{fw.directionHints.length > 80 ? "…" : ""}
-                              </div>
-                            )}
-                            {fw.handcraftHints && (
-                              <div className="mt-1 text-[10px] text-muted-foreground">
-                                发挥空间:{fw.handcraftHints.slice(0, 80)}{fw.handcraftHints.length > 80 ? "…" : ""}
-                              </div>
-                            )}
-                            <Button
-                              size="sm"
-                              variant="outline"
-                              className="mt-2 w-full h-7 text-xs"
-                              onClick={() => setOutlineCreatorState({ open: true, frameworkId: fw.id })}
-                            >
-                              基于此框架创建章纲
-                            </Button>
-                          </div>
-                        ))}
-                      </div>
-                    </div>
-                  )}
-                </section>
-              )}
-
-              <section className="rounded-lg border bg-card p-3">
-                <div className="mb-2 flex items-center gap-2 text-sm font-medium">
-                  <RefreshCw className="h-4 w-4" />
-                  最近拆文结果
-                </div>
-                {selectedProject.analyses.length === 0 ? (
-                  <div className="text-xs text-muted-foreground">
-                    还没有拆文结果。
-                  </div>
-                ) : (
-                  <div className="space-y-3">
-                    {selectedProject.analyses.slice(0, 5).map((analysis) => (
-                      <article
-                        key={analysis.id}
-                        className="rounded-lg border bg-background p-3"
-                      >
-                        <div className="mb-2 flex items-center text-sm font-medium">
-                          {analysis.title}
-                          <Button
-                            size="sm"
-                            variant="outline"
-                            className="ml-2 h-6 text-xs"
-                            onClick={() => setExtractDialogState({ open: true, analysis })}
-                            disabled={running}
-                          >
-                            提取框架
-                          </Button>
-                        </div>
-                        <pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs leading-6 text-muted-foreground">
-                          {analysis.markdown}
-                        </pre>
-                      </article>
-                    ))}
-                  </div>
-                )}
-              </section>
-
-              {status && (
-                <div className="rounded-lg border bg-card p-3 text-xs leading-5 text-muted-foreground">
-                  {status}
-                </div>
-              )}
-            </div>
-          )}
-
-          {extractDialogState.analysis && (
-            <PlotFrameworkExtractDialog
-              open={extractDialogState.open}
-              onOpenChange={(open) => setExtractDialogState((st) => ({ ...st, open }))}
-              analysisMarkdown={extractDialogState.analysis.markdown}
-              sourceDismantlingProjectId={selectedProject?.id ?? ""}
-              sourceDismantlingProjectTitle={selectedProject?.title ?? ""}
-              rangeChapterIds={extractDialogState.analysis.chapterIds}
-              projectTitle={selectedProject?.title ?? ""}
-              chapters={selectedProject?.chapters.filter((ch) => extractDialogState.analysis!.chapterIds.includes(ch.id)) ?? []}
-              onSaved={() => { bumpDataVersion(); }}
-            />
-          )}
-
-          <OutlineCreatorDialog
-            open={outlineCreatorState.open}
-            onOpenChange={(open) => setOutlineCreatorState((st) => ({ ...st, open }))}
-            frameworkId={outlineCreatorState.frameworkId}
-          />
-        </aside>
-      </div>
-    </div>
-  );
-}
-
-function StatusBadge({ status }: { status: DismantlingChapter["status"] }) {
-  const label =
-    status === "done"
-      ? "已拆"
-      : status === "running"
-        ? "拆文中"
-        : status === "failed"
-          ? "失败"
-          : "待拆";
-  return (
-    <span className="rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
-      {label}
-    </span>
-  );
-}
-
-function mergeUnique(items: string[]): string[] {
-  return [...new Set(items.map((item) => item.trim()).filter(Boolean))];
-}

+ 0 - 143
src/components/novel/foreshadowing-panel.tsx

@@ -1,143 +0,0 @@
-import { useEffect, useState } from "react"
-import { useTranslation } from "react-i18next"
-import { Lightbulb, Loader2 } from "lucide-react"
-import { useWikiStore } from "@/stores/wiki-store"
-import { loadForeshadowingTracker, type ForeshadowingStore } from "@/lib/novel/foreshadowing-tracker"
-
-const STATUS_LABELS: Record<string, string> = {
-  planted: "已埋设",
-  advanced: "推进中",
-  resolved: "已回收",
-  abandoned: "已放弃",
-}
-
-const STATUS_COLORS: Record<string, string> = {
-  planted: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
-  advanced: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
-  resolved: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
-  abandoned: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300",
-}
-
-export function ForeshadowingPanel() {
-  const { t } = useTranslation()
-  const project = useWikiStore((s) => s.project)
-  const [store, setStore] = useState<ForeshadowingStore | null>(null)
-  const [loading, setLoading] = useState(true)
-
-  useEffect(() => {
-    if (!project) return
-    setLoading(true)
-    loadForeshadowingTracker(project.path)
-      .then(setStore)
-      .catch(() => setStore(null))
-      .finally(() => setLoading(false))
-  }, [project])
-
-  const unresolved = store?.items.filter(f => f.status !== "resolved" && f.status !== "abandoned") ?? []
-  const resolved = store?.items.filter(f => f.status === "resolved") ?? []
-  const abandoned = store?.items.filter(f => f.status === "abandoned") ?? []
-
-  if (!project) return null
-
-  return (
-    <div className="flex h-full flex-col">
-      <div className="border-b px-4 py-3">
-        <div className="flex items-center gap-2">
-          <Lightbulb className="h-4 w-4 text-amber-500" />
-          <h2 className="text-sm font-semibold">{t("novel.foreshadowing.title")}</h2>
-        </div>
-      </div>
-      <div className="min-h-0 flex-1 overflow-y-auto p-3">
-        {loading ? (
-          <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
-            <Loader2 className="h-4 w-4 animate-spin" />
-            {t("novel.foreshadowing.loading")}
-          </div>
-        ) : !store || store.items.length === 0 ? (
-          <div className="py-8 text-center text-sm text-muted-foreground">
-            {t("novel.foreshadowing.noData")}
-          </div>
-        ) : (
-          <div className="space-y-3">
-            <div className="text-xs text-muted-foreground">
-              {t("novel.foreshadowing.summary", {
-                total: store.items.length,
-                unresolved: unresolved.length,
-                resolved: resolved.length,
-              })}
-            </div>
-            {unresolved.length > 0 && (
-              <div>
-                <h3 className="mb-2 text-xs font-semibold text-amber-600 dark:text-amber-400">
-                  {t("novel.foreshadowing.unresolved")} ({unresolved.length})
-                </h3>
-                <div className="space-y-2">
-                  {unresolved.map((f) => (
-                    <div key={f.id} className="rounded-md border p-2 text-sm">
-                      <div className="flex items-center justify-between">
-                        <span className="font-medium">{f.name}</span>
-                        <span className={`rounded px-1.5 py-0.5 text-xs ${STATUS_COLORS[f.status]}`}>
-                          {STATUS_LABELS[f.status]}
-                        </span>
-                      </div>
-                      {f.description && (
-                        <p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
-                      )}
-                      <p className="mt-1 text-xs text-muted-foreground">
-                        {t("novel.foreshadowing.plantedAt", { chapter: f.plantedChapter })}
-                      </p>
-                    </div>
-                  ))}
-                </div>
-              </div>
-            )}
-            {resolved.length > 0 && (
-              <div>
-                <h3 className="mb-2 text-xs font-semibold text-green-600 dark:text-green-400">
-                  {t("novel.foreshadowing.resolved")} ({resolved.length})
-                </h3>
-                <div className="space-y-2 opacity-60">
-                  {resolved.map((f) => (
-                    <div key={f.id} className="rounded-md border p-2 text-sm">
-                      <div className="flex items-center justify-between">
-                        <span className="font-medium line-through">{f.name}</span>
-                        <span className={`rounded px-1.5 py-0.5 text-xs ${STATUS_COLORS[f.status]}`}>
-                          {STATUS_LABELS[f.status]}
-                        </span>
-                      </div>
-                      <p className="mt-1 text-xs text-muted-foreground">
-                        {t("novel.foreshadowing.resolvedAt", { chapter: f.resolvedChapter ?? "?" })}
-                      </p>
-                    </div>
-                  ))}
-                </div>
-              </div>
-            )}
-            {abandoned.length > 0 && (
-              <div>
-                <h3 className="mb-2 text-xs font-semibold text-zinc-500">
-                  {t("novel.foreshadowing.abandoned", { defaultValue: "已放弃" })} ({abandoned.length})
-                </h3>
-                <div className="space-y-2 opacity-50">
-                  {abandoned.map((f) => (
-                    <div key={f.id} className="rounded-md border p-2 text-sm">
-                      <div className="flex items-center justify-between">
-                        <span className="font-medium">{f.name}</span>
-                        <span className={`rounded px-1.5 py-0.5 text-xs ${STATUS_COLORS[f.status]}`}>
-                          {STATUS_LABELS[f.status]}
-                        </span>
-                      </div>
-                      {f.notes && (
-                        <p className="mt-1 text-xs text-muted-foreground">{f.notes}</p>
-                      )}
-                    </div>
-                  ))}
-                </div>
-              </div>
-            )}
-          </div>
-        )}
-      </div>
-    </div>
-  )
-}

+ 0 - 676
src/components/novel/plot-framework-extract-dialog.tsx

@@ -1,676 +0,0 @@
-/**
- * 剧情框架提取预览对话框
- *
- * 流程:阶段0(从已有拆文结果提取四段)→ 阶段1-4(并行拆解角色/方向/发挥/伏笔)
- *      → 阶段5(合并 agent 输出 JSON)→ 预览编辑 → 入库
- *
- * 各阶段共享 buildDismantlingCachePrefix 章节前缀,命中 API 供应商前缀缓存。
- * 任一阶段失败 → 硬失败,显示原因,不回退、不留空、不降级。
- */
-
-import { useEffect, useRef, useState } from "react"
-import { Loader2, AlertCircle, Plus, X } from "lucide-react";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-  DialogFooter,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
-import { useWikiStore } from "@/stores/wiki-store";
-import { streamChat, type ChatMessage } from "@/lib/llm-client";
-import type { ContentBlock } from "@/lib/llm-providers";
-import { resolveNovelModel } from "@/lib/novel/model-resolver";
-import { hasUsableLlm } from "@/lib/has-usable-llm";
-import {
-  buildDismantlingCachePrefix,
-  extractPlotFrameworkBeatsFromAnalysis,
-  extractPlotFrameworkLineageFromAnalysis,
-  type DismantlingChapter,
-} from "@/lib/novel/dismantling";
-import {
-  buildDismantlingCharacterPrompt,
-  buildDismantlingDirectionPrompt,
-  buildDismantlingHandcraftPrompt,
-  buildDismantlingForeshadowingPrompt,
-  buildFrameworkMergePrompt,
-  extractCharactersFromStage,
-  extractDirectionHintsFromStage,
-  extractHandcraftHintsFromStage,
-  extractForeshadowingFromStage,
-  parseFrameworkMergeOutput,
-  type ParsedFrameworkMerge,
-} from "@/lib/novel/dismantling-stages";
-import { upsertPlotFramework } from "@/lib/novel/plot-framework-library";
-import type { PlotFrameworkCharacter } from "@/lib/novel/plot-framework";
-
-interface PlotFrameworkExtractDialogProps {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  analysisMarkdown: string;
-  sourceDismantlingProjectId: string;
-  sourceDismantlingProjectTitle: string;
-  rangeChapterIds: string[];
-  projectTitle: string;
-  chapters: DismantlingChapter[];
-  onSaved?: () => void;
-}
-
-type ExtractPhase =
-  "idle" | "stage0" | "stages1-4" | "merge" | "preview" | "saving" | "error";
-
-/** 把以 cachePrefix 开头的 user 提示词拆成 [前缀块(cacheControl), 余下块],命中 API 前缀缓存 */
-function buildCachedMessages(
-  prompt: string,
-  cachePrefix: string,
-): ChatMessage[] {
-  const rest = prompt.startsWith(cachePrefix)
-    ? prompt.slice(cachePrefix.length)
-    : prompt;
-  const blocks: ContentBlock[] = [
-    { type: "text", text: cachePrefix, cacheControl: true },
-    ...(rest ? [{ type: "text" as const, text: rest }] : []),
-  ];
-  return [{ role: "user", content: blocks }];
-}
-
-/** 用 streamChat 收集一次完整输出(非流式语义,收集所有 token 拼成字符串) */
-async function collectStageOutput(
-  prompt: string,
-  cachePrefix: string,
-  llmConfig: Parameters<typeof streamChat>[0],
-  signal?: AbortSignal,
-): Promise<string> {
-  const messages = buildCachedMessages(prompt, cachePrefix);
-  let output = "";
-  await new Promise<void>((resolve, reject) => {
-    if (signal?.aborted) {
-      reject(new DOMException("Aborted", "AbortError"));
-      return;
-    }
-    void streamChat(llmConfig, messages, {
-      onToken: (token) => {
-        if (signal?.aborted) return;
-        output += token;
-      },
-      onDone: resolve,
-      onError: reject,
-    });
-    if (signal) {
-      signal.addEventListener("abort", () => {
-        reject(new DOMException("Aborted", "AbortError"));
-      }, { once: true });
-    }
-  });
-  return output;
-}
-
-const PHASE_LABELS: Record<ExtractPhase, string> = {
-  idle: "",
-  stage0: "正在提取四段框架...",
-  "stages1-4": "正在并行拆解角色/方向/发挥/伏笔...",
-  merge: "正在合并框架...",
-  preview: "预览编辑,确认后入库",
-  saving: "正在入库...",
-  error: "失败",
-};
-
-export function PlotFrameworkExtractDialog({
-  open,
-  onOpenChange,
-  analysisMarkdown,
-  sourceDismantlingProjectId,
-  sourceDismantlingProjectTitle,
-  rangeChapterIds,
-  projectTitle,
-  chapters,
-  onSaved,
-}: PlotFrameworkExtractDialogProps) {
-  const project = useWikiStore((state) => state.project);
-  const llmConfig = useWikiStore((state) => state.llmConfig);
-  const novelConfig = useWikiStore((state) => state.novelConfig);
-  const providerConfigs = useWikiStore((state) => state.providerConfigs);
-
-  const [phase, setPhase] = useState<ExtractPhase>("idle");
-  const [error, setError] = useState("");
-  const [progress, setProgress] = useState("");
-  const startedRef = useRef(false);
-  const cancelledRef = useRef(false);
-  const abortControllerRef = useRef<AbortController | null>(null);
-
-  // 预览态表单字段
-  const [title, setTitle] = useState("");
-  const [hook, setHook] = useState("");
-  const [buildup, setBuildup] = useState("");
-  const [payoff, setPayoff] = useState("");
-  const [endingHook, setEndingHook] = useState("");
-  const [characters, setCharacters] = useState<PlotFrameworkCharacter[]>([]);
-  const [foreshadowing, setForeshadowing] = useState<string[]>([]);
-  const [line, setLine] = useState<"main" | "sub">("main");
-  const [reusableTemplate, setReusableTemplate] = useState("");
-  const [directionHints, setDirectionHints] = useState("");
-  const [handcraftHints, setHandcraftHints] = useState("");
-  const [prevConnector, setPrevConnector] = useState("");
-  const [nextConnector, setNextConnector] = useState("");
-
-  // 对话框打开时自动启动提取流程
-  useEffect(() => {
-    if (!open || startedRef.current) return;
-    startedRef.current = true;
-    void runExtraction();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open]);
-
-  // 对话框关闭时重置状态
-  useEffect(() => {
-    if (!open) {
-      startedRef.current = false;
-      setPhase("idle");
-      setError("");
-      setProgress("");
-    }
-  }, [open]);
-
-  async function runExtraction() {
-    try {
-      cancelledRef.current = false;
-      abortControllerRef.current = new AbortController();
-      const signal = abortControllerRef.current.signal;
-
-      // ── 前置校验 ──
-      if (!project) {
-        throw new Error("未打开项目,无法提取框架");
-      }
-      if (!hasUsableLlm(llmConfig, providerConfigs)) {
-        throw new Error("AI 模型未配置或不可用,请先在设置中配置 LLM");
-      }
-      const modelConfig = resolveNovelModel(llmConfig, novelConfig, "extract");
-      const cachePrefix = buildDismantlingCachePrefix(projectTitle, chapters);
-
-      // ── 阶段0:从已有拆文结果提取四段框架(不调 AI) ──
-      setPhase("stage0");
-      setProgress("●○○○○ 正在提取四段框架...");
-      const beats = extractPlotFrameworkBeatsFromAnalysis(analysisMarkdown);
-      if (
-        !beats ||
-        !beats.hook ||
-        !beats.buildup ||
-        !beats.payoff ||
-        !beats.endingHook
-      ) {
-        throw new Error(
-          "阶段0 失败:拆文结果中四段框架不完整(钩子/铺垫/爽点/结尾钩子缺一不可)",
-        );
-      }
-      if (cancelledRef.current) throw new DOMException("Aborted", "AbortError");
-      const lineage = extractPlotFrameworkLineageFromAnalysis(analysisMarkdown);
-
-      // ── 阶段1-4:并行拆解 ──
-      setPhase("stages1-4");
-      setProgress("●●○○○ 正在并行拆解角色/方向/发挥/伏笔...");
-      const [charRaw, dirRaw, craftRaw, foresRaw] = await Promise.all([
-        collectStageOutput(
-          buildDismantlingCharacterPrompt({ projectTitle, chapters }),
-          cachePrefix,
-          modelConfig,
-          signal,
-        ),
-        collectStageOutput(
-          buildDismantlingDirectionPrompt({ projectTitle, chapters }),
-          cachePrefix,
-          modelConfig,
-          signal,
-        ),
-        collectStageOutput(
-          buildDismantlingHandcraftPrompt({ projectTitle, chapters }),
-          cachePrefix,
-          modelConfig,
-          signal,
-        ),
-        collectStageOutput(
-          buildDismantlingForeshadowingPrompt({ projectTitle, chapters }),
-          cachePrefix,
-          modelConfig,
-          signal,
-        ),
-      ]);
-
-      if (cancelledRef.current) throw new DOMException("Aborted", "AbortError");
-      const stageCharacters = extractCharactersFromStage(charRaw);
-      const stageDirection = extractDirectionHintsFromStage(dirRaw);
-      const stageHandcraft = extractHandcraftHintsFromStage(craftRaw);
-      const stageForeshadowing = extractForeshadowingFromStage(foresRaw);
-
-      // ── 阶段5:合并 agent ──
-      setPhase("merge");
-      setProgress("●●●●○ 正在合并框架...");
-      const mergePrompt = buildFrameworkMergePrompt({
-        projectTitle,
-        chapters,
-        stageOutputs: {
-          beats: `## 开局钩子\n${beats.hook}\n## 铺垫\n${beats.buildup}\n## 爽点\n${beats.payoff}\n## 结尾钩子\n${beats.endingHook}`,
-          characters: charRaw,
-          direction: dirRaw,
-          handcraft: craftRaw,
-          foreshadowing: foresRaw,
-        },
-      });
-      const mergeRaw = await collectStageOutput(
-        mergePrompt,
-        cachePrefix,
-        modelConfig,
-        signal,
-      );
-      if (cancelledRef.current) throw new DOMException("Aborted", "AbortError");
-      const merged = parseFrameworkMergeOutput(mergeRaw);
-
-      // ── 预览态:预填表单 ──
-      setPhase("preview");
-      setProgress("●●●●● 预览编辑,确认后入库");
-      fillFormFromMerged(
-        merged,
-        beats.hook,
-        beats.buildup,
-        beats.payoff,
-        beats.endingHook,
-        lineage,
-        stageCharacters,
-        stageDirection,
-        stageHandcraft,
-        stageForeshadowing,
-      );
-    } catch (err) {
-      if ((err as DOMException)?.name === "AbortError") {
-        setPhase("idle");
-        setError("");
-        setProgress("");
-        onOpenChange(false);
-        return;
-      }
-      setPhase("error");
-      setError(err instanceof Error ? err.message : String(err));
-      setProgress("✋ 失败");
-    } finally {
-      abortControllerRef.current = null;
-    }
-  }
-
-  function fillFormFromMerged(
-    merged: ParsedFrameworkMerge,
-    fallbackHook: string,
-    fallbackBuildup: string,
-    fallbackPayoff: string,
-    fallbackEndingHook: string,
-    lineage: ReturnType<typeof extractPlotFrameworkLineageFromAnalysis>,
-    stageCharacters: PlotFrameworkCharacter[],
-    stageDirection: string,
-    stageHandcraft: string,
-    stageForeshadowing: string[],
-  ) {
-    setTitle(merged.title || "未命名剧情框架");
-    setHook(merged.beats.hook || fallbackHook);
-    setBuildup(merged.beats.buildup || fallbackBuildup);
-    setPayoff(merged.beats.payoff || fallbackPayoff);
-    setEndingHook(merged.beats.endingHook || fallbackEndingHook);
-    setCharacters(
-      merged.characters.length > 0 ? merged.characters : stageCharacters,
-    );
-    setForeshadowing(
-      merged.foreshadowing.length > 0
-        ? merged.foreshadowing
-        : stageForeshadowing,
-    );
-    setLine(merged.line);
-    setReusableTemplate(merged.reusableTemplate);
-    setDirectionHints(merged.directionHints || stageDirection);
-    setHandcraftHints(merged.handcraftHints || stageHandcraft);
-    setPrevConnector(merged.prevConnector || lineage?.prevConnector || "");
-    setNextConnector(merged.nextConnector || lineage?.nextConnector || "");
-  }
-
-  function handleRetry() {
-    setPhase("idle");
-    setError("");
-    setProgress("");
-    startedRef.current = false;
-    void runExtraction();
-  }
-
-  function handleCancel() {
-    if (!executing) {
-      onOpenChange(false);
-      return;
-    }
-    cancelledRef.current = true;
-    abortControllerRef.current?.abort();
-  }
-
-  async function handleSave() {
-    if (!project) return;
-    setPhase("saving");
-    setProgress("正在入库...");
-    try {
-      const now = Date.now();
-      await upsertPlotFramework(project.path, {
-        id: `framework-${now}`,
-        title: title.trim() || "未命名剧情框架",
-        beats: {
-          hook: hook.trim(),
-          buildup: buildup.trim(),
-          payoff: payoff.trim(),
-          endingHook: endingHook.trim(),
-        },
-        rangeChapterIds,
-        line,
-        characters,
-        foreshadowing,
-        reusableTemplate: reusableTemplate.trim(),
-        directionHints: directionHints.trim(),
-        handcraftHints: handcraftHints.trim(),
-        prevConnector: prevConnector.trim() || undefined,
-        nextConnector: nextConnector.trim() || undefined,
-        sourceDismantlingProjectId,
-        sourceDismantlingProjectTitle,
-        createdAt: now,
-        updatedAt: now,
-      });
-      onSaved?.();
-      onOpenChange(false);
-    } catch (err) {
-      setPhase("error");
-      setError(err instanceof Error ? err.message : String(err));
-    }
-  }
-
-  function updateCharacter(
-    index: number,
-    field: "name" | "role",
-    value: string,
-  ) {
-    setCharacters((prev) =>
-      prev.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
-    );
-  }
-  function addCharacter() {
-    setCharacters((prev) => [...prev, { name: "", role: "" }]);
-  }
-  function removeCharacter(index: number) {
-    setCharacters((prev) => prev.filter((_, i) => i !== index));
-  }
-  function updateForeshadowing(index: number, value: string) {
-    setForeshadowing((prev) => prev.map((f, i) => (i === index ? value : f)));
-  }
-  function addForeshadowing() {
-    setForeshadowing((prev) => [...prev, ""]);
-  }
-  function removeForeshadowing(index: number) {
-    setForeshadowing((prev) => prev.filter((_, i) => i !== index));
-  }
-
-  const executing =
-    phase === "stage0" ||
-    phase === "stages1-4" ||
-    phase === "merge" ||
-    phase === "saving";
-  const inError = phase === "error";
-  const inPreview = phase === "preview";
-
-  return (
-    <Dialog open={open} onOpenChange={(open) => {
-      if (!open && executing) {
-        handleCancel();
-      } else if (!executing) {
-        onOpenChange(open);
-      }
-    }}>
-      <DialogContent className="sm:max-w-[700px]">
-        <DialogHeader>
-          <DialogTitle>提取剧情框架</DialogTitle>
-        </DialogHeader>
-
-        <div className="max-h-[85vh] overflow-y-auto space-y-4 py-2">
-          {/* 执行中进度 */}
-          {executing && (
-            <div className="flex items-center gap-2 p-3 bg-muted rounded-md text-sm">
-              <Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
-              <span>{progress || PHASE_LABELS[phase]}</span>
-            </div>
-          )}
-
-          {/* 错误态 */}
-          {inError && (
-            <div className="flex items-start gap-2 p-3 bg-destructive/10 text-destructive rounded-md">
-              <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
-              <div className="space-y-1">
-                <p className="text-sm font-medium">提取失败</p>
-                <p className="text-sm whitespace-pre-wrap">{error}</p>
-              </div>
-            </div>
-          )}
-
-          {/* 预览编辑表单 */}
-          {inPreview && (
-            <>
-              <div className="space-y-2">
-                <Label>框架标题</Label>
-                <Input
-                  value={title}
-                  onChange={(e) => setTitle(e.target.value)}
-                  placeholder="一句话总结方向"
-                />
-              </div>
-
-              <div className="grid grid-cols-1 gap-3">
-                <div className="space-y-2">
-                  <Label>开局钩子</Label>
-                  <Textarea
-                    value={hook}
-                    onChange={(e) => setHook(e.target.value)}
-                    rows={2}
-                    className={!hook.trim() ? "border-destructive" : ""}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label>铺垫</Label>
-                  <Textarea
-                    value={buildup}
-                    onChange={(e) => setBuildup(e.target.value)}
-                    rows={2}
-                    className={!buildup.trim() ? "border-destructive" : ""}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label>爽点</Label>
-                  <Textarea
-                    value={payoff}
-                    onChange={(e) => setPayoff(e.target.value)}
-                    rows={2}
-                    className={!payoff.trim() ? "border-destructive" : ""}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label>结尾钩子</Label>
-                  <Textarea
-                    value={endingHook}
-                    onChange={(e) => setEndingHook(e.target.value)}
-                    rows={2}
-                    className={!endingHook.trim() ? "border-destructive" : ""}
-                  />
-                </div>
-              </div>
-
-              {/* 涉及角色 */}
-              <div className="space-y-2">
-                <Label>涉及角色</Label>
-                {characters.map((c, i) => (
-                  <div key={i} className="flex gap-2 items-center">
-                    <Input
-                      value={c.name}
-                      onChange={(e) =>
-                        updateCharacter(i, "name", e.target.value)
-                      }
-                      placeholder="角色名"
-                      className="flex-1"
-                    />
-                    <Input
-                      value={c.role}
-                      onChange={(e) =>
-                        updateCharacter(i, "role", e.target.value)
-                      }
-                      placeholder="在该框架中的作用"
-                      className="flex-[2]"
-                    />
-                    <Button
-                      variant="ghost"
-                      size="icon"
-                      onClick={() => removeCharacter(i)}
-                    >
-                      <X className="h-4 w-4" />
-                    </Button>
-                  </div>
-                ))}
-                <Button variant="outline" size="sm" onClick={addCharacter}>
-                  <Plus className="h-4 w-4 mr-1" /> 添加角色
-                </Button>
-              </div>
-
-              {/* 伏笔 */}
-              <div className="space-y-2">
-                <Label>伏笔</Label>
-                {foreshadowing.map((f, i) => (
-                  <div key={i} className="flex gap-2 items-center">
-                    <Input
-                      value={f}
-                      onChange={(e) => updateForeshadowing(i, e.target.value)}
-                      placeholder="伏笔描述"
-                      className="flex-1"
-                    />
-                    <Button
-                      variant="ghost"
-                      size="icon"
-                      onClick={() => removeForeshadowing(i)}
-                    >
-                      <X className="h-4 w-4" />
-                    </Button>
-                  </div>
-                ))}
-                <Button variant="outline" size="sm" onClick={addForeshadowing}>
-                  <Plus className="h-4 w-4 mr-1" /> 添加伏笔
-                </Button>
-              </div>
-
-              {/* 归属 */}
-              <div className="space-y-2">
-                <Label>归属</Label>
-                <div className="flex gap-4">
-                  <label className="flex items-center gap-2 cursor-pointer">
-                    <input
-                      type="radio"
-                      checked={line === "main"}
-                      onChange={() => setLine("main")}
-                    />
-                    <span className="text-sm">主线</span>
-                  </label>
-                  <label className="flex items-center gap-2 cursor-pointer">
-                    <input
-                      type="radio"
-                      checked={line === "sub"}
-                      onChange={() => setLine("sub")}
-                    />
-                    <span className="text-sm">支线</span>
-                  </label>
-                </div>
-              </div>
-
-              <div className="space-y-2">
-                <Label>一句话可复用模板</Label>
-                <Input
-                  value={reusableTemplate}
-                  onChange={(e) => setReusableTemplate(e.target.value)}
-                  placeholder="如 先压后扬,规则打破"
-                />
-              </div>
-
-              <div className="space-y-2">
-                <Label>方向指引</Label>
-                <Textarea
-                  value={directionHints}
-                  onChange={(e) => setDirectionHints(e.target.value)}
-                  rows={3}
-                />
-              </div>
-
-              <div className="space-y-2">
-                <Label>作者发挥空间提示</Label>
-                <Textarea
-                  value={handcraftHints}
-                  onChange={(e) => setHandcraftHints(e.target.value)}
-                  rows={3}
-                />
-              </div>
-
-              <div className="grid grid-cols-2 gap-3">
-                <div className="space-y-2">
-                  <Label>与上一框架衔接</Label>
-                  <Input
-                    value={prevConnector}
-                    onChange={(e) => setPrevConnector(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label>与下一框架衔接</Label>
-                  <Input
-                    value={nextConnector}
-                    onChange={(e) => setNextConnector(e.target.value)}
-                  />
-                </div>
-              </div>
-            </>
-          )}
-        </div>
-
-        <DialogFooter>
-          {inError && (
-            <>
-              <Button variant="outline" onClick={() => onOpenChange(false)}>
-                关闭
-              </Button>
-              <Button onClick={handleRetry}>
-                重试
-              </Button>
-            </>
-          )}
-          {inPreview && (
-            <>
-              <Button variant="outline" onClick={() => onOpenChange(false)}>
-                取消
-              </Button>
-              <Button
-                onClick={handleSave}
-                disabled={
-                  !hook.trim() ||
-                  !buildup.trim() ||
-                  !payoff.trim() ||
-                  !endingHook.trim() ||
-                  !directionHints.trim() ||
-                  !handcraftHints.trim()
-                }
-              >
-                确认入库
-              </Button>
-            </>
-          )}
-          {executing && (
-            <Button variant="outline" onClick={handleCancel}>
-              取消
-            </Button>
-          )}
-        </DialogFooter>
-      </DialogContent>
-    </Dialog>
-  );
-}

+ 0 - 14
src/components/novel/plot-framework-library-view.layout.spec.ts

@@ -1,14 +0,0 @@
-import { readFileSync } from "node:fs"
-import { resolve } from "node:path"
-import { describe, expect, it } from "vitest"
-
-describe("plot framework library layout", () => {
-  it("keeps the framework list as an internal scroll region", () => {
-    const source = readFileSync(
-      resolve(process.cwd(), "src/components/novel/plot-framework-library-view.tsx"),
-      "utf8",
-    )
-
-    expect(source).toContain('className="min-h-0 flex-1 overflow-y-auto p-4"')
-  })
-})

+ 0 - 1223
src/components/novel/plot-framework-library-view.tsx

@@ -1,1223 +0,0 @@
-import { useEffect, useState, useMemo, useRef } from "react";
-import {
-  Search,
-  Trash2,
-  Edit3,
-  Save,
-  X,
-  BookOpen,
-  GitBranch,
-  Clock,
-  ChevronDown,
-  ChevronUp,
-  ArrowRight,
-  History,
-  Download,
-  Upload,
-  CheckSquare,
-  Square,
-  Layers,
-  Tag as TagIcon,
-  RotateCcw,
-} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Label } from "@/components/ui/label";
-import { useWikiStore } from "@/stores/wiki-store";
-import {
-  loadPlotFrameworkLibrary,
-  removePlotFramework,
-  upsertPlotFramework,
-  manualAdjustPlotFrameworkPacing,
-  removePlotFrameworks,
-  exportPlotFrameworks,
-  importPlotFrameworks,
-  collectAllTags,
-  rollbackPlotFramework,
-  deletePlotFrameworkSnapshot,
-} from "@/lib/novel/plot-framework-library";
-import {
-  listMainLineFrameworks,
-  listSubLineFrameworks,
-  type PlotFramework,
-  type PlotFrameworkCharacter,
-} from "@/lib/novel/plot-framework";
-
-const PACING_LABELS: Record<string, string> = {
-  tight: "紧凑",
-  standard: "标准",
-  loose: "舒展",
-};
-
-const TABS = [
-  { id: "main", label: "主线", icon: BookOpen },
-  { id: "sub", label: "支线", icon: GitBranch },
-  { id: "timeline", label: "主线时间线", icon: History },
-] as const;
-
-type TabId = (typeof TABS)[number]["id"];
-
-export function PlotFrameworkLibraryView() {
-  const project = useWikiStore((state) => state.project);
-  const dataVersion = useWikiStore((state) => state.dataVersion);
-  const bumpDataVersion = useWikiStore((state) => state.bumpDataVersion);
-
-  const [mainFrameworks, setMainFrameworks] = useState<PlotFramework[]>([]);
-  const [subFrameworks, setSubFrameworks] = useState<PlotFramework[]>([]);
-  const [searchQuery, setSearchQuery] = useState("");
-  const [pacingFilter, setPacingFilter] = useState<string>("all");
-  const [activeTab, setActiveTab] = useState<TabId>("main");
-  const [editingId, setEditingId] = useState<string | null>(null);
-  const [expandedId, setExpandedId] = useState<string | null>(null);
-  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
-  const [tagFilter, setTagFilter] = useState<string | null>(null);
-  const [historyViewId, setHistoryViewId] = useState<string | null>(null);
-  const fileInputRef = useRef<HTMLInputElement>(null);
-
-  const [editForm, setEditForm] = useState<{
-    title: string;
-    hook: string;
-    buildup: string;
-    payoff: string;
-    endingHook: string;
-    directionHints: string;
-    handcraftHints: string;
-    reusableTemplate: string;
-    prevConnector: string;
-    nextConnector: string;
-    line: "main" | "sub";
-    characters: PlotFrameworkCharacter[];
-    foreshadowing: string[];
-    tags: string[];
-  } | null>(null);
-
-  useEffect(() => {
-    if (!project) {
-      setMainFrameworks([]);
-      setSubFrameworks([]);
-      return;
-    }
-    let cancelled = false;
-    void loadPlotFrameworkLibrary(project.path).then((library) => {
-      if (cancelled) return;
-      setMainFrameworks(listMainLineFrameworks(library));
-      setSubFrameworks(listSubLineFrameworks(library));
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, [project, dataVersion]);
-
-  const allTags = useMemo(
-    () => collectAllTags([...mainFrameworks, ...subFrameworks]),
-    [mainFrameworks, subFrameworks],
-  );
-
-  const filteredMain = useMemo(() => {
-    let list = mainFrameworks;
-    if (pacingFilter !== "all") {
-      list = list.filter((fw) => fw.pacing === pacingFilter);
-    }
-    if (tagFilter) {
-      list = list.filter((fw) => (fw.tags || []).includes(tagFilter));
-    }
-    if (searchQuery.trim()) {
-      const q = searchQuery.toLowerCase();
-      list = list.filter(
-        (fw) =>
-          fw.title.toLowerCase().includes(q) ||
-          fw.beats.hook.toLowerCase().includes(q) ||
-          fw.beats.buildup.toLowerCase().includes(q) ||
-          fw.beats.payoff.toLowerCase().includes(q) ||
-          fw.beats.endingHook.toLowerCase().includes(q) ||
-          fw.directionHints.toLowerCase().includes(q) ||
-          fw.handcraftHints.toLowerCase().includes(q) ||
-          (fw.tags || []).some((t) => t.toLowerCase().includes(q)) ||
-          fw.characters.some((c) => c.name.toLowerCase().includes(q)),
-      );
-    }
-    return list;
-  }, [mainFrameworks, searchQuery, pacingFilter, tagFilter]);
-
-  const filteredSub = useMemo(() => {
-    let list = subFrameworks;
-    if (pacingFilter !== "all") {
-      list = list.filter((fw) => fw.pacing === pacingFilter);
-    }
-    if (tagFilter) {
-      list = list.filter((fw) => (fw.tags || []).includes(tagFilter));
-    }
-    if (searchQuery.trim()) {
-      const q = searchQuery.toLowerCase();
-      list = list.filter(
-        (fw) =>
-          fw.title.toLowerCase().includes(q) ||
-          fw.beats.hook.toLowerCase().includes(q) ||
-          fw.beats.buildup.toLowerCase().includes(q) ||
-          fw.beats.payoff.toLowerCase().includes(q) ||
-          fw.beats.endingHook.toLowerCase().includes(q) ||
-          fw.directionHints.toLowerCase().includes(q) ||
-          fw.handcraftHints.toLowerCase().includes(q) ||
-          (fw.tags || []).some((t) => t.toLowerCase().includes(q)) ||
-          fw.characters.some((c) => c.name.toLowerCase().includes(q)),
-      );
-    }
-    return list;
-  }, [subFrameworks, searchQuery, pacingFilter, tagFilter]);
-
-  function startEdit(fw: PlotFramework) {
-    setEditingId(fw.id);
-    setEditForm({
-      title: fw.title,
-      hook: fw.beats.hook,
-      buildup: fw.beats.buildup,
-      payoff: fw.beats.payoff,
-      endingHook: fw.beats.endingHook,
-      directionHints: fw.directionHints,
-      handcraftHints: fw.handcraftHints,
-      reusableTemplate: fw.reusableTemplate || "",
-      prevConnector: fw.prevConnector || "",
-      nextConnector: fw.nextConnector || "",
-      line: fw.line,
-      characters: [...fw.characters],
-      foreshadowing: [...fw.foreshadowing],
-      tags: [...(fw.tags || [])],
-    });
-  }
-
-  function cancelEdit() {
-    setEditingId(null);
-    setEditForm(null);
-  }
-
-  async function saveEdit(fw: PlotFramework) {
-    if (!project || !editForm) return;
-    try {
-      await upsertPlotFramework(project.path, {
-        ...fw,
-        title: editForm.title.trim() || "未命名剧情框架",
-        beats: {
-          hook: editForm.hook.trim(),
-          buildup: editForm.buildup.trim(),
-          payoff: editForm.payoff.trim(),
-          endingHook: editForm.endingHook.trim(),
-        },
-        directionHints: editForm.directionHints.trim(),
-        handcraftHints: editForm.handcraftHints.trim(),
-        reusableTemplate: editForm.reusableTemplate.trim(),
-        prevConnector: editForm.prevConnector.trim() || undefined,
-        nextConnector: editForm.nextConnector.trim() || undefined,
-        line: editForm.line,
-        characters: editForm.characters.filter((c) => c.name.trim()),
-        foreshadowing: editForm.foreshadowing.filter((f) => f.trim()),
-        tags: Array.from(new Set(editForm.tags.map((t) => t.trim()).filter(Boolean))),
-        updatedAt: Date.now(),
-      });
-      bumpDataVersion();
-      setEditingId(null);
-      setEditForm(null);
-    } catch (err) {
-      console.error("保存失败:", err);
-    }
-  }
-
-  async function handleDelete(fw: PlotFramework) {
-    if (!project) return;
-    if (!confirm(`确定删除框架「${fw.title}」吗?此操作不可撤销。`)) return;
-    try {
-      await removePlotFramework(project.path, fw.id);
-      bumpDataVersion();
-    } catch (err) {
-      console.error("删除失败:", err);
-    }
-  }
-
-  async function handlePacingChange(fw: PlotFramework, pacing: string) {
-    if (!project) return;
-    try {
-      await manualAdjustPlotFrameworkPacing(project.path, fw.id, pacing as any);
-      bumpDataVersion();
-    } catch (err) {
-      console.error("调整节奏失败:", err);
-    }
-  }
-
-  function toggleSelect(id: string) {
-    const next = new Set(selectedIds);
-    if (next.has(id)) next.delete(id);
-    else next.add(id);
-    setSelectedIds(next);
-  }
-
-  function clearSelection() {
-    setSelectedIds(new Set());
-  }
-
-  async function handleBatchDelete() {
-    if (!project || selectedIds.size === 0) return;
-    if (!confirm(`确定删除选中的 ${selectedIds.size} 个框架吗?此操作不可撤销。`)) return;
-    try {
-      await removePlotFrameworks(project.path, Array.from(selectedIds));
-      bumpDataVersion();
-      setSelectedIds(new Set());
-    } catch (err) {
-      console.error("批量删除失败:", err);
-    }
-  }
-
-  function handleExport(frameworks: PlotFramework[]) {
-    const json = exportPlotFrameworks(frameworks);
-    const blob = new Blob([json], { type: "application/json" });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement("a");
-    a.href = url;
-    a.download = `plot-frameworks-${new Date().toISOString().slice(0, 10)}.json`;
-    a.click();
-    URL.revokeObjectURL(url);
-  }
-
-  function handleExportSelected() {
-    const all = [...mainFrameworks, ...subFrameworks];
-    const selected = all.filter((f) => selectedIds.has(f.id));
-    if (selected.length === 0) {
-      alert("请先选择要导出的框架");
-      return;
-    }
-    handleExport(selected);
-  }
-
-  function handleExportAll() {
-    handleExport([...mainFrameworks, ...subFrameworks]);
-  }
-
-  function triggerImport() {
-    fileInputRef.current?.click();
-  }
-
-  async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
-    if (!project) return;
-    const file = e.target.files?.[0];
-    if (!file) return;
-    try {
-      const text = await file.text();
-      const result = await importPlotFrameworks(project.path, text);
-      alert(`导入完成:成功导入 ${result.imported} 个,跳过 ${result.skipped} 个不完整的框架`);
-      bumpDataVersion();
-    } catch (err: any) {
-      alert(`导入失败:${err.message}`);
-    }
-    e.target.value = "";
-  }
-
-  function toggleExpand(id: string) {
-    setExpandedId(expandedId === id ? null : id);
-  }
-
-  function updateEditCharacter(index: number, field: "name" | "role", value: string) {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      characters: editForm.characters.map((c, i) =>
-        i === index ? { ...c, [field]: value } : c,
-      ),
-    });
-  }
-
-  function addEditCharacter() {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      characters: [...editForm.characters, { name: "", role: "" }],
-    });
-  }
-
-  function removeEditCharacter(index: number) {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      characters: editForm.characters.filter((_, i) => i !== index),
-    });
-  }
-
-  function updateEditForeshadowing(index: number, value: string) {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      foreshadowing: editForm.foreshadowing.map((f, i) =>
-        i === index ? value : f,
-      ),
-    });
-  }
-
-  function addEditForeshadowing() {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      foreshadowing: [...editForm.foreshadowing, ""],
-    });
-  }
-
-  function removeEditForeshadowing(index: number) {
-    if (!editForm) return;
-    setEditForm({
-      ...editForm,
-      foreshadowing: editForm.foreshadowing.filter((_, i) => i !== index),
-    });
-  }
-
-  function addEditTag(value: string) {
-    if (!editForm) return;
-    const tag = value.trim();
-    if (!tag || editForm.tags.includes(tag)) return;
-    setEditForm({ ...editForm, tags: [...editForm.tags, tag] });
-  }
-
-  function removeEditTag(tag: string) {
-    if (!editForm) return;
-    setEditForm({ ...editForm, tags: editForm.tags.filter((t) => t !== tag) });
-  }
-
-  async function handleRollback(fw: PlotFramework, snapshotIndex: number) {
-    if (!project) return;
-    if (!confirm("确定回滚到该历史版本吗?当前版本会作为新快照存入历史。")) return;
-    try {
-      await rollbackPlotFramework(project.path, fw.id, snapshotIndex);
-      bumpDataVersion();
-      setHistoryViewId(null);
-    } catch (err) {
-      console.error("回滚失败:", err);
-    }
-  }
-
-  async function handleDeleteSnapshot(fw: PlotFramework, snapshotIndex: number) {
-    if (!project) return;
-    if (!confirm("确定删除该历史版本吗?")) return;
-    try {
-      await deletePlotFrameworkSnapshot(project.path, fw.id, snapshotIndex);
-      bumpDataVersion();
-    } catch (err) {
-      console.error("删除历史版本失败:", err);
-    }
-  }
-
-  return (
-    <div className="h-full flex flex-col">
-      <div className="flex items-center justify-between px-4 py-3 border-b">
-        <div className="flex items-center gap-3">
-          <div className="flex h-9 w-9 items-center justify-center rounded-md bg-primary/10">
-            <Layers className="h-5 w-5 text-primary" />
-          </div>
-          <div>
-            <h2 className="text-lg font-semibold">剧情框架库</h2>
-            <p className="text-sm text-muted-foreground">
-              共 {mainFrameworks.length + subFrameworks.length} 个框架(主线 {mainFrameworks.length} / 支线 {subFrameworks.length})
-            </p>
-          </div>
-        </div>
-        <div className="flex items-center gap-2">
-          <input
-            ref={fileInputRef}
-            type="file"
-            accept=".json"
-            onChange={handleImportFile}
-            className="hidden"
-          />
-          <Button variant="outline" size="sm" onClick={triggerImport}>
-            <Upload className="h-4 w-4 mr-2" />
-            导入
-          </Button>
-          <Button variant="outline" size="sm" onClick={handleExportAll}>
-            <Download className="h-4 w-4 mr-2" />
-            导出全部
-          </Button>
-        </div>
-      </div>
-
-      <div className="flex items-center gap-2 px-4 py-3 border-b">
-        <div className="relative flex-1">
-          <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
-          <Input
-            value={searchQuery}
-            onChange={(e) => setSearchQuery(e.target.value)}
-            placeholder="搜索框架标题、内容、角色..."
-            className="pl-8"
-          />
-        </div>
-        <select
-          value={pacingFilter}
-          onChange={(e) => setPacingFilter(e.target.value)}
-          className="h-9 rounded-md border border-input bg-background px-3 text-sm"
-        >
-          <option value="all">全部节奏</option>
-          <option value="tight">紧凑</option>
-          <option value="standard">标准</option>
-          <option value="loose">舒展</option>
-        </select>
-      </div>
-
-      {allTags.length > 0 && (
-        <div className="flex items-center gap-2 px-4 py-2 border-b overflow-x-auto">
-          <TagIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
-          <button
-            type="button"
-            onClick={() => setTagFilter(null)}
-            className={`flex-shrink-0 rounded-md px-2 py-0.5 text-xs transition-colors ${
-              tagFilter === null
-                ? "bg-primary text-primary-foreground"
-                : "bg-muted text-muted-foreground hover:bg-accent"
-            }`}
-          >
-            全部
-          </button>
-          {allTags.map(({ tag, count }) => (
-            <button
-              key={tag}
-              type="button"
-              onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
-              className={`flex-shrink-0 rounded-md px-2 py-0.5 text-xs transition-colors ${
-                tagFilter === tag
-                  ? "bg-primary text-primary-foreground"
-                  : "bg-muted text-muted-foreground hover:bg-accent"
-              }`}
-            >
-              {tag} ({count})
-            </button>
-          ))}
-        </div>
-      )}
-
-      {selectedIds.size > 0 && (
-        <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
-          <span className="text-sm">
-            已选择 <span className="font-medium">{selectedIds.size}</span> 个框架
-          </span>
-          <div className="flex items-center gap-2">
-            <Button variant="outline" size="sm" onClick={clearSelection}>
-              取消选择
-            </Button>
-            <Button variant="outline" size="sm" onClick={handleExportSelected}>
-              <Download className="h-4 w-4 mr-2" />
-              导出选中
-            </Button>
-            <Button
-              variant="destructive"
-              size="sm"
-              onClick={handleBatchDelete}
-            >
-              <Trash2 className="h-4 w-4 mr-2" />
-              批量删除
-            </Button>
-          </div>
-        </div>
-      )}
-
-      <div className="flex shrink-0 items-center gap-1 border-b px-4 py-2">
-        {TABS.map((tab) => {
-          const Icon = tab.icon;
-          const count =
-            tab.id === "main"
-              ? filteredMain.length
-              : tab.id === "sub"
-              ? filteredSub.length
-              : null;
-          return (
-            <button
-              key={tab.id}
-              type="button"
-              onClick={() => setActiveTab(tab.id)}
-              className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
-                activeTab === tab.id
-                  ? "bg-primary text-primary-foreground"
-                  : "text-muted-foreground hover:bg-accent hover:text-foreground"
-              }`}
-            >
-              <Icon className="h-4 w-4" />
-              {tab.label}
-              {count !== null && (
-                <span
-                  className={`rounded px-1.5 py-0.5 text-xs ${
-                    activeTab === tab.id
-                      ? "bg-primary-foreground/20"
-                      : "bg-muted"
-                  }`}
-                >
-                  {count}
-                </span>
-              )}
-            </button>
-          );
-        })}
-      </div>
-
-      <div className="min-h-0 flex-1 overflow-y-auto p-4">
-        {activeTab === "main" && (
-          <div className="space-y-3">
-            {filteredMain.length === 0 ? (
-              <EmptyState text="暂无主线剧情框架" />
-            ) : (
-              filteredMain.map((fw) => (
-                <FrameworkCard
-                  key={fw.id}
-                  fw={fw}
-                  expanded={expandedId === fw.id}
-                  editing={editingId === fw.id}
-                  editForm={editingId === fw.id ? editForm : null}
-                  selected={selectedIds.has(fw.id)}
-                  historyOpen={historyViewId === fw.id}
-                  onToggleExpand={() => toggleExpand(fw.id)}
-                  onEdit={() => startEdit(fw)}
-                  onCancelEdit={cancelEdit}
-                  onSaveEdit={() => saveEdit(fw)}
-                  onDelete={() => handleDelete(fw)}
-                  onToggleSelect={() => toggleSelect(fw.id)}
-                  onToggleHistory={() => setHistoryViewId(historyViewId === fw.id ? null : fw.id)}
-                  onRollback={(idx) => handleRollback(fw, idx)}
-                  onDeleteSnapshot={(idx) => handleDeleteSnapshot(fw, idx)}
-                  onPacingChange={(p) => handlePacingChange(fw, p)}
-                  onEditFormChange={setEditForm}
-                  onUpdateCharacter={updateEditCharacter}
-                  onAddCharacter={addEditCharacter}
-                  onRemoveCharacter={removeEditCharacter}
-                  onUpdateForeshadowing={updateEditForeshadowing}
-                  onAddForeshadowing={addEditForeshadowing}
-                  onRemoveForeshadowing={removeEditForeshadowing}
-                  onAddTag={addEditTag}
-                  onRemoveTag={removeEditTag}
-                />
-              ))
-            )}
-          </div>
-        )}
-
-        {activeTab === "sub" && (
-          <div className="space-y-3">
-            {filteredSub.length === 0 ? (
-              <EmptyState text="暂无支线剧情框架" />
-            ) : (
-              filteredSub.map((fw) => (
-                <FrameworkCard
-                  key={fw.id}
-                  fw={fw}
-                  expanded={expandedId === fw.id}
-                  editing={editingId === fw.id}
-                  editForm={editingId === fw.id ? editForm : null}
-                  selected={selectedIds.has(fw.id)}
-                  historyOpen={historyViewId === fw.id}
-                  onToggleExpand={() => toggleExpand(fw.id)}
-                  onEdit={() => startEdit(fw)}
-                  onCancelEdit={cancelEdit}
-                  onSaveEdit={() => saveEdit(fw)}
-                  onDelete={() => handleDelete(fw)}
-                  onToggleSelect={() => toggleSelect(fw.id)}
-                  onToggleHistory={() => setHistoryViewId(historyViewId === fw.id ? null : fw.id)}
-                  onRollback={(idx) => handleRollback(fw, idx)}
-                  onDeleteSnapshot={(idx) => handleDeleteSnapshot(fw, idx)}
-                  onPacingChange={(p) => handlePacingChange(fw, p)}
-                  onEditFormChange={setEditForm}
-                  onUpdateCharacter={updateEditCharacter}
-                  onAddCharacter={addEditCharacter}
-                  onRemoveCharacter={removeEditCharacter}
-                  onUpdateForeshadowing={updateEditForeshadowing}
-                  onAddForeshadowing={addEditForeshadowing}
-                  onRemoveForeshadowing={removeEditForeshadowing}
-                  onAddTag={addEditTag}
-                  onRemoveTag={removeEditTag}
-                />
-              ))
-            )}
-          </div>
-        )}
-
-        {activeTab === "timeline" && (
-          <div>
-            {mainFrameworks.length === 0 ? (
-              <EmptyState text="暂无主线剧情框架,无法生成时间线" />
-            ) : (
-              <MainTimelineView frameworks={mainFrameworks} />
-            )}
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}
-
-function EmptyState({ text }: { text: string }) {
-  return (
-    <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
-      <BookOpen className="h-12 w-12 mb-3 opacity-50" />
-      <p>{text}</p>
-    </div>
-  );
-}
-
-interface FrameworkCardProps {
-  fw: PlotFramework;
-  expanded: boolean;
-  editing: boolean;
-  editForm: any;
-  selected: boolean;
-  historyOpen: boolean;
-  onToggleExpand: () => void;
-  onEdit: () => void;
-  onCancelEdit: () => void;
-  onSaveEdit: () => void;
-  onDelete: () => void;
-  onToggleSelect: () => void;
-  onToggleHistory: () => void;
-  onRollback: (snapshotIndex: number) => void;
-  onDeleteSnapshot: (snapshotIndex: number) => void;
-  onPacingChange: (pacing: string) => void;
-  onEditFormChange: (form: any) => void;
-  onUpdateCharacter: (index: number, field: "name" | "role", value: string) => void;
-  onAddCharacter: () => void;
-  onRemoveCharacter: (index: number) => void;
-  onUpdateForeshadowing: (index: number, value: string) => void;
-  onAddForeshadowing: () => void;
-  onRemoveForeshadowing: (index: number) => void;
-  onAddTag: (value: string) => void;
-  onRemoveTag: (tag: string) => void;
-}
-
-function FrameworkCard({
-  fw,
-  expanded,
-  editing,
-  editForm,
-  selected,
-  historyOpen,
-  onToggleExpand,
-  onEdit,
-  onCancelEdit,
-  onSaveEdit,
-  onDelete,
-  onToggleSelect,
-  onToggleHistory,
-  onRollback,
-  onDeleteSnapshot,
-  onPacingChange,
-  onEditFormChange,
-  onUpdateCharacter,
-  onAddCharacter,
-  onRemoveCharacter,
-  onUpdateForeshadowing,
-  onAddForeshadowing,
-  onRemoveForeshadowing,
-  onAddTag,
-  onRemoveTag,
-}: FrameworkCardProps) {
-  const updatedStr = new Date(fw.updatedAt).toLocaleDateString("zh-CN");
-  const chapterCount = fw.rangeChapterIds?.length || 0;
-
-  return (
-    <div
-      className={`rounded-lg border bg-card text-card-foreground shadow-sm ${
-        selected ? "ring-2 ring-primary" : ""
-      }`}
-    >
-      <div className="flex p-4 pb-2">
-        <div className="flex items-start justify-between w-full">
-          <div className="flex items-start gap-2 flex-1">
-            {!editing && (
-              <button
-                type="button"
-                onClick={onToggleSelect}
-                className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
-              >
-                {selected ? (
-                  <CheckSquare className="h-4 w-4 text-primary" />
-                ) : (
-                  <Square className="h-4 w-4 text-muted-foreground" />
-                )}
-              </button>
-            )}
-            <div className="flex-1 min-w-0">
-              <div className="flex items-center gap-2 flex-wrap">
-                <h3 className="text-base font-semibold leading-none tracking-tight">
-                  {editing ? (
-                    <Input
-                      value={editForm.title}
-                      onChange={(e) =>
-                        onEditFormChange({ ...editForm, title: e.target.value })
-                      }
-                      className="font-semibold"
-                    />
-                  ) : (
-                    fw.title
-                  )}
-                </h3>
-                <span className="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium">
-                  {PACING_LABELS[fw.pacing || "standard"] || "标准"}
-                </span>
-                {chapterCount > 0 && (
-                  <span className="inline-flex items-center rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
-                    {chapterCount} 章
-                  </span>
-                )}
-              </div>
-              <p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
-                <Clock className="h-3 w-3" />
-                更新于 {updatedStr}
-                {fw.sourceDismantlingProjectTitle && (
-                  <span>· 来源:{fw.sourceDismantlingProjectTitle}</span>
-                )}
-              </p>
-            </div>
-          </div>
-          <div className="flex items-center gap-1">
-            {!editing && (
-              <>
-                <Button
-                  variant="ghost"
-                  size="icon"
-                  onClick={onToggleHistory}
-                  title="版本历史"
-                >
-                  <History className={`h-4 w-4 ${historyOpen ? "text-primary" : ""}`} />
-                </Button>
-                <Button variant="ghost" size="icon" onClick={onEdit}>
-                  <Edit3 className="h-4 w-4" />
-                </Button>
-                <Button variant="ghost" size="icon" onClick={onDelete}>
-                  <Trash2 className="h-4 w-4 text-destructive" />
-                </Button>
-                <Button variant="ghost" size="icon" onClick={onToggleExpand}>
-                  {expanded ? (
-                    <ChevronUp className="h-4 w-4" />
-                  ) : (
-                    <ChevronDown className="h-4 w-4" />
-                  )}
-                </Button>
-              </>
-            )}
-          </div>
-        </div>
-      </div>
-
-      {!editing && (fw.tags || []).length > 0 && (
-        <div className="flex items-center gap-1 flex-wrap px-4 pb-2">
-          {(fw.tags || []).map((tag) => (
-            <span
-              key={tag}
-              className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary"
-            >
-              <TagIcon className="h-3 w-3" />
-              {tag}
-            </span>
-          ))}
-        </div>
-      )}
-
-      {historyOpen && !editing && (
-        <HistoryPanel
-          fw={fw}
-          onRollback={onRollback}
-          onDeleteSnapshot={onDeleteSnapshot}
-        />
-      )}
-
-      {(expanded || editing) && (
-        <div className="p-4 pt-2 space-y-4">
-          <div className="grid grid-cols-2 gap-3">
-            <BeatSection
-              label="钩子"
-              value={editing ? editForm.hook : fw.beats.hook}
-              editing={editing}
-              onChange={(v) => onEditFormChange({ ...editForm, hook: v })}
-            />
-            <BeatSection
-              label="发展"
-              value={editing ? editForm.buildup : fw.beats.buildup}
-              editing={editing}
-              onChange={(v) => onEditFormChange({ ...editForm, buildup: v })}
-            />
-            <BeatSection
-              label="爽点"
-              value={editing ? editForm.payoff : fw.beats.payoff}
-              editing={editing}
-              onChange={(v) => onEditFormChange({ ...editForm, payoff: v })}
-            />
-            <BeatSection
-              label="结尾钩子"
-              value={editing ? editForm.endingHook : fw.beats.endingHook}
-              editing={editing}
-              onChange={(v) => onEditFormChange({ ...editForm, endingHook: v })}
-            />
-          </div>
-
-          {(fw.prevConnector || fw.nextConnector || editing) && (
-            <div className="grid grid-cols-2 gap-3 pt-2 border-t">
-              <BeatSection
-                label="衔接上一框架"
-                value={editing ? editForm.prevConnector : fw.prevConnector || ""}
-                editing={editing}
-                onChange={(v) => onEditFormChange({ ...editForm, prevConnector: v })}
-              />
-              <BeatSection
-                label="衔接下一框架"
-                value={editing ? editForm.nextConnector : fw.nextConnector || ""}
-                editing={editing}
-                onChange={(v) => onEditFormChange({ ...editForm, nextConnector: v })}
-              />
-            </div>
-          )}
-
-          <div className="space-y-2 pt-2 border-t">
-            <div className="flex items-center justify-between">
-              <Label>节奏</Label>
-              {!editing && (
-                <select
-                  value={fw.pacing || "standard"}
-                  onChange={(e) => onPacingChange(e.target.value)}
-                  className="h-7 rounded-md border border-input bg-background px-2 text-xs"
-                >
-                  <option value="tight">紧凑</option>
-                  <option value="standard">标准</option>
-                  <option value="loose">舒展</option>
-                </select>
-              )}
-              {editing && (
-                <select
-                  value={editForm.line}
-                  onChange={(e) =>
-                    onEditFormChange({ ...editForm, line: e.target.value as "main" | "sub" })
-                  }
-                  className="h-7 rounded-md border border-input bg-background px-2 text-xs"
-                >
-                  <option value="main">主线</option>
-                  <option value="sub">支线</option>
-                </select>
-              )}
-            </div>
-            {!editing && fw.autoPacing && (
-              <p className="text-xs text-muted-foreground">
-                (AI 自动判定,可手动调整)
-              </p>
-            )}
-          </div>
-
-          {(fw.directionHints || fw.handcraftHints || fw.reusableTemplate || editing) && (
-            <div className="space-y-3 pt-2 border-t">
-              <BeatSection
-                label="方向提示"
-                value={editing ? editForm.directionHints : fw.directionHints}
-                editing={editing}
-                onChange={(v) => onEditFormChange({ ...editForm, directionHints: v })}
-              />
-              <BeatSection
-                label="手工提示"
-                value={editing ? editForm.handcraftHints : fw.handcraftHints}
-                editing={editing}
-                onChange={(v) => onEditFormChange({ ...editForm, handcraftHints: v })}
-              />
-              <BeatSection
-                label="可复用模板"
-                value={editing ? editForm.reusableTemplate : fw.reusableTemplate || ""}
-                editing={editing}
-                onChange={(v) => onEditFormChange({ ...editForm, reusableTemplate: v })}
-              />
-            </div>
-          )}
-
-          {(fw.characters.length > 0 || editing) && (
-            <div className="space-y-2 pt-2 border-t">
-              <Label>角色</Label>
-              {(editing ? editForm.characters : fw.characters).map((c: any, i: number) => (
-                <div key={i} className="flex gap-2 items-center">
-                  <Input
-                    value={c.name}
-                    onChange={(e) => onUpdateCharacter(i, "name", e.target.value)}
-                    placeholder="角色名"
-                    className="flex-1"
-                    readOnly={!editing}
-                  />
-                  <Input
-                    value={c.role}
-                    onChange={(e) => onUpdateCharacter(i, "role", e.target.value)}
-                    placeholder="在该框架中的作用"
-                    className="flex-[2]"
-                    readOnly={!editing}
-                  />
-                  {editing && (
-                    <Button
-                      variant="ghost"
-                      size="icon"
-                      onClick={() => onRemoveCharacter(i)}
-                    >
-                      <X className="h-4 w-4" />
-                    </Button>
-                  )}
-                </div>
-              ))}
-              {editing && (
-                <Button variant="outline" size="sm" onClick={onAddCharacter}>
-                  添加角色
-                </Button>
-              )}
-            </div>
-          )}
-
-          {(fw.foreshadowing.length > 0 || editing) && (
-            <div className="space-y-2 pt-2 border-t">
-              <Label>伏笔</Label>
-              {(editing ? editForm.foreshadowing : fw.foreshadowing).map((f: string, i: number) => (
-                <div key={i} className="flex gap-2 items-center">
-                  <Input
-                    value={f}
-                    onChange={(e) => onUpdateForeshadowing(i, e.target.value)}
-                    placeholder="伏笔描述"
-                    className="flex-1"
-                    readOnly={!editing}
-                  />
-                  {editing && (
-                    <Button
-                      variant="ghost"
-                      size="icon"
-                      onClick={() => onRemoveForeshadowing(i)}
-                    >
-                      <X className="h-4 w-4" />
-                    </Button>
-                  )}
-                </div>
-              ))}
-              {editing && (
-                <Button variant="outline" size="sm" onClick={onAddForeshadowing}>
-                  添加伏笔
-                </Button>
-              )}
-            </div>
-          )}
-
-          {editing && (
-            <div className="space-y-2 pt-2 border-t">
-              <Label>标签</Label>
-              <div className="flex items-center gap-1 flex-wrap">
-                {editForm.tags.map((tag: string) => (
-                  <span
-                    key={tag}
-                    className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary"
-                  >
-                    <TagIcon className="h-3 w-3" />
-                    {tag}
-                    <button
-                      type="button"
-                      onClick={() => onRemoveTag(tag)}
-                      className="ml-0.5 hover:text-destructive"
-                    >
-                      <X className="h-3 w-3" />
-                    </button>
-                  </span>
-                ))}
-                {editForm.tags.length === 0 && (
-                  <span className="text-xs text-muted-foreground">暂无标签</span>
-                )}
-              </div>
-              <TagInput onAdd={onAddTag} />
-            </div>
-          )}
-
-          {editing && (
-            <div className="flex justify-end gap-2 pt-2 border-t">
-              <Button variant="outline" size="sm" onClick={onCancelEdit}>
-                <X className="h-4 w-4 mr-2" />
-                取消
-              </Button>
-              <Button size="sm" onClick={onSaveEdit}>
-                <Save className="h-4 w-4 mr-2" />
-                保存
-              </Button>
-            </div>
-          )}
-        </div>
-      )}
-    </div>
-  );
-}
-
-function BeatSection({
-  label,
-  value,
-  editing,
-  onChange,
-}: {
-  label: string;
-  value: string;
-  editing: boolean;
-  onChange: (v: string) => void;
-}) {
-  return (
-    <div className="space-y-1">
-      <Label className="text-xs text-muted-foreground">{label}</Label>
-      {editing ? (
-        <Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={2} />
-      ) : (
-        <p className="text-sm whitespace-pre-wrap">{value}</p>
-      )}
-    </div>
-  );
-}
-
-function MainTimelineView({ frameworks }: { frameworks: PlotFramework[] }) {
-  const sorted = [...frameworks].sort((a, b) => a.createdAt - b.createdAt);
-
-  return (
-    <div className="relative">
-      <div className="absolute left-[22px] top-2 bottom-2 w-0.5 bg-border" />
-
-      <div className="space-y-4">
-        {sorted.map((fw, index) => (
-          <div key={fw.id} className="relative flex gap-4">
-            <div className="relative z-10 flex h-[44px] w-[44px] flex-shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
-              {index + 1}
-            </div>
-
-            <div className="flex-1 min-w-0">
-              <div className="rounded-lg border bg-card p-4">
-                <div className="flex items-start justify-between gap-2 mb-2">
-                  <div>
-                    <h4 className="font-medium text-sm">{fw.title}</h4>
-                    <div className="flex items-center gap-2 mt-1">
-                      <span className="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium">
-                        {PACING_LABELS[fw.pacing || "standard"] || "标准"}
-                      </span>
-                      {fw.rangeChapterIds?.length > 0 && (
-                        <span className="text-xs text-muted-foreground">
-                          {fw.rangeChapterIds.length} 章
-                        </span>
-                      )}
-                    </div>
-                  </div>
-                  {index < sorted.length - 1 && (
-                    <ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
-                  )}
-                </div>
-
-                <p className="text-sm text-muted-foreground line-clamp-2 mb-2">
-                  <span className="text-muted-foreground/70">钩子:</span>
-                  {fw.beats.hook}
-                </p>
-                <p className="text-sm text-muted-foreground line-clamp-2">
-                  <span className="text-muted-foreground/70">爽点:</span>
-                  {fw.beats.payoff}
-                </p>
-
-                {(fw.prevConnector || fw.nextConnector) && (
-                  <div className="mt-3 pt-3 border-t grid grid-cols-2 gap-3 text-xs">
-                    {fw.prevConnector && (
-                      <div>
-                        <span className="text-muted-foreground">↩ 衔接上一框架:</span>
-                        <p className="mt-0.5">{fw.prevConnector}</p>
-                      </div>
-                    )}
-                    {fw.nextConnector && (
-                      <div>
-                        <span className="text-muted-foreground">↪ 衔接下一框架:</span>
-                        <p className="mt-0.5">{fw.nextConnector}</p>
-                      </div>
-                    )}
-                  </div>
-                )}
-              </div>
-
-              {index < sorted.length - 1 && <div className="h-4" />}
-            </div>
-          </div>
-        ))}
-      </div>
-    </div>
-  );
-}
-
-function TagInput({ onAdd }: { onAdd: (value: string) => void }) {
-  const [value, setValue] = useState("");
-
-  function handleSubmit(e: React.FormEvent) {
-    e.preventDefault();
-    if (!value.trim()) return;
-    onAdd(value);
-    setValue("");
-  }
-
-  return (
-    <form onSubmit={handleSubmit} className="flex gap-2">
-      <Input
-        value={value}
-        onChange={(e) => setValue(e.target.value)}
-        placeholder="输入标签后回车添加"
-        className="flex-1"
-      />
-      <Button type="submit" variant="outline" size="sm">
-        添加
-      </Button>
-    </form>
-  );
-}
-
-function HistoryPanel({
-  fw,
-  onRollback,
-  onDeleteSnapshot,
-}: {
-  fw: PlotFramework;
-  onRollback: (snapshotIndex: number) => void;
-  onDeleteSnapshot: (snapshotIndex: number) => void;
-}) {
-  const history = fw.history || [];
-
-  return (
-    <div className="mx-4 mb-3 rounded-md border bg-muted/30 p-3">
-      <div className="flex items-center gap-2 mb-2">
-        <History className="h-4 w-4 text-muted-foreground" />
-        <span className="text-sm font-medium">版本历史</span>
-        <span className="text-xs text-muted-foreground">({history.length})</span>
-      </div>
-
-      {history.length === 0 ? (
-        <p className="text-xs text-muted-foreground py-2">
-          暂无历史版本。编辑保存框架后,上一版本会自动存入历史。
-        </p>
-      ) : (
-        <div className="space-y-2 max-h-60 overflow-y-auto">
-          {history.map((snap, idx) => (
-            <div
-              key={idx}
-              className="rounded-md border bg-card p-2 text-xs"
-            >
-              <div className="flex items-center justify-between mb-1">
-                <span className="font-medium">{snap.title}</span>
-                <span className="text-muted-foreground">
-                  {new Date(snap.savedAt).toLocaleString("zh-CN")}
-                </span>
-              </div>
-              <p className="text-muted-foreground line-clamp-1 mb-1">
-                钩子:{snap.beats.hook}
-              </p>
-              <p className="text-muted-foreground line-clamp-1 mb-2">
-                爽点:{snap.beats.payoff}
-              </p>
-              <div className="flex items-center gap-2">
-                <button
-                  type="button"
-                  onClick={() => onRollback(idx)}
-                  className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20"
-                >
-                  <RotateCcw className="h-3 w-3" />
-                  回滚到此版本
-                </button>
-                <button
-                  type="button"
-                  onClick={() => onDeleteSnapshot(idx)}
-                  className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
-                >
-                  <Trash2 className="h-3 w-3" />
-                  删除
-                </button>
-              </div>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  );
-}

+ 0 - 169
src/components/novel/retrieval-status-indicator.tsx

@@ -1,169 +0,0 @@
-import { useState } from "react"
-import { AlertTriangle, RefreshCw, Database, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-
-export interface OutdatedStatus {
-  total: number
-  outdated: number
-  maybeOutdated: number
-  conflict: number
-  chapterNumbers: {
-    maybeOutdated: number[]
-    conflict: number[]
-  }
-}
-
-interface RetrievalStatusIndicatorProps {
-  status: OutdatedStatus | null
-  onRefresh: () => void
-  isRefreshing?: boolean
-}
-
-export function RetrievalStatusIndicator({ status, onRefresh, isRefreshing = false }: RetrievalStatusIndicatorProps) {
-  const [expanded, setExpanded] = useState(false)
-
-  if (!status || status.outdated === 0) {
-    return null
-  }
-
-  const hasConflict = status.conflict > 0
-  const hasMaybeOutdated = status.maybeOutdated > 0
-
-  const tooltipText = [
-    hasMaybeOutdated ? `${status.maybeOutdated} 章索引可能过期` : null,
-    hasConflict ? `${status.conflict} 章存在冲突` : null,
-  ].filter(Boolean).join(",")
-
-  return (
-    <TooltipProvider delay={200}>
-      <div className="relative">
-        <Tooltip>
-          <TooltipTrigger
-            render={(
-              <button
-                type="button"
-                onClick={() => setExpanded(!expanded)}
-                className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-accent"
-                aria-label="检索索引状态"
-              >
-                <div className="relative">
-                  <Database className={`h-4 w-4 ${hasConflict ? "text-amber-600" : "text-amber-500"}`} />
-                  <span
-                    className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ring-1 ring-background ${
-                      hasConflict ? "bg-red-500" : "bg-amber-500"
-                    }`}
-                  />
-                </div>
-              </button>
-            )}
-          />
-          <TooltipContent side="top" className="max-w-xs leading-5">
-            {tooltipText}
-          </TooltipContent>
-        </Tooltip>
-
-        {expanded && (
-          <div className="absolute bottom-full right-0 mb-2 w-72 rounded-lg border bg-background p-3 shadow-lg">
-            <div className="mb-2 flex items-center justify-between">
-              <div className="flex items-center gap-2">
-                <AlertTriangle className="h-4 w-4 text-amber-500" />
-                <span className="text-sm font-medium">检索索引状态</span>
-              </div>
-              <button
-                type="button"
-                onClick={() => setExpanded(false)}
-                className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
-                aria-label="关闭"
-              >
-                <X className="h-3.5 w-3.5" />
-              </button>
-            </div>
-
-            <div className="mb-3 space-y-1.5 text-xs text-muted-foreground">
-              <div className="flex justify-between">
-                <span>索引总章数</span>
-                <span className="font-medium text-foreground">{status.total} 章</span>
-              </div>
-              {hasMaybeOutdated && (
-                <div className="flex justify-between">
-                  <span className="flex items-center gap-1.5">
-                    <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
-                    可能过期
-                  </span>
-                  <span className="font-medium text-amber-600">{status.maybeOutdated} 章</span>
-                </div>
-              )}
-              {hasConflict && (
-                <div className="flex justify-between">
-                  <span className="flex items-center gap-1.5">
-                    <span className="h-1.5 w-1.5 rounded-full bg-red-500" />
-                    存在冲突
-                  </span>
-                  <span className="font-medium text-red-600">{status.conflict} 章</span>
-                </div>
-              )}
-            </div>
-
-            {(hasMaybeOutdated || hasConflict) && (
-              <div className="mb-3 max-h-32 overflow-y-auto rounded-md border bg-muted/20 p-2 text-xs">
-                {hasMaybeOutdated && (
-                  <div className="mb-2">
-                    <div className="mb-1 font-medium text-amber-700 dark:text-amber-400">可能过期:</div>
-                    <div className="flex flex-wrap gap-1">
-                      {status.chapterNumbers.maybeOutdated.slice(0, 20).map((ch) => (
-                        <span
-                          key={ch}
-                          className="rounded-full border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
-                        >
-                          第{ch}章
-                        </span>
-                      ))}
-                      {status.chapterNumbers.maybeOutdated.length > 20 && (
-                        <span className="text-[10px] text-muted-foreground">
-                          +{status.chapterNumbers.maybeOutdated.length - 20} 章
-                        </span>
-                      )}
-                    </div>
-                  </div>
-                )}
-                {hasConflict && (
-                  <div>
-                    <div className="mb-1 font-medium text-red-700 dark:text-red-400">存在冲突:</div>
-                    <div className="flex flex-wrap gap-1">
-                      {status.chapterNumbers.conflict.slice(0, 20).map((ch) => (
-                        <span
-                          key={ch}
-                          className="rounded-full border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300"
-                        >
-                          第{ch}章
-                        </span>
-                      ))}
-                      {status.chapterNumbers.conflict.length > 20 && (
-                        <span className="text-[10px] text-muted-foreground">
-                          +{status.chapterNumbers.conflict.length - 20} 章
-                        </span>
-                      )}
-                    </div>
-                  </div>
-                )}
-              </div>
-            )}
-
-            <Button
-              type="button"
-              variant="outline"
-              size="sm"
-              onClick={onRefresh}
-              disabled={isRefreshing}
-              className="w-full gap-2"
-            >
-              <RefreshCw className={`h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`} />
-              {isRefreshing ? "重新生成中..." : "重新生成索引"}
-            </Button>
-          </div>
-        )}
-      </div>
-    </TooltipProvider>
-  )
-}

+ 0 - 326
src/components/novel/story-simulation/clue-board-panel.tsx

@@ -1,326 +0,0 @@
-import { useState, useMemo } from "react"
-import { Search, Link2 } from "lucide-react"
-import type { RumorEvent, NovelAgent } from "@/lib/novel/story-simulation/types"
-
-interface ClueItem {
-  id: string
-  content: string
-  type: "confirmed" | "rumor" | "observed" | "told"
-  source: string
-  round: number
-  agentId: string
-  agentName: string
-}
-
-interface ClueBoardPanelProps {
-  agents: Map<string, NovelAgent>
-  rumors: RumorEvent[]
-}
-
-const STOP_WORDS = new Set(["的", "了", "是", "在", "有", "和", "与", "等", "也", "都", "就", "不", "我", "你", "他", "她", "它", "们", "这", "那", "个", "一", "之", "而", "于", "上", "下", "中", "里", "外", "前", "后", "左", "右", "大", "小", "多", "少", "很", "太", "最", "更", "还", "又", "再", "已", "曾", "将", "要", "会", "能", "可", "以", "为", "因", "由", "从", "到", "向", "对", "于", "把", "被", "让", "使", "给", "替", "比", "跟", "同", "和", "及", "或", "但", "而", "且", "并", "然", "则", "虽", "若", "如", "假", "使", "令", "叫", "让", "请", "求", "找", "寻", "查", "看", "听", "说", "讲", "谈", "论", "想", "思", "念", "忘", "记", "知", "道", "明", "白", "清", "楚", "懂", "会", "能", "可", "行", "成", "败", "好", "坏", "对", "错", "真", "假", "新", "旧", "老", "少", "男", "女", "人", "事", "物", "地", "方", "时", "间", "年", "月", "日", "天", "夜", "早", "晚", "今", "明", "昨", "前", "后"])
-
-function extractKeywords(text: string): Set<string> {
-  const keywords = new Set<string>()
-  const chars = text.split("")
-  for (let i = 0; i < chars.length; i++) {
-    const char = chars[i]
-    if (STOP_WORDS.has(char)) continue
-    if (/[\u4e00-\u9fa5]/.test(char)) {
-      keywords.add(char)
-      if (i + 1 < chars.length && /[\u4e00-\u9fa5]/.test(chars[i + 1]) && !STOP_WORDS.has(chars[i] + chars[i + 1])) {
-        const bigram = chars[i] + chars[i + 1]
-        if (!STOP_WORDS.has(bigram)) {
-          keywords.add(bigram)
-        }
-      }
-    }
-  }
-  return keywords
-}
-
-function calcSimilarity(a: Set<string>, b: Set<string>): number {
-  if (a.size === 0 || b.size === 0) return 0
-  let intersection = 0
-  for (const kw of a) {
-    if (b.has(kw)) intersection++
-  }
-  const union = a.size + b.size - intersection
-  return union > 0 ? intersection / union : 0
-}
-
-function getTypeLabel(type: ClueItem["type"]): string {
-  switch (type) {
-    case "confirmed":
-      return "调查证实"
-    case "rumor":
-      return "传闻得知"
-    case "observed":
-      return "亲眼所见"
-    case "told":
-      return "他人告知"
-  }
-}
-
-function getTypeColor(type: ClueItem["type"]): string {
-  switch (type) {
-    case "confirmed":
-      return "border-blue-400 bg-blue-50 dark:border-blue-600 dark:bg-blue-950/30"
-    case "rumor":
-      return "border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-950/30"
-    case "observed":
-      return "border-emerald-400 bg-emerald-50 dark:border-emerald-600 dark:bg-emerald-950/30"
-    case "told":
-      return "border-purple-400 bg-purple-50 dark:border-purple-600 dark:bg-purple-950/30"
-  }
-}
-
-function getTypeIcon(type: ClueItem["type"]): string {
-  switch (type) {
-    case "confirmed":
-      return "🔵"
-    case "rumor":
-      return "🟡"
-    case "observed":
-      return "🟢"
-    case "told":
-      return "🟣"
-  }
-}
-
-export function ClueBoardPanel({ agents, rumors }: ClueBoardPanelProps) {
-  const agentList = useMemo(() => Array.from(agents.values()), [agents])
-  const [selectedAgentId, setSelectedAgentId] = useState<string | null>(
-    agentList.length > 0 ? agentList[0].characterId : null,
-  )
-  const [selectedClueId, setSelectedClueId] = useState<string | null>(null)
-
-  const visibleRumorsByAgent = useMemo(() => {
-    const map = new Map<string, RumorEvent[]>()
-    for (const agent of agentList) {
-      map.set(agent.characterId, [])
-    }
-    for (const rumor of rumors) {
-      for (const agentId of rumor.observableBy) {
-        if (!map.has(agentId)) map.set(agentId, [])
-        map.get(agentId)!.push(rumor)
-      }
-    }
-    return map
-  }, [rumors, agentList])
-
-  const cluesByAgent = useMemo(() => {
-    const map = new Map<string, ClueItem[]>()
-    for (const agent of agentList) {
-      const clueList: ClueItem[] = []
-      const knownSecrets = agent.memory?.knownSecrets ?? new Set<string>()
-      let idx = 0
-      for (const secret of knownSecrets) {
-        clueList.push({
-          id: `${agent.characterId}-confirmed-${idx++}`,
-          content: secret,
-          type: "confirmed",
-          source: "调查证实",
-          round: 0,
-          agentId: agent.characterId,
-          agentName: agent.name,
-        })
-      }
-      const visibleRumors = visibleRumorsByAgent.get(agent.characterId) ?? []
-      for (const rumor of visibleRumors) {
-        if (rumor.verifiedBy.includes(agent.characterId)) continue
-        clueList.push({
-          id: `${agent.characterId}-rumor-${rumor.id}`,
-          content: rumor.content,
-          type: "rumor",
-          source: `传闻(第${rumor.round + 1}轮)`,
-          round: rumor.round,
-          agentId: agent.characterId,
-          agentName: agent.name,
-        })
-      }
-      map.set(agent.characterId, clueList)
-    }
-    return map
-  }, [agentList, visibleRumorsByAgent])
-
-  const currentClues = useMemo(() => {
-    if (!selectedAgentId) return []
-    return cluesByAgent.get(selectedAgentId) ?? []
-  }, [selectedAgentId, cluesByAgent])
-
-  const selectedClue = useMemo(() => {
-    if (!selectedClueId) return null
-    return currentClues.find((c) => c.id === selectedClueId) ?? null
-  }, [selectedClueId, currentClues])
-
-  const relatedClues = useMemo(() => {
-    if (!selectedClue || currentClues.length <= 1) return []
-    const selectedKeywords = extractKeywords(selectedClue.content)
-    const others = currentClues.filter((c) => c.id !== selectedClue.id)
-    const withScore = others.map((clue) => {
-      const kw = extractKeywords(clue.content)
-      const sharedCount = Array.from(selectedKeywords).filter((k) => kw.has(k)).length
-      const similarity = calcSimilarity(selectedKeywords, kw)
-      return { clue, sharedCount, similarity }
-    })
-    return withScore
-      .filter((item) => item.sharedCount >= 2)
-      .sort((a, b) => b.similarity - a.similarity)
-      .slice(0, 5)
-  }, [selectedClue, currentClues])
-
-  if (agentList.length === 0) {
-    return (
-      <div className="flex h-full items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
-        暂无角色数据
-      </div>
-    )
-  }
-
-  return (
-    <div className="flex h-full min-h-0 flex-col gap-3">
-      <div className="shrink-0">
-        <div className="mb-2 flex items-center gap-2 overflow-x-auto pb-1">
-          {agentList.map((agent) => {
-            const clueCount = cluesByAgent.get(agent.characterId)?.length ?? 0
-            const isActive = selectedAgentId === agent.characterId
-            return (
-              <button
-                key={agent.characterId}
-                type="button"
-                onClick={() => {
-                  setSelectedAgentId(agent.characterId)
-                  setSelectedClueId(null)
-                }}
-                className={`shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
-                  isActive
-                    ? "border-primary bg-primary text-primary-foreground"
-                    : "bg-background/70 hover:bg-muted/50"
-                }`}
-              >
-                <span>{agent.name}</span>
-                <span className={`ml-1.5 rounded px-1.5 py-0.5 text-[10px] ${
-                  isActive
-                    ? "bg-primary-foreground/20 text-primary-foreground"
-                    : "bg-muted text-muted-foreground"
-                }`}>
-                  {clueCount}
-                </span>
-              </button>
-            )
-          })}
-        </div>
-
-        <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
-          <span className="flex items-center gap-1">
-            <span className="text-sm">🟢</span>
-            <span>亲眼所见</span>
-          </span>
-          <span className="flex items-center gap-1">
-            <span className="text-sm">🔵</span>
-            <span>调查证实</span>
-          </span>
-          <span className="flex items-center gap-1">
-            <span className="text-sm">🟡</span>
-            <span>传闻得知</span>
-          </span>
-          <span className="flex items-center gap-1">
-            <span className="text-sm">🟣</span>
-            <span>他人告知</span>
-          </span>
-        </div>
-      </div>
-
-      <div className="min-h-0 flex-1 overflow-y-auto">
-        {currentClues.length === 0 ? (
-          <div className="flex h-full items-center justify-center rounded-md border bg-muted/20 p-6 text-center text-xs text-muted-foreground">
-            <div>
-              <Search className="mx-auto mb-2 h-6 w-6 opacity-50" />
-              <div>该角色暂无线索</div>
-            </div>
-          </div>
-        ) : (
-          <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
-            {currentClues.map((clue) => (
-              <button
-                key={clue.id}
-                type="button"
-                onClick={() => setSelectedClueId(clue.id)}
-                className={`rounded-md border-2 p-2.5 text-left transition-all ${
-                  selectedClueId === clue.id
-                    ? "ring-2 ring-primary ring-offset-1"
-                    : "hover:shadow-sm"
-                } ${getTypeColor(clue.type)}`}
-              >
-                <div className="mb-1 flex items-center justify-between gap-1">
-                  <span className="text-[10px] font-medium">
-                    {getTypeIcon(clue.type)} {getTypeLabel(clue.type)}
-                  </span>
-                  <span className="text-[10px] text-muted-foreground">
-                    R{clue.round + 1}
-                  </span>
-                </div>
-                <div className="line-clamp-3 text-xs leading-relaxed">
-                  {clue.content}
-                </div>
-              </button>
-            ))}
-          </div>
-        )}
-      </div>
-
-      {selectedClue && (
-        <div className="shrink-0 rounded-md border bg-background/80 p-3">
-          <div className="mb-2 flex items-start justify-between gap-2">
-            <div className="flex items-center gap-2">
-              <span className="text-base">{getTypeIcon(selectedClue.type)}</span>
-              <span className="text-sm font-medium">
-                {getTypeLabel(selectedClue.type)}
-              </span>
-              <span className="text-[11px] text-muted-foreground">
-                · {selectedClue.source}
-              </span>
-            </div>
-            <button
-              type="button"
-              onClick={() => setSelectedClueId(null)}
-              className="text-[11px] text-muted-foreground hover:text-foreground"
-            >
-              收起
-            </button>
-          </div>
-          <div className="mb-3 rounded-md border bg-muted/20 p-2.5 text-xs leading-relaxed">
-            {selectedClue.content}
-          </div>
-
-          {relatedClues.length > 0 && (
-            <div>
-              <div className="mb-1.5 flex items-center gap-1 text-[11px] font-medium">
-                <Link2 className="h-3 w-3" />
-                <span>关联线索</span>
-              </div>
-              <div className="space-y-1.5">
-                {relatedClues.map(({ clue, similarity }) => (
-                  <button
-                    key={clue.id}
-                    type="button"
-                    onClick={() => setSelectedClueId(clue.id)}
-                    className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs transition-colors hover:bg-muted/30 ${getTypeColor(
-                      clue.type,
-                    )}`}
-                  >
-                    <span className="text-sm">{getTypeIcon(clue.type)}</span>
-                    <span className="line-clamp-2 flex-1">{clue.content}</span>
-                    <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
-                      {(similarity * 100).toFixed(0)}%
-                    </span>
-                  </button>
-                ))}
-              </div>
-            </div>
-          )}
-        </div>
-      )}
-    </div>
-  )
-}

+ 0 - 67
src/components/novel/timeline-view.tsx

@@ -1,67 +0,0 @@
-import { useEffect, useState } from "react"
-import { useTranslation } from "react-i18next"
-import { Clock, Loader2 } from "lucide-react"
-import { useWikiStore } from "@/stores/wiki-store"
-import { getTimelineEvents, type TimelineEntry } from "@/lib/novel/timeline"
-
-export function TimelineView() {
-  const { t } = useTranslation()
-  const project = useWikiStore((s) => s.project)
-  const [events, setEvents] = useState<TimelineEntry[]>([])
-  const [loading, setLoading] = useState(true)
-
-  useEffect(() => {
-    if (!project) return
-    setLoading(true)
-    ;(async () => {
-      try {
-        const entries = await getTimelineEvents(project.path)
-        setEvents(entries)
-      } catch {
-        setEvents([])
-      } finally {
-        setLoading(false)
-      }
-    })()
-  }, [project])
-
-  if (!project) return null
-
-  return (
-    <div className="flex h-full flex-col">
-      <div className="border-b px-4 py-3">
-        <div className="flex items-center gap-2">
-          <Clock className="h-4 w-4 text-muted-foreground" />
-          <h2 className="text-sm font-semibold">{t("novel.timeline.title")}</h2>
-        </div>
-      </div>
-      <div className="min-h-0 flex-1 overflow-y-auto">
-        {loading ? (
-          <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
-            <Loader2 className="h-4 w-4 animate-spin" />
-            {t("novel.timeline.loading")}
-          </div>
-        ) : events.length === 0 ? (
-          <div className="p-8 text-center text-sm text-muted-foreground">
-            {t("novel.timeline.noEvents")}
-          </div>
-        ) : (
-          <div className="relative">
-            <div className="absolute left-6 top-0 h-full w-px bg-border" />
-            <div className="space-y-0">
-              {events.map((item, i) => (
-                <div key={i} className="relative flex items-start gap-4 px-4 py-2 pl-10">
-                  <div className="absolute left-[18px] top-3 h-3 w-3 rounded-full border-2 border-primary bg-background" />
-                  <div className="min-w-[60px] text-xs font-medium text-primary">
-                    {t("novel.timeline.chapter", { num: item.chapterNumber })}
-                  </div>
-                  <div className="text-sm text-foreground">{item.event}</div>
-                </div>
-              ))}
-            </div>
-          </div>
-        )}
-      </div>
-    </div>
-  )
-}

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

@@ -7,6 +7,7 @@ import type {
 } from "./analysis-pipeline-types"
 import {
   loadAndRecoverAnalysisTasks,
+  replaceAnalysisTaskChunks,
   saveAnalysisChunk,
   saveAnalysisTask,
   saveCompletedChunk,
@@ -88,6 +89,16 @@ function createMemoryIo() {
         directories.add(parts.slice(0, index).join("/"))
       }
     },
+    async deleteFile(path) {
+      const normalized = normalize(path)
+      const prefix = `${normalized}/`
+      for (const file of [...files.keys()]) {
+        if (file === normalized || file.startsWith(prefix)) files.delete(file)
+      }
+      for (const directory of [...directories]) {
+        if (directory === normalized || directory.startsWith(prefix)) directories.delete(directory)
+      }
+    },
     async fileExists(path) {
       const normalized = normalize(path)
       return files.has(normalized) || directories.has(normalized)
@@ -161,4 +172,32 @@ describe("analysis pipeline storage", () => {
     expect(memory.writes.at(-2)).toContain("chunk-0011-0020.result.json")
     expect(memory.writes.at(-1)).toContain("chunk-0011-0020.json")
   })
+
+  it("replaceAnalysisTaskChunks 清空旧区块文件后再写入新区块", async () => {
+    const memory = createMemoryIo()
+    const current = task()
+    const bookPath = current.bookPath
+    await saveAnalysisTask(current, memory.io)
+    await saveCompletedChunk(bookPath, chunk("chunk-0001-0010", "completed"), { characters: [] }, memory.io, 50)
+    await saveAnalysisChunk(bookPath, chunk("chunk-0011-0020", "pending"), memory.io)
+
+    const next = [{
+      ...chunk("chunk-0001-0005", "pending"),
+      chapterIds: ["ch-1", "ch-2", "ch-3", "ch-4", "ch-5"],
+      startOrder: 1,
+      endOrder: 5,
+      resultPath: null,
+      completedAt: null,
+      status: "pending" as const,
+      attempts: 0,
+      startedAt: null,
+    }]
+    await replaceAnalysisTaskChunks(bookPath, "analysis-1", next, memory.io)
+
+    const recovered = await loadAndRecoverAnalysisTasks("E:/Novel", memory.io)
+    expect(recovered.chunks.map((item) => item.id)).toEqual(["chunk-0001-0005"])
+    expect([...memory.files.keys()].some((path) => path.includes("chunk-0001-0010"))).toBe(false)
+    expect([...memory.files.keys()].some((path) => path.includes("chunk-0011-0020"))).toBe(false)
+    expect([...memory.files.keys()].some((path) => path.includes("chunk-0001-0005.json"))).toBe(true)
+  })
 })

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

@@ -1,5 +1,6 @@
 import {
   createDirectory,
+  deleteFile,
   fileExists,
   listDirectory,
   readFile,
@@ -25,6 +26,7 @@ export interface AnalysisStorageEntry {
 
 export interface AnalysisPipelineStorageIo {
   createDirectory(path: string): Promise<void>
+  deleteFile(path: string): Promise<void>
   fileExists(path: string): Promise<boolean>
   listDirectory(path: string): Promise<AnalysisStorageEntry[]>
   readFile(path: string): Promise<string>
@@ -33,6 +35,7 @@ export interface AnalysisPipelineStorageIo {
 
 const defaultIo: AnalysisPipelineStorageIo = {
   createDirectory,
+  deleteFile,
   fileExists,
   listDirectory: async (path) => listDirectory(path),
   readFile,
@@ -120,6 +123,23 @@ export async function saveAnalysisChunk(
   await io.writeFileAtomic(analysisChunkPath(bookPath, chunk), JSON.stringify(chunk, null, 2))
 }
 
+/** 清空任务下旧区块(含 .result.json)后写入新区块,避免重配范围残留 orphan。 */
+export async function replaceAnalysisTaskChunks(
+  bookPath: string,
+  taskId: string,
+  nextChunks: AnalysisChunkRecord[],
+  io: AnalysisPipelineStorageIo = defaultIo,
+): Promise<void> {
+  assertSafeId(taskId, "分析任务 ID")
+  for (const chunk of nextChunks) {
+    if (chunk.taskId !== taskId) throw new Error("区块不属于当前分析任务")
+  }
+  const root = analysisChunksRoot(bookPath, taskId)
+  if (await io.fileExists(root)) await io.deleteFile(root)
+  await ensureTaskDirectories(bookPath, taskId, io)
+  for (const chunk of nextChunks) await saveAnalysisChunk(bookPath, chunk, io)
+}
+
 export async function saveCompletedChunk<T>(
   bookPath: string,
   chunk: AnalysisChunkRecord,

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

@@ -1,9 +1,12 @@
+import type { RecognizedCharacter } from "./types"
+
 export const ANALYSIS_SKILL_ORDER = ["characters", "story", "style"] as const
 
 export type AnalysisSkill = (typeof ANALYSIS_SKILL_ORDER)[number]
 
 export type AnalysisTaskStatus =
   | "awaiting-range"
+  | "awaiting-character-selection"
   | "queued"
   | "running"
   | "paused"
@@ -77,6 +80,10 @@ export interface BookAnalysisPipelineTask {
   status: AnalysisTaskStatus
   currentSkill: AnalysisSkill | null
   modules: Record<AnalysisSkill, AnalysisModuleState>
+  /** 轻量识别结果;供深度分析前的角色勾选面板重开 */
+  recognizedCharacters?: RecognizedCharacter[]
+  /** 用户确认后的深挖目标;有值时角色 adapter 只分析这些角色 */
+  targetCharacters?: RecognizedCharacter[]
   error: string | null
   createdAt: number
   startedAt: number | null
@@ -126,6 +133,21 @@ export interface BookAnalysisModuleManifest {
   updatedAt: number
 }
 
+/** 运行时细进度(仅内存,不落盘) */
+export interface AnalysisRuntimeProgress {
+  stageLabel: string
+  percentage: number
+  currentItem?: string
+}
+
+export function analysisProgressKey(
+  taskId: string,
+  skill: AnalysisSkill,
+  scope: string,
+): string {
+  return `${taskId}:${skill}:${scope}`
+}
+
 export function normalizeSelectedSkills(skills: AnalysisSkill[]): AnalysisSkill[] {
   const selected = new Set(skills)
   return ANALYSIS_SKILL_ORDER.filter((skill) => selected.has(skill))

+ 166 - 8
src/lib/novel/book-analysis/analysis-scheduler.spec.ts

@@ -172,26 +172,184 @@ describe("analysis scheduler", () => {
     expect(harness.calls).toContain("characters:chunk-3:start")
   })
 
-  it("同批区块失败时等待其他已开始区块完成,继续后只重试失败区块", async () => {
-    let failedOnce = false
+  it("同批部分区块失败时用已完成区块继续汇总并标记任务成功", async () => {
     const harness = createHarness({
       onRun: async (_skill, chunkId) => {
-        if (chunkId === "chunk-1" && !failedOnce) {
-          failedOnce = true
-          throw new Error("模拟区块失败")
-        }
+        if (chunkId === "chunk-1") throw new Error("模拟区块失败")
         await new Promise((resolve) => setTimeout(resolve, 1))
       },
     })
     harness.scheduler.initialize([task(["characters"])], chunks(["characters"]))
 
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+
+    const snapshot = harness.scheduler.getSnapshot()
+    expect(snapshot.tasks[0].status).toBe("completed")
+    expect(snapshot.chunks.find((chunk) => chunk.id === "chunk-1")?.status).toBe("failed")
+    expect(snapshot.chunks.find((chunk) => chunk.id === "chunk-2")?.status).toBe("completed")
+    expect(snapshot.tasks[0].modules.characters.summary).toContain("失败已跳过")
+    expect(harness.calls).toContain("characters:aggregate")
+    expect(harness.calls).toContain("characters:publish")
+  })
+
+  it("全部区块失败时任务失败;continue 会重置失败区块后重试", async () => {
+    let failAll = true
+    const harness = createHarness({
+      onRun: async () => {
+        if (failAll) throw new Error("模拟全部失败")
+      },
+    })
+    harness.scheduler.initialize([task(["characters"], ["chunk-1"])], chunks(["characters"], 1))
+
     await harness.scheduler.continueTask("task-1")
     expect(harness.scheduler.getSnapshot().tasks[0].status).toBe("failed")
-    expect(harness.scheduler.getSnapshot().chunks.find((chunk) => chunk.id === "chunk-2")?.status).toBe("completed")
 
+    failAll = false
     await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+    expect(harness.scheduler.getSnapshot().tasks[0].status).toBe("completed")
     expect(harness.calls.filter((call) => call === "characters:chunk-1:start")).toHaveLength(2)
-    expect(harness.calls.filter((call) => call === "characters:chunk-2:start")).toHaveLength(1)
+  })
+
+  it("区块 onProgress 会进入 snapshot.progresses,完成后清除", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const adapters = Object.fromEntries(["characters", "story", "style"].map((skill) => [
+      skill,
+      {
+        skill,
+        async runChunk({ chunk, onProgress }) {
+          onProgress?.({ stageLabel: "识别角色中", percentage: 40, currentItem: "第一章" })
+          await gate
+          onProgress?.({ stageLabel: "区块完成", percentage: 100 })
+          return { result: { chunkId: chunk.id }, evidence: [] }
+        },
+        async aggregate({ onProgress }) {
+          onProgress?.({ stageLabel: "正在合并角色候选…", percentage: 93 })
+          return { skill }
+        },
+        async publish({ onProgress }) {
+          onProgress?.({ stageLabel: "正在保存角色结果…", percentage: 97 })
+          return `${skill}.json`
+        },
+      } satisfies AnalysisSkillAdapter,
+    ])) as Record<AnalysisSkill, AnalysisSkillAdapter>
+    const savedResults = new Map<string, unknown>()
+    const scheduler = createAnalysisScheduler({
+      adapters,
+      llmConfig: {} as LlmConfig,
+      saveTask: vi.fn(async () => {}),
+      saveChunk: vi.fn(async () => {}),
+      saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => {
+        const resultPath = `${chunk.skill}-${chunk.id}.result.json`
+        savedResults.set(resultPath, result)
+        return { ...chunk, status: "completed", resultPath, completedAt: 10, updatedAt: 10 }
+      }),
+      loadChunkResult: vi.fn(async (chunk) => chunk.resultPath ? savedResults.get(chunk.resultPath) ?? null : null),
+      now: () => 10,
+    })
+    scheduler.initialize([task(["characters"], ["chunk-1"])], chunks(["characters"], 1))
+
+    const running = scheduler.continueTask("task-1")
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    const mid = scheduler.getSnapshot().progresses["task-1:characters:chunk-1"]
+    expect(mid).toMatchObject({ stageLabel: "识别角色中", percentage: 40, currentItem: "第一章" })
+
+    release()
+    await running
+    await scheduler.whenIdle()
+
+    expect(scheduler.getSnapshot().progresses).toEqual({})
+    expect(scheduler.getSnapshot().tasks[0].status).toBe("completed")
+  })
+
+  it("enqueue 会移除同任务下不在新列表中的 orphan 区块,且不调度它们", async () => {
+    const harness = createHarness()
+    const orphan: AnalysisChunkRecord = {
+      ...chunks(["characters"], 1)[0],
+      id: "chunk-orphan",
+      startOrder: 99,
+      endOrder: 100,
+      status: "completed",
+      resultPath: "orphan.result.json",
+    }
+    harness.scheduler.initialize(
+      [task(["characters"], ["chunk-1"])],
+      [...chunks(["characters"], 1), orphan],
+    )
+
+    await harness.scheduler.enqueue(task(["characters"], ["chunk-1"]), chunks(["characters"], 1))
+    await harness.scheduler.whenIdle()
+
+    const snapshot = harness.scheduler.getSnapshot()
+    expect(snapshot.chunks.map((item) => item.id)).toEqual(["chunk-1"])
+    expect(harness.calls).not.toContain("characters:chunk-orphan:start")
+    expect(snapshot.tasks[0].status).toBe("completed")
+  })
+
+  it("即使内存残留 orphan completed,汇总仍只使用 expectedIds 内区块", async () => {
+    const harness = createHarness()
+    const taskValue = task(["characters"], ["chunk-1"])
+    const expected = chunks(["characters"], 1)[0]
+    const orphan: AnalysisChunkRecord = {
+      ...expected,
+      id: "chunk-orphan",
+      startOrder: 50,
+      endOrder: 60,
+      status: "completed",
+      resultPath: "orphan.result.json",
+    }
+    // 先 initialize 带 orphan,再手动塞回 orphan(绕过 enqueue 清理),验证汇总防线
+    harness.scheduler.initialize([taskValue], [expected, orphan])
+    await harness.scheduler.continueTask("task-1")
+    await harness.scheduler.whenIdle()
+
+    expect(harness.scheduler.getSnapshot().tasks[0].status).toBe("completed")
+    expect(harness.calls).toContain("characters:aggregate")
+    expect(harness.calls).not.toContain("characters:chunk-orphan:start")
+  })
+
+  it("snapshot.progresses 为浅拷贝,外部修改不影响内部状态", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const adapters = Object.fromEntries(["characters", "story", "style"].map((skill) => [
+      skill,
+      {
+        skill,
+        async runChunk({ chunk, onProgress }) {
+          onProgress?.({ stageLabel: "进行中", percentage: 40 })
+          await gate
+          return { result: { chunkId: chunk.id }, evidence: [] }
+        },
+        async aggregate() { return { skill } },
+        async publish() { return `${skill}.json` },
+      } satisfies AnalysisSkillAdapter,
+    ])) as Record<AnalysisSkill, AnalysisSkillAdapter>
+    const scheduler = createAnalysisScheduler({
+      adapters,
+      llmConfig: {} as LlmConfig,
+      saveTask: vi.fn(async () => {}),
+      saveChunk: vi.fn(async () => {}),
+      saveCompletedChunk: vi.fn(async (_bookPath, chunk, result) => ({
+        ...chunk,
+        status: "completed",
+        resultPath: `${chunk.id}.result.json`,
+        completedAt: 10,
+        updatedAt: 10,
+      })),
+      loadChunkResult: vi.fn(async () => ({ result: {}, evidence: [] })),
+      now: () => 10,
+    })
+    scheduler.initialize([task(["characters"], ["chunk-1"])], chunks(["characters"], 1))
+    const running = scheduler.continueTask("task-1")
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    const snap = scheduler.getSnapshot()
+    const key = "task-1:characters:chunk-1"
+    snap.progresses[key].percentage = 1
+    expect(scheduler.getSnapshot().progresses[key].percentage).toBe(40)
+    release()
+    await running
   })
 
   it("取消聚合时中止聚合请求并把任务标记为已取消", async () => {

+ 129 - 10
src/lib/novel/book-analysis/analysis-scheduler.ts

@@ -7,10 +7,12 @@ import {
 } from "./analysis-pipeline-storage"
 import {
   ANALYSIS_SKILL_ORDER,
+  analysisProgressKey,
   normalizeSelectedSkills,
   type AnalysisChunkRecord,
   type AnalysisEvidenceSnippet,
   type AnalysisModuleState,
+  type AnalysisRuntimeProgress,
   type AnalysisSkill,
   type BookAnalysisPipelineTask,
 } from "./analysis-pipeline-types"
@@ -23,6 +25,7 @@ import type {
 export interface AnalysisSchedulerSnapshot {
   tasks: BookAnalysisPipelineTask[]
   chunks: AnalysisChunkRecord[]
+  progresses: Record<string, AnalysisRuntimeProgress>
 }
 
 export interface AnalysisScheduler {
@@ -58,6 +61,16 @@ function copyTask(task: BookAnalysisPipelineTask): BookAnalysisPipelineTask {
     ...task,
     selectedSkills: [...task.selectedSkills],
     range: task.range ? { ...task.range } : null,
+    recognizedCharacters: task.recognizedCharacters?.map((character) => ({
+      ...character,
+      aliases: [...character.aliases],
+      chapterIndices: [...character.chapterIndices],
+    })),
+    targetCharacters: task.targetCharacters?.map((character) => ({
+      ...character,
+      aliases: [...character.aliases],
+      chapterIndices: [...character.chapterIndices],
+    })),
     modules: Object.fromEntries(ANALYSIS_SKILL_ORDER.map((skill) => [skill, {
       ...task.modules[skill],
       range: { ...task.modules[skill].range },
@@ -71,6 +84,14 @@ function copyChunk(chunk: AnalysisChunkRecord): AnalysisChunkRecord {
   return { ...chunk, chapterIds: [...chunk.chapterIds] }
 }
 
+function clampProgress(progress: AnalysisRuntimeProgress): AnalysisRuntimeProgress {
+  return {
+    stageLabel: progress.stageLabel,
+    percentage: Math.max(0, Math.min(100, Math.round(progress.percentage))),
+    ...(progress.currentItem ? { currentItem: progress.currentItem } : {}),
+  }
+}
+
 export function createAnalysisScheduler(options: AnalysisSchedulerOptions): AnalysisScheduler {
   const concurrency = Math.max(1, Math.min(2, Math.floor(options.concurrency ?? 2)))
   const persistTask = options.saveTask ?? saveAnalysisTask
@@ -80,6 +101,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
   const now = options.now ?? Date.now
   const tasks = new Map<string, BookAnalysisPipelineTask>()
   const chunks = new Map<string, AnalysisChunkRecord>()
+  const progresses = new Map<string, AnalysisRuntimeProgress>()
   const taskRuns = new Map<string, Promise<void>>()
   const chunkRuns = new Map<string, Promise<void>>()
   const controllers = new Map<string, AbortController>()
@@ -94,6 +116,9 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
     return {
       tasks: [...tasks.values()].map(copyTask),
       chunks: [...chunks.values()].map(copyChunk),
+      progresses: Object.fromEntries(
+        [...progresses.entries()].map(([key, progress]) => [key, { ...progress }]),
+      ),
     }
   }
 
@@ -103,6 +128,37 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
     for (const listener of listeners) listener(value)
   }
 
+  function reportProgress(key: string, progress: AnalysisRuntimeProgress): void {
+    const next = clampProgress(progress)
+    const prev = progresses.get(key)
+    if (
+      prev
+      && prev.stageLabel === next.stageLabel
+      && prev.currentItem === next.currentItem
+      && Math.abs(prev.percentage - next.percentage) < 1
+    ) {
+      return
+    }
+    progresses.set(key, next)
+    notify()
+  }
+
+  function clearProgress(key: string): void {
+    if (!progresses.delete(key)) return
+    notify()
+  }
+
+  function clearProgressesForTask(taskId: string): void {
+    let changed = false
+    for (const key of [...progresses.keys()]) {
+      if (key.startsWith(`${taskId}:`)) {
+        progresses.delete(key)
+        changed = true
+      }
+    }
+    if (changed) notify()
+  }
+
   function resolveLlmConfig(): LlmConfig {
     return typeof options.llmConfig === "function" ? options.llmConfig() : options.llmConfig
   }
@@ -161,6 +217,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
         updatedAt: startedAt,
       }
       chunks.set(key, running)
+      reportProgress(key, { stageLabel: "准备分析区块…", percentage: 1 })
       await persistChunk(task.bookPath, running)
       notify()
 
@@ -169,10 +226,12 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
           ...contextFor(task, skill),
           chunk: copyChunk(running),
           signal: controller.signal,
+          onProgress: (progress) => reportProgress(key, progress),
         })
         if (controller.signal.aborted) throw new Error("分析任务已取消")
         const completed = await persistCompletedChunk(task.bookPath, running, output)
         chunks.set(key, completed)
+        clearProgress(key)
         notify()
       } catch (error) {
         const failedAt = now()
@@ -185,6 +244,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
           updatedAt: failedAt,
         }
         chunks.set(key, failed)
+        clearProgress(key)
         await persistChunk(task.bookPath, failed)
         notify()
         throw error
@@ -205,6 +265,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
 
   async function markTaskStopped(task: BookAnalysisPipelineTask, status: "paused" | "cancelled"): Promise<void> {
     const stoppedAt = now()
+    clearProgressesForTask(task.id)
     await updateTask({
       ...task,
       status,
@@ -266,19 +327,24 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
             await markTaskStopped(tasks.get(taskId) ?? task, "paused")
             return
           }
+          const expectedIds = new Set(task.modules[skill].chunkIds)
+          // 只调度 pending;failed 需 continue/retry 显式重置后再跑,避免同轮无限重试
           const pending = chunksFor(taskId, skill)
-            .filter((chunk) => chunk.status === "pending" || chunk.status === "failed")
+            .filter((chunk) => expectedIds.has(chunk.id) && chunk.status === "pending")
             .slice(0, concurrency)
           if (pending.length === 0) break
-          const settled = await Promise.allSettled(pending.map((chunk) => runChunk(task!, skill, chunk)))
-          const rejected = settled.find((result): result is PromiseRejectedResult => result.status === "rejected")
-          if (rejected) throw rejected.reason
+          await Promise.allSettled(pending.map((chunk) => runChunk(task!, skill, chunk)))
         }
 
-        const completedChunks = chunksFor(taskId, skill).filter((chunk) => chunk.status === "completed")
         const expectedIds = new Set(task.modules[skill].chunkIds)
-        if (completedChunks.length !== expectedIds.size || completedChunks.some((chunk) => !expectedIds.has(chunk.id))) {
-          throw new Error("章节区块尚未全部完成,无法汇总")
+        const completedChunks = chunksFor(taskId, skill).filter(
+          (chunk) => expectedIds.has(chunk.id) && chunk.status === "completed",
+        )
+        const failedChunks = chunksFor(taskId, skill).filter(
+          (chunk) => expectedIds.has(chunk.id) && (chunk.status === "failed" || chunk.status === "cancelled"),
+        )
+        if (completedChunks.length === 0) {
+          throw new Error(failedChunks[0]?.error || "章节区块尚未全部完成,无法汇总")
         }
 
         const outputs: AnalysisChunkOutput[] = []
@@ -288,22 +354,26 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
           outputs.push(output)
         }
         const aggregateController = new AbortController()
-        const aggregateKey = `${taskId}:${skill}:aggregate`
+        const aggregateKey = analysisProgressKey(taskId, skill, "aggregate")
         controllers.set(aggregateKey, aggregateController)
+        reportProgress(aggregateKey, { stageLabel: "正在汇总…", percentage: 92 })
         let result: unknown
         try {
           result = await options.adapters[skill].aggregate({
             ...contextFor(task, skill),
             chunks: outputs.map((output) => output.result),
             signal: aggregateController.signal,
+            onProgress: (progress) => reportProgress(aggregateKey, progress),
           })
         } finally {
           controllers.delete(aggregateKey)
+          clearProgress(aggregateKey)
         }
         const evidence: AnalysisEvidenceSnippet[] = outputs.flatMap((output) => output.evidence)
         const publishController = new AbortController()
-        const publishKey = `${taskId}:${skill}:publish`
+        const publishKey = analysisProgressKey(taskId, skill, "publish")
         controllers.set(publishKey, publishController)
+        reportProgress(publishKey, { stageLabel: "正在发布结果…", percentage: 96 })
         let resultPath: string
         try {
           resultPath = await options.adapters[skill].publish({
@@ -311,13 +381,18 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
             result,
             evidence,
             signal: publishController.signal,
+            onProgress: (progress) => reportProgress(publishKey, progress),
           })
         } finally {
           controllers.delete(publishKey)
+          clearProgress(publishKey)
         }
 
         task = tasks.get(taskId) ?? task
         const completedAt = now()
+        const partialSummary = failedChunks.length > 0
+          ? `已用 ${completedChunks.length}/${expectedIds.size} 个成功区块完成汇总;${failedChunks.length} 个区块失败已跳过`
+          : undefined
         task = {
           ...task,
           modules: {
@@ -326,8 +401,9 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
               ...task.modules[skill],
               status: "completed",
               completedChunkIds: completedChunks.map((chunk) => chunk.id),
-              failedChunkId: null,
+              failedChunkId: failedChunks[0]?.id ?? null,
               resultPath,
+              summary: partialSummary ?? task.modules[skill].summary,
               updatedAt: completedAt,
             },
           },
@@ -337,6 +413,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
       }
 
       const completedAt = now()
+      clearProgressesForTask(taskId)
       await updateTask({
         ...(tasks.get(taskId) ?? task),
         status: "completed",
@@ -357,6 +434,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
       }
       const failedSkill = task.currentSkill
       const failedAt = now()
+      clearProgressesForTask(taskId)
       await updateTask({
         ...task,
         status: "failed",
@@ -392,12 +470,17 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
     initialize(nextTasks, nextChunks) {
       tasks.clear()
       chunks.clear()
+      progresses.clear()
       for (const task of nextTasks) tasks.set(task.id, copyTask(task))
       for (const chunk of nextChunks) chunks.set(chunkKey(chunk), copyChunk(chunk))
       notify()
     },
     async enqueue(task, nextChunks) {
       tasks.set(task.id, copyTask(task))
+      const nextKeys = new Set(nextChunks.map((chunk) => chunkKey(chunk)))
+      for (const [key, chunk] of chunks) {
+        if (chunk.taskId === task.id && !nextKeys.has(key)) chunks.delete(key)
+      }
       for (const chunk of nextChunks) chunks.set(chunkKey(chunk), copyChunk(chunk))
       notify()
       await runTask(task.id)
@@ -415,6 +498,41 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
       if (!task || task.status === "completed") return
       pauseRequested.delete(taskId)
       cancelRequested.delete(taskId)
+      // 断点继续:把失败/取消区块重置为 pending,并恢复失败 Skill
+      for (const chunk of [...chunks.values()].filter((item) => item.taskId === taskId)) {
+        if (chunk.status !== "failed" && chunk.status !== "cancelled") continue
+        const reset = {
+          ...chunk,
+          status: "pending" as const,
+          error: null,
+          startedAt: null,
+          completedAt: null,
+          updatedAt: now(),
+        }
+        chunks.set(chunkKey(chunk), reset)
+        await persistChunk(task.bookPath, reset)
+      }
+      if (task.status === "failed") {
+        const failedSkill = task.currentSkill
+          ?? ANALYSIS_SKILL_ORDER.find((skill) => task.modules[skill].status === "failed")
+          ?? null
+        await updateTask({
+          ...task,
+          status: "queued",
+          error: null,
+          modules: failedSkill ? {
+            ...task.modules,
+            [failedSkill]: {
+              ...task.modules[failedSkill],
+              status: "pending",
+              failedChunkId: null,
+              updatedAt: now(),
+            },
+          } : task.modules,
+          updatedAt: now(),
+        })
+      }
+      notify()
       await runTask(taskId)
     },
     async retryFailedChunk(taskId, skill, chunkId) {
@@ -454,6 +572,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
       disposed = true
       for (const controller of controllers.values()) controller.abort()
       await Promise.allSettled([...taskRuns.values()])
+      progresses.clear()
       listeners.clear()
       permitWaiters.splice(0).forEach((resolve) => resolve())
     },

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

@@ -2,6 +2,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import type {
   AnalysisChunkRecord,
   AnalysisEvidenceSnippet,
+  AnalysisRuntimeProgress,
   AnalysisSkill,
   BookAnalysisPipelineTask,
 } from "./analysis-pipeline-types"
@@ -14,11 +15,14 @@ export interface AnalysisSkillContext {
   llmConfig: LlmConfig
 }
 
+export type AnalysisProgressReporter = (progress: AnalysisRuntimeProgress) => void
+
 export interface AnalysisSkillAdapter<TChunk = unknown, TResult = unknown> {
   skill: AnalysisSkill
   runChunk(input: AnalysisSkillContext & {
     chunk: AnalysisChunkRecord
     signal: AbortSignal
+    onProgress?: AnalysisProgressReporter
   }): Promise<{
     result: TChunk
     evidence: AnalysisEvidenceSnippet[]
@@ -26,11 +30,13 @@ export interface AnalysisSkillAdapter<TChunk = unknown, TResult = unknown> {
   aggregate(input: AnalysisSkillContext & {
     chunks: TChunk[]
     signal: AbortSignal
+    onProgress?: AnalysisProgressReporter
   }): Promise<TResult>
   publish(input: AnalysisSkillContext & {
     result: TResult
     evidence: AnalysisEvidenceSnippet[]
     signal: AbortSignal
+    onProgress?: AnalysisProgressReporter
   }): Promise<string>
 }
 

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

@@ -144,4 +144,57 @@ describe("character analysis adapter", () => {
       signal: new AbortController().signal,
     })).rejects.toThrow("未识别到可提取角色")
   })
+
+  it("runChunk 把 task.targetCharacters 传给 extractCharacters", async () => {
+    const extractCharacters = vi.fn(async () => ({
+      success: true,
+      characters: [character({})],
+      warnings: [],
+    }))
+    const adapter = createCharacterAnalysisAdapter({ extractCharacters })
+    const inputTask = {
+      ...task(),
+      targetCharacters: [{
+        id: "char-1",
+        name: "林远",
+        aliases: [],
+        appearances: 3,
+        chapterIndices: [0, 1],
+        importanceScore: 90,
+        category: "主角" as const,
+        sourceBook: "book-1",
+      }],
+    }
+
+    await adapter.runChunk({
+      task: inputTask,
+      skill: "characters",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunk: {
+        version: 1,
+        id: "chunk-0001-0010",
+        taskId: inputTask.id,
+        skill: "characters",
+        chapterIds: ["ch-0001"],
+        startOrder: 1,
+        endOrder: 10,
+        wordCount: 1000,
+        status: "running",
+        attempts: 1,
+        resultPath: null,
+        error: null,
+        startedAt: 1,
+        completedAt: null,
+        updatedAt: 1,
+      },
+      signal: new AbortController().signal,
+    })
+
+    expect(extractCharacters).toHaveBeenCalledWith(expect.objectContaining({
+      targetCharacters: inputTask.targetCharacters,
+      selectedChapterIds: ["ch-0001"],
+    }))
+  })
 })

+ 18 - 3
src/lib/novel/book-analysis/character-analysis-adapter.ts

@@ -205,7 +205,7 @@ export function createCharacterAnalysisAdapter(
   const dependencies = { ...defaultDependencies, ...overrides }
   return {
     skill: "characters",
-    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+    async runChunk({ task, bookPath, llmConfig, chunk, signal, onProgress }) {
       const extracted = await dependencies.extractCharacters({
         bookPath,
         selectedChapterIds: chunk.chapterIds,
@@ -213,8 +213,19 @@ export function createCharacterAnalysisAdapter(
         depth: "fast",
         persistResults: false,
         signal,
+        ...(task.targetCharacters && task.targetCharacters.length > 0
+          ? { targetCharacters: task.targetCharacters }
+          : {}),
+        onProgress: (progress) => {
+          onProgress?.({
+            stageLabel: progress.stageLabel,
+            percentage: progress.percentage,
+            currentItem: progress.currentItem ?? progress.currentCharacter,
+          })
+        },
       })
       if (!extracted.success) throw new Error("角色区块分析失败")
+      onProgress?.({ stageLabel: "角色区块分析完成", percentage: 100 })
       return {
         result: { characters: extracted.characters },
         evidence: characterEvidence(
@@ -228,15 +239,18 @@ export function createCharacterAnalysisAdapter(
         ),
       }
     },
-    async aggregate({ chunks }) {
+    async aggregate({ chunks, onProgress }) {
+      onProgress?.({ stageLabel: "正在合并角色候选…", percentage: 93 })
       const merged = mergeCharacterChunkResults(chunks.map((chunk) => chunk.characters))
       const candidates = selectCharacterCandidates(merged)
       if (candidates.length === 0) {
         throw new Error("所选章节未识别到可提取角色,请确认章节正文包含有姓名的重要角色")
       }
+      onProgress?.({ stageLabel: `已选出 ${candidates.length} 个候选角色`, percentage: 95 })
       return candidates
     },
-    async publish({ task, bookPath, projectPath, result, evidence }) {
+    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
+      onProgress?.({ stageLabel: "正在保存角色结果…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布角色分析")
       for (const character of result) await dependencies.persistCharacter(bookPath, character)
@@ -263,6 +277,7 @@ export function createCharacterAnalysisAdapter(
       }
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
+      onProgress?.({ stageLabel: "角色结果已发布", percentage: 100 })
       return resultPath
     },
   }

+ 58 - 3
src/lib/novel/book-analysis/character-extraction-engine.spec.ts

@@ -160,19 +160,74 @@ describe("extractCharactersFromChapters 角色识别失败处理", () => {
     expect(result.characters.map((item) => item.name)).toEqual(["林烬"])
   })
 
-  it("角色识别请求失败时向上返回明确错误", async () => {
+  it("角色识别请求失败时跳过该章并仍返回成功(空结果)", async () => {
     vi.mocked(readFile).mockResolvedValue("---\ntitle: 第一章\norder: 1\n---\n林烬推门而入。")
     streamChatMock.mockImplementationOnce(async (_cfg, _messages, handlers: any) => {
       handlers.onError(new Error("模型连接失败"))
     })
 
-    await expect(extractCharactersFromChapters({
+    const result = await extractCharactersFromChapters({
       bookPath: "E:/Novel/book-analysis/book-1",
       selectedChapterIds: ["chapter-1"],
       llmConfig: fakeLlmConfig,
       depth: "fast",
       persistResults: false,
-    })).rejects.toThrow("角色识别失败")
+    })
+
+    expect(result.success).toBe(true)
+    expect(result.characters).toEqual([])
+    expect(result.warnings?.some((item) => item.includes("角色识别失败"))).toBe(true)
+  })
+
+  it("单个角色详情失败时跳过该角色并保留其他成功结果", async () => {
+    vi.mocked(readFile).mockResolvedValue("---\ntitle: 第一章\norder: 1\n---\n林烬与顾司玥同行。")
+    streamChatMock.mockImplementation(async (_cfg, messages: Array<{ content: string }>, handlers: any) => {
+      const prompt = messages[0]?.content ?? ""
+      if (prompt.includes('角色"顾司玥"')) {
+        handlers.onError(new Error("JSON Parse error: Unterminated string"))
+        return
+      }
+      handlers.onToken(JSON.stringify({
+        name: "林烬",
+        category: "protagonist",
+        personality: "冷静",
+      }))
+      handlers.onDone()
+    })
+
+    const result = await extractCharactersFromChapters({
+      bookPath: "E:/Novel/book-analysis/book-1",
+      selectedChapterIds: ["chapter-1"],
+      llmConfig: fakeLlmConfig,
+      depth: "fast",
+      persistResults: false,
+      targetCharacters: [
+        {
+          id: "char-linjing",
+          name: "林烬",
+          aliases: [],
+          appearances: 1,
+          chapterIndices: [0],
+          importanceScore: 90,
+          category: "主角",
+          sourceBook: "book-1",
+        },
+        {
+          id: "char-guyue",
+          name: "顾司玥",
+          aliases: [],
+          appearances: 1,
+          chapterIndices: [0],
+          importanceScore: 80,
+          category: "主角",
+          sourceBook: "book-1",
+        },
+      ],
+    })
+
+    expect(result.success).toBe(true)
+    expect(result.characters.map((item) => item.name)).toEqual(["林烬"])
+    expect(result.warnings?.some((item) => item.includes("顾司玥"))).toBe(true)
   })
 })
 

+ 97 - 71
src/lib/novel/book-analysis/character-extraction-engine.ts

@@ -21,6 +21,7 @@ import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { analyzeSixDimensions, DEPTH_DESCRIPTIONS } from "./six-dimension-engine"
 import { stableCharacterId } from "./character-recognition-engine"
 import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
+import { parseLlmJsonObject } from "./llm-json"
 
 export interface CharacterExtractionInput {
   bookPath: string
@@ -56,6 +57,8 @@ export interface CharacterExtractionInput {
 export interface CharacterExtractionResult {
   success: boolean
   characters: ExtractedCharacter[]
+  /** 部分章节/角色失败时的告警,不阻断整体成功 */
+  warnings?: string[]
 }
 
 /**
@@ -104,34 +107,47 @@ ${chapterContent.substring(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)} ${chapterContent.
 
     if (streamError) throw streamError
 
-    // 解析 JSON
-    const jsonMatch = response.match(/\{[\s\S]*\}/)
-    if (jsonMatch) {
-      const data = JSON.parse(jsonMatch[0]) as { characters?: unknown }
-      if (!Array.isArray(data.characters)) throw new Error("模型返回内容缺少 characters 数组")
-      return data.characters.flatMap((item): Array<{ name: string; aliases: string[]; importance: number }> => {
-        if (!item || typeof item !== "object") return []
-        const candidate = item as Record<string, unknown>
-        const name = typeof candidate.name === "string" ? candidate.name.trim() : ""
-        if (!name) return []
-        const rawImportance = Number(candidate.importance)
-        return [{
-          name,
-          aliases: Array.isArray(candidate.aliases)
-            ? candidate.aliases.filter((alias): alias is string => typeof alias === "string")
-            : [],
-          importance: Number.isFinite(rawImportance)
-            ? Math.max(1, Math.min(10, rawImportance))
-            : 5,
-        }]
-      })
-    }
-
-    throw new Error("模型返回内容不是有效 JSON")
+    const data = parseLlmJsonObject(response)
+    if (!data || !Array.isArray(data.characters)) throw new Error("模型返回内容缺少 characters 数组")
+    return data.characters.flatMap((item): Array<{ name: string; aliases: string[]; importance: number }> => {
+      if (!item || typeof item !== "object") return []
+      const candidate = item as Record<string, unknown>
+      const name = typeof candidate.name === "string" ? candidate.name.trim() : ""
+      if (!name) return []
+      const rawImportance = Number(candidate.importance)
+      return [{
+        name,
+        aliases: Array.isArray(candidate.aliases)
+          ? candidate.aliases.filter((alias): alias is string => typeof alias === "string")
+          : [],
+        importance: Number.isFinite(rawImportance)
+          ? Math.max(1, Math.min(10, rawImportance))
+          : 5,
+      }]
+    })
   } catch (error) {
-    console.error(`Failed to identify characters in chapter ${chapterTitle}:`, error)
     if (signal?.aborted) throw new Error("用户取消分析")
-    throw new Error(`角色识别失败(${chapterTitle}):${error instanceof Error ? error.message : String(error)}`)
+    const message = error instanceof Error ? error.message : String(error)
+    console.warn(`角色识别失败(${chapterTitle}),跳过该章:`, message)
+    throw new Error(`角色识别失败(${chapterTitle}):${message}`)
+  }
+}
+
+async function identifyCharactersInChapterSafe(
+  chapterContent: string,
+  chapterTitle: string,
+  chapterOrder: number,
+  llmConfig: LlmConfig,
+  signal: AbortSignal | undefined,
+  warnings: string[],
+): Promise<Array<{ name: string; aliases: string[]; importance: number }>> {
+  try {
+    return await identifyCharactersInChapter(chapterContent, chapterTitle, chapterOrder, llmConfig, signal)
+  } catch (error) {
+    if (signal?.aborted || (error instanceof Error && error.message === "用户取消分析")) throw error
+    const message = error instanceof Error ? error.message : String(error)
+    warnings.push(message)
+    return []
   }
 }
 
@@ -207,49 +223,47 @@ ${corpus}
 
     if (streamError) throw streamError
 
-    // 解析 JSON
-    const jsonMatch = response.match(/\{[\s\S]*\}/)
-    if (jsonMatch) {
-      const data = JSON.parse(jsonMatch[0])
-
-      const character: ExtractedCharacter = {
-        // feature/fix-six-dim-extract:用稳定 hash id 替代 Math.random,避免 id 跨调用漂移
-        id: stableCharacterId(data.name || characterName, ""),
-        name: data.name || characterName,
-        aliases: data.aliases || [],
-        importance: 5, // 默认值,稍后会更新
-        category: data.category || "minor",
-        firstAppearance: relevantChapters[0]?.order || 1,
-        lastAppearance: relevantChapters[relevantChapters.length - 1]?.order || 1,
-        appearanceCount: relevantChapters.length,
-        description: data.description || "",
-        personality: data.personality || "",
-        motivation: typeof data.motivation === "string" ? data.motivation : "",
-        goals: Array.isArray(data.goals) ? data.goals.filter((item: unknown): item is string => typeof item === "string") : [],
-        fears: Array.isArray(data.fears) ? data.fears.filter((item: unknown): item is string => typeof item === "string") : [],
-        growthArc: typeof data.growthArc === "string" ? data.growthArc : "",
-        behaviorPatterns: typeof data.behaviorPatterns === "string" ? data.behaviorPatterns : "",
-        speechStyle: data.speechStyle || "",
-        relationships: data.relationships || [],
-        keyEvents: data.keyEvents || [],
-        representativeQuotes: Array.isArray(data.representativeQuotes)
-          ? data.representativeQuotes.filter((item: unknown): item is { chapterId: string; text: string } => {
-              if (!item || typeof item !== "object") return false
-              const candidate = item as Record<string, unknown>
-              return typeof candidate.chapterId === "string" && typeof candidate.text === "string"
-            })
-          : [],
-        corpus: corpus.substring(0, 10000), // 保留部分语料
-      }
-
-      return character
+    const data = parseLlmJsonObject(response)
+    if (!data) throw new Error("模型返回内容不是有效 JSON")
+
+    const character: ExtractedCharacter = {
+      // feature/fix-six-dim-extract:用稳定 hash id 替代 Math.random,避免 id 跨调用漂移
+      id: stableCharacterId(typeof data.name === "string" ? data.name : characterName, ""),
+      name: typeof data.name === "string" && data.name ? data.name : characterName,
+      aliases: Array.isArray(data.aliases)
+        ? data.aliases.filter((item): item is string => typeof item === "string")
+        : [],
+      importance: 5, // 默认值,稍后会更新
+      category: (typeof data.category === "string" ? data.category : "minor") as ExtractedCharacter["category"],
+      firstAppearance: relevantChapters[0]?.order || 1,
+      lastAppearance: relevantChapters[relevantChapters.length - 1]?.order || 1,
+      appearanceCount: relevantChapters.length,
+      description: typeof data.description === "string" ? data.description : "",
+      personality: typeof data.personality === "string" ? data.personality : "",
+      motivation: typeof data.motivation === "string" ? data.motivation : "",
+      goals: Array.isArray(data.goals) ? data.goals.filter((item): item is string => typeof item === "string") : [],
+      fears: Array.isArray(data.fears) ? data.fears.filter((item): item is string => typeof item === "string") : [],
+      growthArc: typeof data.growthArc === "string" ? data.growthArc : "",
+      behaviorPatterns: typeof data.behaviorPatterns === "string" ? data.behaviorPatterns : "",
+      speechStyle: typeof data.speechStyle === "string" ? data.speechStyle : "",
+      relationships: Array.isArray(data.relationships) ? data.relationships as ExtractedCharacter["relationships"] : [],
+      keyEvents: Array.isArray(data.keyEvents) ? data.keyEvents as ExtractedCharacter["keyEvents"] : [],
+      representativeQuotes: Array.isArray(data.representativeQuotes)
+        ? data.representativeQuotes.filter((item: unknown): item is { chapterId: string; text: string } => {
+            if (!item || typeof item !== "object") return false
+            const candidate = item as Record<string, unknown>
+            return typeof candidate.chapterId === "string" && typeof candidate.text === "string"
+          })
+        : [],
+      corpus: corpus.substring(0, 10000), // 保留部分语料
     }
 
-    throw new Error("模型返回内容不是有效 JSON")
+    return character
   } catch (error) {
-    console.error(`Failed to analyze character ${characterName}:`, error)
     if (signal?.aborted) throw new Error("用户取消分析")
-    throw new Error(`角色详情提取失败(${characterName}):${error instanceof Error ? error.message : String(error)}`)
+    const message = error instanceof Error ? error.message : String(error)
+    console.warn(`角色详情提取失败(${characterName}),跳过该角色:`, message)
+    return null
   }
 }
 
@@ -260,6 +274,7 @@ export async function extractCharactersFromChapters(
   input: CharacterExtractionInput
 ): Promise<CharacterExtractionResult> {
   const { bookPath, selectedChapterIds, llmConfig, onProgress, signal } = input
+  const warnings: string[] = []
 
   onProgress?.({
     stage: "extracting_characters",
@@ -333,12 +348,13 @@ export async function extractCharactersFromChapters(
         currentItem: chapter.title,
       })
 
-      const identified = await identifyCharactersInChapter(
+      const identified = await identifyCharactersInChapterSafe(
         chapter.content,
         chapter.title,
         chapter.order,
         llmConfig,
-        signal
+        signal,
+        warnings,
       )
 
       // 汇总角色出现次数
@@ -397,20 +413,23 @@ export async function extractCharactersFromChapters(
       currentItem: characterName,
     })
 
-    // 获取该角色相关的章节
-    const relevantChapters = chapters.filter(ch => data.chapters.includes(ch.order))
+    // 获取该角色相关的章节;跨 chunk 索引未命中时回退到当前区块全部章节
+    const matchedChapters = chapters.filter(ch => data.chapters.includes(ch.order))
+    const relevantChapters = matchedChapters.length > 0 ? matchedChapters : chapters
 
     const character = await analyzeCharacterDetails(
       characterName,
       relevantChapters,
       llmConfig,
-      signal
+      signal,
     )
 
     if (character) {
       character.importance = data.importance
       character.appearanceCount = data.count
       characters.push(character)
+    } else {
+      warnings.push(`角色详情提取失败(${characterName}):已跳过`)
     }
   }
 
@@ -463,7 +482,9 @@ export async function extractCharactersFromChapters(
         })
         characters[i] = result.character
       } catch (e) {
-        console.error(`[6d] failed for ${character.name}:`, e)
+        const message = e instanceof Error ? e.message : String(e)
+        console.warn(`[6d] failed for ${character.name}:`, message)
+        warnings.push(`6 维度分析失败(${character.name}):${message}`)
       }
       // 保存更新后的角色(feature/fix-six-dim-extract:writeFile 失败不应中断整个 6 维流程)
       try {
@@ -486,9 +507,14 @@ export async function extractCharactersFromChapters(
     percentage: 90,
   })
 
+  if (warnings.length > 0) {
+    console.warn(`[角色提取] 部分失败但仍返回成功结果:${warnings.join(";")}`)
+  }
+
   return {
     success: true,
     characters,
+    warnings: warnings.length > 0 ? warnings : undefined,
   }
 }
 

+ 52 - 0
src/lib/novel/book-analysis/llm-json.spec.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest"
+import { extractJsonObjectCandidate, parseLlmJsonObject } from "./llm-json"
+
+describe("llm-json", () => {
+  it("提取 fence 内配平的 JSON object", () => {
+    const raw = '说明如下:\n```json\n{"name":"顾司玥","description":"女主"}\n```\n完'
+    expect(extractJsonObjectCandidate(raw)).toBe('{"name":"顾司玥","description":"女主"}')
+  })
+
+  it("用 jsonrepair 解析含裸换行的角色详情输出", () => {
+    const raw = `
+模型分析:
+\`\`\`json
+{
+  "name": "顾司玥",
+  "aliases": [],
+  "category": "protagonist",
+  "description": "外貌清冷
+出身神秘",
+  "personality": "克制",
+  "motivation": "查明真相",
+  "goals": ["活下去"],
+  "fears": ["被抛弃"],
+  "growthArc": "从疏离到信任",
+  "behaviorPatterns": "先观察后行动",
+  "speechStyle": "短句",
+  "relationships": [],
+  "keyEvents": [],
+  "representativeQuotes": [
+    {
+      "chapterId": "ch-0001",
+      "text": "她低声道:
+“别跟着我。”"
+    }
+  ]
+}
+\`\`\`
+`
+    const parsed = parseLlmJsonObject(raw)
+    expect(parsed).not.toBeNull()
+    expect(parsed?.name).toBe("顾司玥")
+    expect(String(parsed?.description)).toContain("外貌清冷")
+    expect(Array.isArray(parsed?.representativeQuotes)).toBe(true)
+  })
+
+  it("用 jsonrepair 消化截断输出", () => {
+    const raw = '{"name":"顾司玥","personality":"冷静且'
+    const parsed = parseLlmJsonObject(raw)
+    expect(parsed?.name).toBe("顾司玥")
+    expect(String(parsed?.personality)).toContain("冷静")
+  })
+})

+ 71 - 0
src/lib/novel/book-analysis/llm-json.ts

@@ -0,0 +1,71 @@
+/**
+ * 解析 LLM 返回的 JSON object:剥 fence / 定位 `{...}`,再用 jsonrepair 修复脏输出。
+ */
+
+import { jsonrepair } from "jsonrepair"
+
+function stripMarkdownFence(raw: string): string {
+  const trimmed = raw.trim()
+  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  if (fenced) return fenced[1].trim()
+  return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/i, "").trim()
+}
+
+/** 提取第一个配平的 `{...}`(尊重字符串转义);截断时返回从 `{` 到末尾。 */
+export function extractJsonObjectCandidate(raw: string): string | null {
+  const text = stripMarkdownFence(raw)
+  const start = text.indexOf("{")
+  if (start < 0) return null
+
+  let depth = 0
+  let inString = false
+  let escape = false
+  for (let i = start; i < text.length; i++) {
+    const ch = text[i]
+    if (escape) {
+      escape = false
+      continue
+    }
+    if (ch === "\\" && inString) {
+      escape = true
+      continue
+    }
+    if (ch === '"') {
+      inString = !inString
+      continue
+    }
+    if (inString) continue
+    if (ch === "{") depth += 1
+    else if (ch === "}") {
+      depth -= 1
+      if (depth === 0) return text.slice(start, i + 1)
+    }
+  }
+  return text.slice(start)
+}
+
+function tryParseObject(text: string): Record<string, unknown> | null {
+  try {
+    const parsed = JSON.parse(text) as unknown
+    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+      return parsed as Record<string, unknown>
+    }
+  } catch {
+    // fall through
+  }
+  return null
+}
+
+export function parseLlmJsonObject(raw: string): Record<string, unknown> | null {
+  const candidate = extractJsonObjectCandidate(raw)
+  if (!candidate) return null
+
+  const direct = tryParseObject(candidate)
+  if (direct) return direct
+
+  try {
+    return tryParseObject(jsonrepair(candidate))
+  } catch {
+    return null
+  }
+}

+ 30 - 16
src/lib/novel/book-analysis/story-analysis-adapter.ts

@@ -116,25 +116,31 @@ export function createStoryAnalysisAdapter(
   const dependencies = { ...defaultDependencies, ...overrides }
   return {
     skill: "story",
-    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+    async runChunk({ task, bookPath, llmConfig, chunk, signal, onProgress }) {
+      onProgress?.({ stageLabel: "读取章节…", percentage: 10 })
       const chapters = await dependencies.loadChapters(bookPath, chunk.chapterIds)
       if (chapters.length !== chunk.chapterIds.length) {
         throw new Error("所选故事章节读取不完整,请检查章节文件后重试")
       }
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法分析故事")
-      const temporaryCharacters = task.modules.characters.status === "completed"
-        ? undefined
-        : (await dependencies.recognizeCharacters({
-            chapters: chapters.map((chapter, index) => ({ index, content: chapter.content })),
-            llmConfig,
-            sourceBook: metadata.title,
-            signal,
-          })).map((character) => ({
-            name: character.name,
-            aliases: character.aliases,
-            category: character.category,
-          }))
+      let temporaryCharacters: Array<{ name: string; aliases: string[]; category: string }> | undefined
+      if (task.modules.characters.status === "completed") {
+        temporaryCharacters = undefined
+      } else {
+        onProgress?.({ stageLabel: "识别临时角色…", percentage: 30 })
+        temporaryCharacters = (await dependencies.recognizeCharacters({
+          chapters: chapters.map((chapter, index) => ({ index, content: chapter.content })),
+          llmConfig,
+          sourceBook: metadata.title,
+          signal,
+        })).map((character) => ({
+          name: character.name,
+          aliases: character.aliases,
+          category: character.category,
+        }))
+      }
+      onProgress?.({ stageLabel: "正在调用模型分析故事框架…", percentage: 50 })
       const markdown = await dependencies.callModel([
         { role: "system", content: "你是严谨的小说故事框架拆解助手,必须输出可复用的中文四段框架。" },
         {
@@ -146,22 +152,29 @@ export function createStoryAnalysisAdapter(
           }),
         },
       ], llmConfig, signal)
+      onProgress?.({ stageLabel: "解析故事框架结果…", percentage: 90 })
       return {
         result: { markdown, rangeChapterIds: chunk.chapterIds },
         evidence: storyEvidence(task.id, task.bookId, chunk.id, chapters, dependencies.now()),
       }
     },
-    async aggregate({ chunks, llmConfig, signal }) {
+    async aggregate({ chunks, llmConfig, signal, onProgress }) {
       if (chunks.length === 0) throw new Error("没有已完成的故事区块可供汇总")
       const rangeChapterIds = chunks.flatMap((chunk) => chunk.rangeChapterIds)
-      if (chunks.length === 1) return { ...chunks[0], 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 }
     },
-    async publish({ task, bookPath, projectPath, result, evidence }) {
+    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
+      onProgress?.({ stageLabel: "正在发布故事框架…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布故事分析")
       const framework = dependencies.buildDraft({
@@ -195,6 +208,7 @@ export function createStoryAnalysisAdapter(
       }
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
+      onProgress?.({ stageLabel: "故事框架已发布", percentage: 100 })
       return resultPath
     },
   }

+ 15 - 4
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -123,7 +123,8 @@ export function createStyleAnalysisAdapter(
   const dependencies = { ...defaultDependencies, ...overrides }
   return {
     skill: "style",
-    async runChunk({ task, bookPath, llmConfig, chunk, signal }) {
+    async runChunk({ task, bookPath, llmConfig, chunk, signal, onProgress }) {
+      onProgress?.({ stageLabel: "读取章节样本…", percentage: 10 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法分析文风")
       const blocks: string[] = []
@@ -133,10 +134,12 @@ export function createStyleAnalysisAdapter(
         if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
       }
       if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")
+      onProgress?.({ stageLabel: "正在调用模型分析文风…", percentage: 40 })
       const raw = await dependencies.callModel([
         { role: "system", content: "你是专业的小说文风分析助手。只输出用户要求的 JSON,不要解释。" },
         { role: "user", content: buildStyleExtractionPrompt(blocks.join("\n\n———\n\n"), metadata.title) },
       ], llmConfig, signal)
+      onProgress?.({ stageLabel: "解析文风结果…", percentage: 85 })
       const profile = parseStyleProfileResult(raw, chunk.chapterIds)
       const evidence = parseStyleEvidenceResult(raw).flatMap((candidate, index): AnalysisEvidenceSnippet[] => {
         if (!chunk.chapterIds.includes(candidate.chapterId)) return []
@@ -159,12 +162,17 @@ export function createStyleAnalysisAdapter(
           updatedAt: dependencies.now(),
         }]
       })
+      onProgress?.({ stageLabel: "文风区块分析完成", percentage: 95 })
       return { result: { raw, profile }, evidence }
     },
-    async aggregate({ chunks, llmConfig, signal }) {
+    async aggregate({ chunks, llmConfig, signal, onProgress }) {
       if (chunks.length === 0) throw new Error("没有已完成的文风区块可供汇总")
       const evidenceIds: string[] = []
-      if (chunks.length === 1) return mergeStyleChunkProfiles(chunks, evidenceIds, dependencies.now())
+      if (chunks.length === 1) {
+        onProgress?.({ stageLabel: "单区块无需汇总", percentage: 95 })
+        return mergeStyleChunkProfiles(chunks, evidenceIds, dependencies.now())
+      }
+      onProgress?.({ stageLabel: "正在调用模型汇总文风…", percentage: 93 })
       const raw = await dependencies.callModel([
         { role: "system", content: "你只汇总已有文风分析,不分析范围外内容。" },
         { role: "user", content: aggregatePrompt(chunks) },
@@ -172,9 +180,11 @@ export function createStyleAnalysisAdapter(
       const profile = parseStyleProfileResult(raw, chunks.flatMap((chunk) => chunk.profile.sampledChapterIds))
       profile.generatedAt = dependencies.now()
       profile.evidenceIds = evidenceIds
+      onProgress?.({ stageLabel: "文风汇总完成", percentage: 95 })
       return profile
     },
-    async publish({ task, bookPath, projectPath, result, evidence }) {
+    async publish({ task, bookPath, projectPath, result, evidence, onProgress }) {
+      onProgress?.({ stageLabel: "正在发布文风结果…", percentage: 97 })
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布文风分析")
       result.evidenceIds = evidence.map((item) => item.id)
@@ -209,6 +219,7 @@ export function createStyleAnalysisAdapter(
       }
       await dependencies.saveManifest(bookPath, manifest)
       await dependencies.rebuildContextIndex(projectPath)
+      onProgress?.({ stageLabel: "文风结果已发布", percentage: 100 })
       return resultPath
     },
   }

+ 153 - 0
src/stores/book-analysis-pipeline-store.character-selection.spec.ts

@@ -0,0 +1,153 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/lib/novel/book-analysis/analysis-pipeline-storage", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/lib/novel/book-analysis/analysis-pipeline-storage")>()
+  return {
+    ...actual,
+    loadAndRecoverAnalysisTasks: vi.fn(async () => ({ tasks: [], chunks: [] })),
+    saveAnalysisTask: vi.fn(async () => undefined),
+    saveAnalysisChunk: vi.fn(async () => undefined),
+    replaceAnalysisTaskChunks: vi.fn(async () => undefined),
+  }
+})
+
+vi.mock("@/lib/novel/book-analysis/analysis-scheduler", () => ({
+  createAnalysisScheduler: vi.fn(() => ({
+    initialize: vi.fn(),
+    subscribe: vi.fn(() => () => undefined),
+    dispose: vi.fn(async () => undefined),
+    enqueue: vi.fn(async () => undefined),
+    pauseTask: vi.fn(async () => undefined),
+    continueTask: vi.fn(async () => undefined),
+    retryFailedChunk: vi.fn(async () => undefined),
+    cancelTask: vi.fn(async () => undefined),
+    getSnapshot: vi.fn(() => ({ tasks: [], chunks: [], progresses: {} })),
+    whenIdle: vi.fn(async () => undefined),
+  })),
+}))
+
+vi.mock("@/lib/novel/book-analysis/analysis-engine", () => ({
+  loadChapterList: vi.fn(async () => [
+    { chapterId: "ch-0001", title: "第一章", order: 1, wordCount: 1000, selected: false, analyzed: false },
+    { chapterId: "ch-0002", title: "第二章", order: 2, wordCount: 1000, selected: false, analyzed: false },
+  ]),
+  loadMetadata: vi.fn(async () => null),
+}))
+
+vi.mock("@/lib/has-usable-llm", () => ({
+  hasUsableLlm: vi.fn(() => true),
+}))
+
+describe("book-analysis-pipeline-store 角色选型门闩", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("配置含 characters 的范围后进入 awaiting-character-selection", async () => {
+    const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+    const store = createBookAnalysisPipelineStore()
+    await store.getState().initializeProject("E:/Novel-character-gate")
+    const task = await store.getState().createAwaitingRangeTask({
+      bookId: "book-1",
+      bookPath: "E:/Novel-character-gate/book-analysis/book-1",
+      selectedSkills: ["characters"],
+      forceNew: true,
+    })
+    expect(task).not.toBeNull()
+
+    await store.getState().configureTaskRange(task!.id, { startOrder: 1, endOrder: 2 }, ["characters"])
+    const updated = store.getState().tasks.find((item) => item.id === task!.id)
+    expect(updated?.status).toBe("awaiting-character-selection")
+    expect(updated?.targetCharacters).toBeUndefined()
+    expect(updated?.recognizedCharacters).toBeUndefined()
+  })
+
+  it("不含 characters 时配置后直接 queued", async () => {
+    const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+    const store = createBookAnalysisPipelineStore()
+    await store.getState().initializeProject("E:/Novel-style-only")
+    const task = await store.getState().createAwaitingRangeTask({
+      bookId: "book-2",
+      bookPath: "E:/Novel-style-only/book-analysis/book-2",
+      selectedSkills: ["style"],
+      forceNew: true,
+    })
+    await store.getState().configureTaskRange(task!.id, { startOrder: 1, endOrder: 2 }, ["style"])
+    expect(store.getState().tasks.find((item) => item.id === task!.id)?.status).toBe("queued")
+  })
+
+  it("确认角色选型后写入 targetCharacters 并进入 queued", async () => {
+    const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+    const store = createBookAnalysisPipelineStore()
+    await store.getState().initializeProject("E:/Novel-confirm-chars")
+    const task = await store.getState().createAwaitingRangeTask({
+      bookId: "book-3",
+      bookPath: "E:/Novel-confirm-chars/book-analysis/book-3",
+      selectedSkills: ["characters"],
+      forceNew: true,
+    })
+    await store.getState().configureTaskRange(task!.id, { startOrder: 1, endOrder: 2 }, ["characters"])
+    await store.getState().setTaskRecognizedCharacters(task!.id, [
+      {
+        id: "char-1",
+        name: "林远",
+        aliases: [],
+        appearances: 2,
+        chapterIndices: [0, 1],
+        importanceScore: 90,
+        category: "主角",
+        sourceBook: "book-3",
+      },
+      {
+        id: "char-2",
+        name: "路人",
+        aliases: [],
+        appearances: 1,
+        chapterIndices: [0],
+        importanceScore: 10,
+        category: "次要",
+        sourceBook: "book-3",
+      },
+    ])
+
+    await store.getState().confirmCharacterSelection(task!.id, ["char-1"])
+    const updated = store.getState().tasks.find((item) => item.id === task!.id)
+    expect(updated?.status).toBe("queued")
+    expect(updated?.targetCharacters).toEqual([
+      expect.objectContaining({ id: "char-1", name: "林远" }),
+    ])
+  })
+
+  it("未选角色时不能 startTask", async () => {
+    const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+    const store = createBookAnalysisPipelineStore()
+    await store.getState().initializeProject("E:/Novel-block-start")
+    const task = await store.getState().createAwaitingRangeTask({
+      bookId: "book-4",
+      bookPath: "E:/Novel-block-start/book-analysis/book-4",
+      selectedSkills: ["characters"],
+      forceNew: true,
+    })
+    await store.getState().configureTaskRange(task!.id, { startOrder: 1, endOrder: 2 }, ["characters"])
+    await expect(store.getState().startTask(task!.id)).rejects.toThrow("请先选择要深度分析的角色")
+  })
+
+  it("setRuntimeProgress 可写入并清除识别进度", async () => {
+    const { createBookAnalysisPipelineStore } = await import("./book-analysis-pipeline-store")
+    const store = createBookAnalysisPipelineStore()
+    await store.getState().initializeProject("E:/Novel-runtime-progress")
+    const key = "task-x:characters:recognition"
+    store.getState().setRuntimeProgress(key, {
+      stageLabel: "读取章节中",
+      percentage: 20,
+      currentItem: "第一章",
+    })
+    expect(store.getState().progresses[key]).toEqual({
+      stageLabel: "读取章节中",
+      percentage: 20,
+      currentItem: "第一章",
+    })
+    store.getState().setRuntimeProgress(key, null)
+    expect(store.getState().progresses[key]).toBeUndefined()
+  })
+})

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

@@ -15,6 +15,7 @@ vi.mock("@/lib/novel/book-analysis/analysis-pipeline-storage", async (importOrig
     loadAndRecoverAnalysisTasks: vi.fn(() => recoveryGate.promise),
     saveAnalysisTask: vi.fn(async () => undefined),
     saveAnalysisChunk: vi.fn(async () => undefined),
+    replaceAnalysisTaskChunks: vi.fn(async () => undefined),
   }
 })
 

+ 128 - 7
src/stores/book-analysis-pipeline-store.ts

@@ -8,7 +8,7 @@ import {
 } from "@/lib/novel/book-analysis/analysis-chunk-planner"
 import {
   loadAndRecoverAnalysisTasks,
-  saveAnalysisChunk,
+  replaceAnalysisTaskChunks,
   saveAnalysisTask,
 } from "@/lib/novel/book-analysis/analysis-pipeline-storage"
 import {
@@ -16,9 +16,11 @@ import {
   normalizeSelectedSkills,
   type AnalysisChapterRange,
   type AnalysisChunkRecord,
+  type AnalysisRuntimeProgress,
   type AnalysisSkill,
   type BookAnalysisPipelineTask,
 } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import { createAnalysisScheduler, type AnalysisScheduler } from "@/lib/novel/book-analysis/analysis-scheduler"
 import { characterAnalysisAdapter } from "@/lib/novel/book-analysis/character-analysis-adapter"
 import { storyAnalysisAdapter } from "@/lib/novel/book-analysis/story-analysis-adapter"
@@ -81,6 +83,7 @@ export interface BookAnalysisPipelineState {
   projectPath: string | null
   tasks: BookAnalysisPipelineTask[]
   chunks: AnalysisChunkRecord[]
+  progresses: Record<string, AnalysisRuntimeProgress>
   dismissedBatchIds: string[]
   initializeProject(projectPath: string): Promise<void>
   createAwaitingRangeTask(input: {
@@ -91,6 +94,11 @@ export interface BookAnalysisPipelineState {
     forceNew?: boolean
   }): Promise<BookAnalysisPipelineTask | null>
   configureTaskRange(taskId: string, range: AnalysisChapterRange, selectedSkills?: AnalysisSkill[]): Promise<void>
+  setTaskRecognizedCharacters(taskId: string, characters: RecognizedCharacter[]): Promise<void>
+  failTask(taskId: string, error: string): Promise<void>
+  confirmCharacterSelection(taskId: string, selectedIds: string[]): Promise<void>
+  /** 写入运行时进度(如角色识别);scheduler 快照不会覆盖以 `:recognition` 结尾的 key */
+  setRuntimeProgress(key: string, progress: AnalysisRuntimeProgress | null): void
   startTask(taskId: string): Promise<void>
   pauseTask(taskId: string): Promise<void>
   continueTask(taskId: string): Promise<void>
@@ -100,6 +108,10 @@ export interface BookAnalysisPipelineState {
   dispose(): Promise<void>
 }
 
+function isRecognitionProgressKey(key: string): boolean {
+  return key.endsWith(":recognition")
+}
+
 export function createBookAnalysisPipelineStore() {
   let scheduler: AnalysisScheduler | null = null
   let unsubscribe: (() => void) | null = null
@@ -109,6 +121,7 @@ export function createBookAnalysisPipelineStore() {
     projectPath: null,
     tasks: [],
     chunks: [],
+    progresses: {},
     dismissedBatchIds: [],
     async initializeProject(rawPath) {
       const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
@@ -122,7 +135,7 @@ export function createBookAnalysisPipelineStore() {
       unsubscribe = null
       await scheduler?.dispose()
       scheduler = null
-      set({ projectPath, tasks: [], chunks: [], dismissedBatchIds: [] })
+      set({ projectPath, tasks: [], chunks: [], progresses: {}, dismissedBatchIds: [] })
       const recovered = await loadAndRecoverAnalysisTasks(projectPath)
       if (token !== generation || get().projectPath !== projectPath) return
       const currentState = get()
@@ -145,11 +158,20 @@ export function createBookAnalysisPipelineStore() {
       })
       scheduler = nextScheduler
       nextScheduler.initialize(mergedTasks, mergedChunks)
-      set({ tasks: mergedTasks, chunks: mergedChunks })
+      set({ tasks: mergedTasks, chunks: mergedChunks, progresses: {} })
       setActiveAnalysisSnapshot(projectPath, mergedTasks)
       unsubscribe = nextScheduler.subscribe((snapshot) => {
         if (token !== generation || scheduler !== nextScheduler) return
-        set({ tasks: snapshot.tasks, chunks: snapshot.chunks })
+        set((state) => {
+          const preserved = Object.fromEntries(
+            Object.entries(state.progresses).filter(([key]) => isRecognitionProgressKey(key)),
+          )
+          return {
+            tasks: snapshot.tasks,
+            chunks: snapshot.chunks,
+            progresses: { ...preserved, ...snapshot.progresses },
+          }
+        })
         setActiveAnalysisSnapshot(projectPath, snapshot.tasks)
       })
     },
@@ -188,13 +210,16 @@ export function createBookAnalysisPipelineStore() {
         { maxChunkChars: computeAnalysisChunkCharLimit(llmConfig.maxContextSize) },
       )
       const now = Date.now()
+      const needsCharacterSelection = selectedSkills.includes("characters")
       const configured: BookAnalysisPipelineTask = {
         ...task,
         selectedSkills,
         range,
-        status: "queued",
+        status: needsCharacterSelection ? "awaiting-character-selection" : "queued",
         currentSkill: null,
         error: null,
+        recognizedCharacters: undefined,
+        targetCharacters: undefined,
         modules: Object.fromEntries(ANALYSIS_SKILL_ORDER.map((skill) => [skill, {
           ...task.modules[skill],
           status: selectedSkills.includes(skill) ? "pending" : "skipped",
@@ -222,18 +247,98 @@ export function createBookAnalysisPipelineStore() {
         updatedAt: now,
       })))
       await saveAnalysisTask(configured)
-      for (const chunk of chunks) await saveAnalysisChunk(task.bookPath, chunk)
+      await replaceAnalysisTaskChunks(task.bookPath, taskId, chunks)
       set((state) => ({
         tasks: state.tasks.map((item) => item.id === taskId ? configured : item),
         chunks: [...state.chunks.filter((chunk) => chunk.taskId !== taskId), ...chunks],
       }))
       setActiveAnalysisSnapshot(task.projectPath, get().tasks)
     },
+    async setTaskRecognizedCharacters(taskId, characters) {
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!task) throw new Error("未找到分析任务")
+      if (task.status !== "awaiting-character-selection") {
+        throw new Error("当前任务不在待选择角色状态")
+      }
+      const updated: BookAnalysisPipelineTask = {
+        ...task,
+        recognizedCharacters: characters,
+        error: null,
+        updatedAt: Date.now(),
+      }
+      await saveAnalysisTask(updated)
+      set((state) => ({
+        tasks: state.tasks.map((item) => item.id === taskId ? updated : item),
+      }))
+      setActiveAnalysisSnapshot(task.projectPath, get().tasks)
+    },
+    async failTask(taskId, error) {
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!task) throw new Error("未找到分析任务")
+      const updated: BookAnalysisPipelineTask = {
+        ...task,
+        status: "failed",
+        error,
+        updatedAt: Date.now(),
+      }
+      await saveAnalysisTask(updated)
+      set((state) => ({
+        tasks: state.tasks.map((item) => item.id === taskId ? updated : item),
+      }))
+      setActiveAnalysisSnapshot(task.projectPath, get().tasks)
+    },
+    async confirmCharacterSelection(taskId, selectedIds) {
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!task) throw new Error("未找到分析任务")
+      if (task.status !== "awaiting-character-selection") {
+        throw new Error("当前任务不在待选择角色状态")
+      }
+      const recognized = task.recognizedCharacters ?? []
+      if (recognized.length === 0) throw new Error("尚未完成角色识别")
+      const idSet = new Set(selectedIds)
+      const targetCharacters = recognized.filter((character) => idSet.has(character.id))
+      if (targetCharacters.length === 0) throw new Error("请至少选择一个角色")
+      const updated: BookAnalysisPipelineTask = {
+        ...task,
+        targetCharacters,
+        status: "queued",
+        error: null,
+        updatedAt: Date.now(),
+      }
+      await saveAnalysisTask(updated)
+      set((state) => ({
+        tasks: state.tasks.map((item) => item.id === taskId ? updated : item),
+      }))
+      setActiveAnalysisSnapshot(task.projectPath, get().tasks)
+    },
+    setRuntimeProgress(key, progress) {
+      set((state) => {
+        if (progress === null) {
+          if (!(key in state.progresses)) return state
+          const next = { ...state.progresses }
+          delete next[key]
+          return { progresses: next }
+        }
+        return {
+          progresses: {
+            ...state.progresses,
+            [key]: {
+              stageLabel: progress.stageLabel,
+              percentage: Math.max(0, Math.min(100, Math.round(progress.percentage))),
+              ...(progress.currentItem ? { currentItem: progress.currentItem } : {}),
+            },
+          },
+        }
+      })
+    },
     async startTask(taskId) {
       const current = scheduler
       const task = get().tasks.find((item) => item.id === taskId)
       if (!current || !task) throw new Error("分析任务尚未初始化")
       if (!task.range || task.status === "awaiting-range") throw new Error("请先选择章节范围")
+      if (task.status === "awaiting-character-selection") {
+        throw new Error("请先选择要深度分析的角色")
+      }
       const wikiState = useWikiStore.getState()
       if (!hasUsableLlm(resolveDefaultModel(wikiState.llmConfig), wikiState.providerConfigs)) {
         throw new Error("未配置可用模型,请先在设置中配置默认模型")
@@ -253,6 +358,22 @@ export function createBookAnalysisPipelineStore() {
       await scheduler.retryFailedChunk(taskId, skill, chunkId)
     },
     async cancelTask(taskId) {
+      const task = get().tasks.find((item) => item.id === taskId)
+      if (!task) throw new Error("未找到分析任务")
+      if (task.status === "awaiting-range" || task.status === "awaiting-character-selection") {
+        const updated: BookAnalysisPipelineTask = {
+          ...task,
+          status: "cancelled",
+          error: null,
+          updatedAt: Date.now(),
+        }
+        await saveAnalysisTask(updated)
+        set((state) => ({
+          tasks: state.tasks.map((item) => item.id === taskId ? updated : item),
+        }))
+        setActiveAnalysisSnapshot(task.projectPath, get().tasks)
+        return
+      }
       if (!scheduler) throw new Error("分析任务尚未初始化")
       await scheduler.cancelTask(taskId)
     },
@@ -267,7 +388,7 @@ export function createBookAnalysisPipelineStore() {
       await scheduler?.dispose()
       scheduler = null
       if (projectPath) clearActiveAnalysisSnapshot(projectPath)
-      set({ projectPath: null, tasks: [], chunks: [], dismissedBatchIds: [] })
+      set({ projectPath: null, tasks: [], chunks: [], progresses: {}, dismissedBatchIds: [] })
     },
   }))
 }

+ 0 - 425
src/stores/de-ai-batch-store.spec.ts

@@ -1,425 +0,0 @@
-import { describe, expect, it, vi } from "vitest"
-import { createDeAiBatchStore } from "./de-ai-batch-store"
-import type { DeAiChapterRunner } from "@/lib/novel/de-ai-batch/engine"
-import type {
-  CreateDeAiBatchTaskInput,
-  DeAiBatchChapter,
-  DeAiBatchTask,
-  DeAiBatchTaskRecord,
-} from "@/lib/novel/de-ai-batch/types"
-
-function input(workId: string, chapterIds = ["chapter-1", "chapter-2"]): CreateDeAiBatchTaskInput {
-  return {
-    projectPath: "C:/project",
-    workId,
-    workTitle: `作品-${workId}`,
-    modelKey: "openai/test",
-    skillId: "skill-a",
-    skillName: "自然改写",
-    skillContent: "规则",
-    chapters: chapterIds.map((id, index) => ({
-      id,
-      title: `第${index + 1}章`,
-      order: index + 1,
-      sourcePath: `C:/project/wiki/chapters/${workId}/${id}.md`,
-      sourceContent: `${workId}-${id}-原文`,
-    })),
-  }
-}
-
-function toRecord(source: CreateDeAiBatchTaskInput, id = `task-${source.workId}`): DeAiBatchTaskRecord {
-  const now = 1
-  const task: DeAiBatchTask = {
-    version: 1,
-    id,
-    projectPath: source.projectPath,
-    workId: source.workId,
-    workTitle: source.workTitle,
-    modelKey: source.modelKey,
-    skillId: source.skillId,
-    skillName: source.skillName,
-    skillContent: source.skillContent,
-    status: "queued",
-    chapterIds: source.chapters.map((chapter) => chapter.id),
-    error: null,
-    createdAt: now,
-    startedAt: null,
-    completedAt: null,
-    updatedAt: now,
-  }
-  const chapters: DeAiBatchChapter[] = source.chapters.map((chapter) => ({
-    version: 1,
-    id: chapter.id,
-    taskId: id,
-    title: chapter.title,
-    order: chapter.order,
-    sourcePath: chapter.sourcePath,
-    sourceContent: chapter.sourceContent,
-    candidateContent: null,
-    status: "pending",
-    runId: null,
-    generation: 0,
-    error: null,
-    createdAt: now,
-    updatedAt: now,
-  }))
-  return { task, chapters }
-}
-
-function setup(options: {
-  loaded?: DeAiBatchTaskRecord[]
-  runner?: DeAiChapterRunner
-  validateTaskModel?: (task: DeAiBatchTask) => string | null
-} = {}) {
-  const records = new Map<string, DeAiBatchTaskRecord>()
-  for (const item of options.loaded ?? []) records.set(item.task.id, structuredClone(item))
-  const storage = {
-    loadProject: vi.fn(async (projectPath: string) => Array.from(records.values())
-      .filter((item) => item.task.projectPath === projectPath)
-      .map((item) => structuredClone(item))),
-    createTask: vi.fn(async (source: CreateDeAiBatchTaskInput) => {
-      const item = toRecord(source)
-      records.set(item.task.id, structuredClone(item))
-      return item
-    }),
-    saveTask: vi.fn(async (task: DeAiBatchTask) => {
-      const current = records.get(task.id)
-      if (current) current.task = structuredClone(task)
-    }),
-    saveChapter: vi.fn(async (chapter: DeAiBatchChapter) => {
-      const current = records.get(chapter.taskId)
-      if (!current) return
-      const index = current.chapters.findIndex((item) => item.id === chapter.id)
-      if (index >= 0) current.chapters[index] = structuredClone(chapter)
-    }),
-  }
-  const runner = options.runner ?? vi.fn<DeAiChapterRunner>(async ({ chapter }) => `${chapter.sourceContent}-候选`)
-  const applyChapter = vi.fn(async () => undefined)
-  const store = createDeAiBatchStore({ storage, runner, applyChapter, validateTaskModel: options.validateTaskModel ?? (() => null), now: (() => {
-    let value = 10
-    return () => value++
-  })() })
-  return { store, storage, runner, applyChapter }
-}
-
-async function waitFor(assertion: () => void): Promise<void> {
-  const started = Date.now()
-  while (true) {
-    try {
-      assertion()
-      return
-    } catch (error) {
-      if (Date.now() - started > 1000) throw error
-      await new Promise((resolve) => setTimeout(resolve, 0))
-    }
-  }
-}
-
-describe("de-ai batch store", () => {
-  it("初始化只加载并展示 interrupted 任务,不自动继续", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "interrupted"
-    const { store, runner } = setup({ loaded: [restored] })
-
-    await store.getState().initializeProject("C:/project")
-
-    expect(store.getState().records).toHaveLength(1)
-    expect(store.getState().records[0].task.status).toBe("interrupted")
-    expect(runner).not.toHaveBeenCalled()
-  })
-
-  it("新批次按作品创建独立任务并交给 scheduler", async () => {
-    const { store, storage, runner } = setup()
-    await store.getState().initializeProject("C:/project")
-
-    await store.getState().createBatch([input("a"), input("b")])
-    await waitFor(() => {
-      expect(store.getState().records.map((item) => item.task.status)).toEqual(["reviewing", "reviewing"])
-    })
-
-    expect(storage.createTask).toHaveBeenCalledTimes(2)
-    expect(new Set(vi.mocked(runner).mock.calls.map(([call]) => call.task.workId))).toEqual(new Set(["a", "b"]))
-  })
-
-  it("继续 interrupted 任务时跳过已生成和已确认章节", async () => {
-    const restored = toRecord(input("a", ["chapter-1", "chapter-2", "chapter-3"]))
-    restored.task.status = "interrupted"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "已有候选" }
-    restored.chapters[1] = { ...restored.chapters[1], status: "confirmed", candidateContent: "已确认候选" }
-    const { store, runner } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-
-    expect(await store.getState().continueTask(restored.task.id)).toBe(true)
-    await waitFor(() => expect(store.getState().records[0].task.status).toBe("reviewing"))
-
-    expect(runner).toHaveBeenCalledTimes(1)
-    expect(vi.mocked(runner).mock.calls[0][0].chapter.id).toBe("chapter-3")
-  })
-
-  it("completed 任务不允许整批继续", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "completed"
-    restored.chapters = restored.chapters.map((chapter) => ({ ...chapter, status: "confirmed" }))
-    const { store, runner } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-
-    expect(await store.getState().continueTask(restored.task.id)).toBe(false)
-    expect(runner).not.toHaveBeenCalled()
-  })
-
-  it.each(["queued", "running", "reviewing", "cancelled", "completed"] as const)(
-    "%s 任务不在继续白名单中",
-    async (status) => {
-      const restored = toRecord(input("a"))
-      restored.task.status = status
-      const { store, runner } = setup({ loaded: [restored] })
-      await store.getState().initializeProject("C:/project")
-      expect(await store.getState().continueTask(restored.task.id)).toBe(false)
-      expect(runner).not.toHaveBeenCalled()
-    },
-  )
-
-  it("同一章节重复确认时只执行一次写回", async () => {
-    let release!: () => void
-    const applyGate = new Promise<void>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
-    const { store, applyChapter } = setup({ loaded: [restored] })
-    vi.mocked(applyChapter).mockImplementationOnce(async () => applyGate)
-    await store.getState().initializeProject("C:/project")
-    const first = store.getState().confirmChapter(restored.task.id, "chapter-1")
-    const second = store.getState().confirmChapter(restored.task.id, "chapter-1")
-    expect(await second).toBe(false)
-    expect(applyChapter).toHaveBeenCalledTimes(1)
-    release()
-    expect(await first).toBe(true)
-    expect(store.getState().records[0].chapters[0].status).toBe("confirmed")
-  })
-
-  it("同一章节重新生成期间拒绝取消", async () => {
-    let release!: (value: string) => void
-    const gate = new Promise<string>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
-    const runner = vi.fn<DeAiChapterRunner>(async () => gate)
-    const { store } = setup({ loaded: [restored], runner })
-    await store.getState().initializeProject("C:/project")
-    const regenerating = store.getState().regenerateChapter(restored.task.id, "chapter-1")
-    expect(await store.getState().cancelChapter(restored.task.id, "chapter-1")).toBe(false)
-    release("新候选")
-    expect(await regenerating).toBe(true)
-    expect(store.getState().records[0].chapters[0]).toMatchObject({ status: "ready", candidateContent: "新候选" })
-  })
-
-  it("最终确认写回前再次拒绝被篡改到项目外的 sourcePath", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选", sourcePath: "C:/project/wiki/chapters/../../项目外.md" }
-    const { store, applyChapter } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-    await expect(store.getState().confirmChapter(restored.task.id, "chapter-1"))
-      .rejects.toThrow("章节源文件不属于当前项目章节目录")
-    expect(applyChapter).not.toHaveBeenCalled()
-  })
-  it("支持指定章重新生成、取消当前章和打开审核", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
-    const { store, runner } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-
-    store.getState().openReview(restored.task.id, "chapter-1")
-    expect(store.getState()).toMatchObject({ reviewOpen: true, reviewTaskId: restored.task.id, reviewChapterId: "chapter-1" })
-
-    await store.getState().regenerateChapter(restored.task.id, "chapter-1")
-    expect(store.getState().records[0].chapters[0].candidateContent).toContain("候选")
-    expect(runner).toHaveBeenCalledTimes(1)
-
-    await store.getState().cancelChapter(restored.task.id, "chapter-2")
-    expect(store.getState().records[0].chapters[1].status).toBe("cancelled")
-  })
-
-  it("确认只覆盖当前章并持久化确认状态", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters = restored.chapters.map((chapter, index) => ({
-      ...chapter,
-      status: "ready",
-      candidateContent: `候选-${index + 1}`,
-    }))
-    const { store, applyChapter, storage } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-
-    expect(await store.getState().confirmChapter(restored.task.id, "chapter-1")).toBe(true)
-
-    expect(applyChapter).toHaveBeenCalledTimes(1)
-    expect(applyChapter).toHaveBeenCalledWith("C:/project/wiki/chapters/a/chapter-1.md", "候选-1")
-    expect(store.getState().records[0].chapters.map((chapter) => chapter.status)).toEqual(["confirmed", "ready"])
-    expect(storage.saveChapter).toHaveBeenCalledWith(expect.objectContaining({ id: "chapter-1", status: "confirmed" }), "C:/project")
-  })
-
-  it("confirms and persists the user-edited candidate content", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = {
-      ...restored.chapters[0],
-      status: "ready",
-      candidateContent: "model candidate",
-    }
-    const { store, applyChapter, storage } = setup({ loaded: [restored] })
-    await store.getState().initializeProject("C:/project")
-
-    expect(await store.getState().confirmChapter(
-      restored.task.id,
-      "chapter-1",
-      "user-edited candidate",
-    )).toBe(true)
-
-    expect(applyChapter).toHaveBeenCalledWith(
-      "C:/project/wiki/chapters/a/chapter-1.md",
-      "user-edited candidate",
-    )
-    expect(store.getState().records[0].chapters[0]).toMatchObject({
-      status: "confirmed",
-      candidateContent: "user-edited candidate",
-    })
-    expect(storage.saveChapter).toHaveBeenCalledWith(
-      expect.objectContaining({
-        id: "chapter-1",
-        status: "confirmed",
-        candidateContent: "user-edited candidate",
-      }),
-      "C:/project",
-    )
-  })
-
-  it("取消批次只停止该作品,其他作品继续", async () => {
-    const { store } = setup()
-    await store.getState().initializeProject("C:/project")
-    await store.getState().createBatch([input("a"), input("b")])
-    await store.getState().cancelTask("task-a")
-
-    expect(store.getState().records.find((item) => item.task.id === "task-a")?.task.status).toBe("cancelled")
-    expect(store.getState().records.find((item) => item.task.id === "task-b")?.task.status).not.toBe("cancelled")
-  })
-
-  it("恢复初始化前校验任务绑定模型,缺失时中文失败且不自动换模型", async () => {
-    const restored = toRecord(input("a"))
-    restored.task.status = "interrupted"
-    const error = "任务绑定模型“custom-a/removed”已不可用,请重新配置该模型后再继续"
-    const { store, storage, runner } = setup({
-      loaded: [restored],
-      validateTaskModel: () => error,
-    })
-
-    await store.getState().initializeProject("C:/project")
-
-    expect(store.getState().records[0].task).toMatchObject({ status: "failed", error })
-    expect(storage.saveTask).toHaveBeenCalledWith(expect.objectContaining({ id: restored.task.id, status: "failed", error }))
-    expect(runner).not.toHaveBeenCalled()
-  })
-
-  it("setConcurrency 立即更新当前 scheduler 而不重建项目", async () => {
-    const { store } = setup()
-    await store.getState().initializeProject("C:/project")
-
-    store.getState().setConcurrency(5)
-
-    expect(store.getState().concurrency).toBe(5)
-    expect(store.getState().scheduler.concurrency).toBe(5)
-  })
-
-  it("确认等待期间切换项目后旧操作失效且不会插入旧 record", async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
-    const { store, applyChapter, storage } = setup({ loaded: [restored] })
-    vi.mocked(applyChapter).mockImplementationOnce(async () => gate)
-    await store.getState().initializeProject("C:/project")
-
-    const confirming = store.getState().confirmChapter(restored.task.id, "chapter-1")
-    await store.getState().initializeProject("C:/other")
-    release()
-
-    expect(await confirming).toBe(false)
-    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
-    expect(storage.saveChapter).not.toHaveBeenCalledWith(expect.objectContaining({ status: "confirmed" }), expect.anything())
-  })
-
-  it("重新生成等待期间 dispose 后旧结果不会重新插入 record", async () => {
-    let release!: (value: string) => void
-    const gate = new Promise<string>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
-    const { store } = setup({ loaded: [restored], runner: vi.fn(async () => gate) })
-    await store.getState().initializeProject("C:/project")
-
-    const regenerating = store.getState().regenerateChapter(restored.task.id, "chapter-1")
-    store.getState().dispose()
-    await store.getState().initializeProject("C:/other")
-    release("迟到候选")
-
-    expect(await regenerating).toBe(false)
-    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
-  })
-
-  it("章节取消等待期间切换项目后不会把旧 record 插入新项目", async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    const { store, storage } = setup({ loaded: [restored] })
-    vi.mocked(storage.saveChapter).mockImplementationOnce(async () => gate)
-    await store.getState().initializeProject("C:/project")
-
-    const cancelling = store.getState().cancelChapter(restored.task.id, "chapter-1")
-    await waitFor(() => expect(storage.saveChapter).toHaveBeenCalled())
-    await store.getState().initializeProject("C:/other")
-    release()
-
-    expect(await cancelling).toBe(false)
-    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
-  })
-  it("章节确认进行中拒绝整批取消,保持磁盘与状态一致", async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
-    const { store, applyChapter } = setup({ loaded: [restored] })
-    vi.mocked(applyChapter).mockImplementationOnce(async () => gate)
-    await store.getState().initializeProject("C:/project")
-
-    const confirming = store.getState().confirmChapter(restored.task.id, "chapter-1")
-    expect(await store.getState().cancelTask(restored.task.id)).toBe(false)
-    release()
-    expect(await confirming).toBe(true)
-    expect(store.getState().records[0]).toMatchObject({
-      task: { status: "reviewing" },
-      chapters: [expect.objectContaining({ id: "chapter-1", status: "confirmed" }), expect.anything()],
-    })
-  })
-
-  it("整批取消持有任务锁期间拒绝章节确认", async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const restored = toRecord(input("a"))
-    restored.task.status = "reviewing"
-    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
-    const { store, storage, applyChapter } = setup({ loaded: [restored] })
-    vi.mocked(storage.saveChapter).mockImplementationOnce(async () => gate)
-    await store.getState().initializeProject("C:/project")
-
-    const cancelling = store.getState().cancelTask(restored.task.id)
-    await waitFor(() => expect(storage.saveChapter).toHaveBeenCalled())
-    expect(await store.getState().confirmChapter(restored.task.id, "chapter-1")).toBe(false)
-    expect(applyChapter).not.toHaveBeenCalled()
-    release()
-    expect(await cancelling).toBe(true)
-    expect(store.getState().records[0].task.status).toBe("cancelled")
-  })})

+ 0 - 466
src/stores/de-ai-batch-store.ts

@@ -1,466 +0,0 @@
-import { create } from "zustand"
-import { createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
-import { assertChapterSourcePath } from "@/lib/novel/de-ai-batch/chapter-path"
-import { createDefaultDeAiBatchLlmRunner, getDeAiBatchTaskModelError } from "@/lib/novel/de-ai-batch/llm-runner"
-import {
-  createDeAiBatchEngine,
-  type DeAiBatchEngine,
-  type DeAiBatchEngineStorage,
-  type DeAiChapterRunner,
-} from "@/lib/novel/de-ai-batch/engine"
-import {
-  createDeAiBatchScheduler,
-  type DeAiBatchScheduler,
-  type DeAiBatchSchedulerSnapshot,
-} from "@/lib/novel/de-ai-batch/scheduler"
-import {
-  createDeAiBatchStorage,
-  type DeAiBatchStorage,
-} from "@/lib/novel/de-ai-batch/storage"
-import type {
-  CreateDeAiBatchTaskInput,
-  DeAiBatchChapter,
-  DeAiBatchTask,
-  DeAiBatchTaskRecord,
-} from "@/lib/novel/de-ai-batch/types"
-
-const EMPTY_SCHEDULER: DeAiBatchSchedulerSnapshot = {
-  concurrency: 3,
-  activeTaskIds: [],
-  queuedTaskIds: [],
-  disposed: false,
-}
-
-interface StoreStorage extends DeAiBatchEngineStorage {
-  loadProject(projectPath: string): Promise<DeAiBatchTaskRecord[]>
-  createTask(input: CreateDeAiBatchTaskInput): Promise<DeAiBatchTaskRecord>
-}
-
-interface ProjectContext {
-  generation: number
-  projectPath: string
-}
-
-export interface CreateDeAiBatchStoreOptions {
-  storage?: StoreStorage | DeAiBatchStorage
-  runner: DeAiChapterRunner
-  validateTaskModel?: (task: DeAiBatchTask) => string | null
-  applyChapter?: (path: string, content: string) => Promise<void>
-  concurrency?: number
-  now?: () => number
-}
-
-export interface DeAiBatchState {
-  projectPath: string | null
-  records: DeAiBatchTaskRecord[]
-  concurrency: number
-  scheduler: DeAiBatchSchedulerSnapshot
-  panelCollapsed: boolean
-  reviewOpen: boolean
-  reviewTaskId: string | null
-  reviewChapterId: string | null
-  initializeProject(projectPath: string): Promise<void>
-  createBatch(inputs: CreateDeAiBatchTaskInput[]): Promise<void>
-  continueTask(taskId: string): Promise<boolean>
-  regenerateChapter(taskId: string, chapterId: string): Promise<boolean>
-  cancelChapter(taskId: string, chapterId: string): Promise<boolean>
-  confirmChapter(taskId: string, chapterId: string, candidateContent?: string): Promise<boolean>
-  cancelTask(taskId: string): Promise<boolean>
-  openReview(taskId: string, chapterId?: string): void
-  closeReview(): void
-  setReviewChapter(chapterId: string): void
-  setPanelCollapsed(collapsed: boolean): void
-  setConcurrency(value: number): void
-  dispose(): void
-}
-
-function taskAfterChapterChange(
-  task: DeAiBatchTask,
-  chapters: DeAiBatchChapter[],
-  now: number,
-): DeAiBatchTask {
-  const allConfirmed = chapters.length > 0 && chapters.every((chapter) => chapter.status === "confirmed")
-  const hasFailedOrCancelled = chapters.some((chapter) => chapter.status === "failed" || chapter.status === "cancelled")
-  return {
-    ...task,
-    status: allConfirmed ? "completed" : hasFailedOrCancelled ? "partial" : "reviewing",
-    completedAt: allConfirmed ? now : null,
-    error: null,
-    updatedAt: now,
-  }
-}
-
-export function createDeAiBatchStore(options: CreateDeAiBatchStoreOptions) {
-  const storage = options.storage ?? createDeAiBatchStorage()
-  const applyChapter = options.applyChapter ?? createDeAiBatchChapterApplier()
-  const now = options.now ?? Date.now
-  const validateTaskModel = options.validateTaskModel ?? getDeAiBatchTaskModelError
-  let scheduler: DeAiBatchScheduler | null = null
-  const engines = new Map<string, DeAiBatchEngine>()
-  let generation = 0
-  const chapterActions = new Map<string, symbol>()
-  const taskActions = new Map<string, symbol>()
-
-  return create<DeAiBatchState>((set, get) => {
-    function captureProjectContext(projectPath: string): ProjectContext | null {
-      return get().projectPath === projectPath ? { generation, projectPath } : null
-    }
-
-    function isProjectContextCurrent(context: ProjectContext): boolean {
-      return generation === context.generation && get().projectPath === context.projectPath
-    }
-
-    function upsertRecord(record: DeAiBatchTaskRecord, context: ProjectContext): void {
-      if (!isProjectContextCurrent(context)) return
-      set((state) => {
-        if (generation !== context.generation || state.projectPath !== context.projectPath) return state
-        const index = state.records.findIndex((item) => item.task.id === record.task.id)
-        const records = [...state.records]
-        if (index >= 0) records[index] = { task: { ...record.task }, chapters: [...record.chapters] }
-        else records.push({ task: { ...record.task }, chapters: [...record.chapters] })
-        return { records }
-      })
-    }
-
-    function chapterActionKey(taskId: string, chapterId: string): string {
-      return `${taskId}:${chapterId}`
-    }
-
-    function beginChapterAction(taskId: string, chapterId: string): symbol | null {
-      const key = chapterActionKey(taskId, chapterId)
-      if (taskActions.has(taskId) || chapterActions.has(key)) return null
-      const token = Symbol(key)
-      chapterActions.set(key, token)
-      return token
-    }
-
-    function endChapterAction(taskId: string, chapterId: string, token: symbol): void {
-      const key = chapterActionKey(taskId, chapterId)
-      if (chapterActions.get(key) === token) chapterActions.delete(key)
-    }
-
-    function beginTaskAction(taskId: string): symbol | null {
-      if (taskActions.has(taskId)) return null
-      if (Array.from(chapterActions.keys()).some((key) => key.startsWith(`${taskId}:`))) return null
-      const token = Symbol(taskId)
-      taskActions.set(taskId, token)
-      return token
-    }
-
-    function endTaskAction(taskId: string, token: symbol): void {
-      if (taskActions.get(taskId) === token) taskActions.delete(taskId)
-    }
-
-    function findRecord(taskId: string): DeAiBatchTaskRecord | null {
-      return get().records.find((record) => record.task.id === taskId) ?? null
-    }
-
-    function getEngine(record: DeAiBatchTaskRecord, context: ProjectContext): DeAiBatchEngine {
-      const existing = engines.get(record.task.id)
-      if (existing) return existing
-      const guardedStorage: DeAiBatchEngineStorage = {
-        saveTask: async (task) => {
-          if (!isProjectContextCurrent(context)) return
-          await storage.saveTask(task)
-        },
-        saveChapter: async (chapter, projectPath) => {
-          if (!isProjectContextCurrent(context)) return
-          await storage.saveChapter(chapter, projectPath ?? context.projectPath)
-        },
-      }
-      const engine = createDeAiBatchEngine({
-        runner: options.runner,
-        storage: guardedStorage,
-        now,
-        onChange: (changed) => upsertRecord(changed, context),
-      })
-      engines.set(record.task.id, engine)
-      return engine
-    }
-
-    function makeScheduler(concurrency: number, context: ProjectContext): DeAiBatchScheduler {
-      return createDeAiBatchScheduler({
-        concurrency,
-        createAgent: (record) => {
-          const engine = getEngine(record, context)
-          return {
-            run: () => engine.runTask(record),
-            dispose: () => {
-              engine.dispose()
-              if (engines.get(record.task.id) === engine) engines.delete(record.task.id)
-            },
-          }
-        },
-        onChange: (snapshot) => {
-          if (isProjectContextCurrent(context)) set({ scheduler: snapshot })
-        },
-        onTaskComplete: (record) => upsertRecord(record, context),
-        onTaskError: (record, error) => {
-          if (!isProjectContextCurrent(context)) return
-          const failed: DeAiBatchTaskRecord = {
-            ...record,
-            task: { ...record.task, status: "failed", error: error.message, updatedAt: now() },
-          }
-          upsertRecord(failed, context)
-          if (isProjectContextCurrent(context)) void storage.saveTask(failed.task)
-        },
-      })
-    }
-
-    async function initializeProject(projectPath: string): Promise<void> {
-      const context: ProjectContext = { generation: ++generation, projectPath }
-      scheduler?.dispose()
-      scheduler = null
-      for (const engine of engines.values()) engine.dispose()
-      engines.clear()
-      chapterActions.clear()
-      taskActions.clear()
-      set({
-        projectPath,
-        records: [],
-        scheduler: { ...EMPTY_SCHEDULER, concurrency: get().concurrency },
-        reviewOpen: false,
-        reviewTaskId: null,
-        reviewChapterId: null,
-      })
-      const loaded = await storage.loadProject(projectPath)
-      if (!isProjectContextCurrent(context)) return
-      for (const record of loaded) {
-        const needsModel = record.chapters.some((chapter) =>
-          chapter.status === "pending" || chapter.status === "generating" || chapter.status === "failed",
-        )
-        if (!needsModel || record.task.status === "completed" || record.task.status === "cancelled") continue
-        const modelError = validateTaskModel(record.task)
-        if (!modelError) continue
-        if (!isProjectContextCurrent(context)) return
-        record.task = {
-          ...record.task,
-          status: "failed",
-          error: modelError,
-          updatedAt: now(),
-        }
-        await storage.saveTask(record.task)
-        if (!isProjectContextCurrent(context)) return
-      }
-      if (!isProjectContextCurrent(context)) return
-      scheduler = makeScheduler(get().concurrency, context)
-      set({ records: loaded, scheduler: scheduler.getSnapshot() })
-    }
-
-    async function createBatch(inputs: CreateDeAiBatchTaskInput[]): Promise<void> {
-      if (inputs.length === 0) return
-      const projectPath = get().projectPath
-      if (!projectPath) throw new Error("??????")
-      if (inputs.some((input) => input.projectPath !== projectPath)) throw new Error("???????????")
-      const context = captureProjectContext(projectPath)
-      if (!context) return
-      if (!scheduler) scheduler = makeScheduler(get().concurrency, context)
-      const records: DeAiBatchTaskRecord[] = []
-      for (const input of inputs) {
-        const record = await storage.createTask(input)
-        if (!isProjectContextCurrent(context)) return
-        records.push(record)
-      }
-      if (!isProjectContextCurrent(context)) return
-      set((state) => state.projectPath === projectPath ? { records: [...state.records, ...records] } : state)
-      scheduler?.enqueue(records)
-    }
-
-    async function continueTask(taskId: string): Promise<boolean> {
-      const token = beginTaskAction(taskId)
-      if (!token) return false
-      try {
-        const record = findRecord(taskId)
-        if (!record || !(["interrupted", "failed", "partial"] as DeAiBatchTask["status"][]).includes(record.task.status)) return false
-        const context = captureProjectContext(record.task.projectPath)
-        if (!context) return false
-        const modelError = validateTaskModel(record.task)
-        if (modelError) {
-          record.task = { ...record.task, status: "failed", error: modelError, updatedAt: now() }
-          if (!isProjectContextCurrent(context)) return false
-          await storage.saveTask(record.task)
-          if (!isProjectContextCurrent(context)) return false
-          upsertRecord(record, context)
-          return false
-        }
-        if (!isProjectContextCurrent(context)) return false
-        if (!scheduler) scheduler = makeScheduler(get().concurrency, context)
-        scheduler.enqueue(record)
-        return true
-      } finally {
-        endTaskAction(taskId, token)
-      }
-    }
-
-    async function regenerateChapter(taskId: string, chapterId: string): Promise<boolean> {
-      const token = beginChapterAction(taskId, chapterId)
-      if (!token) return false
-      try {
-        const record = findRecord(taskId)
-        if (!record) return false
-        const context = captureProjectContext(record.task.projectPath)
-        if (!context) return false
-        const chapter = record.chapters.find((item) => item.id === chapterId)
-        if (!chapter || chapter.status === "confirmed" || chapter.status === "generating") return false
-        await getEngine(record, context).regenerateChapter(record, chapterId)
-        if (!isProjectContextCurrent(context)) return false
-        const latest = record.chapters.find((item) => item.id === chapterId)
-        if (!latest || latest.status === "confirmed") return false
-        record.task = taskAfterChapterChange(record.task, record.chapters, now())
-        await storage.saveTask(record.task)
-        if (!isProjectContextCurrent(context)) return false
-        upsertRecord(record, context)
-        return true
-      } finally {
-        endChapterAction(taskId, chapterId, token)
-      }
-    }
-
-    async function cancelChapter(taskId: string, chapterId: string): Promise<boolean> {
-      const token = beginChapterAction(taskId, chapterId)
-      if (!token) return false
-      try {
-        const record = findRecord(taskId)
-        if (!record) return false
-        const context = captureProjectContext(record.task.projectPath)
-        if (!context) return false
-        const current = record.chapters.find((item) => item.id === chapterId)
-        if (!current || current.status === "confirmed" || current.status === "cancelled") return false
-        const expectedGeneration = current.generation
-        await getEngine(record, context).cancelChapter(taskId, chapterId)
-        if (!isProjectContextCurrent(context)) return false
-        const afterEngine = record.chapters.find((item) => item.id === chapterId)
-        if (!afterEngine || afterEngine.status === "confirmed" || afterEngine.generation !== expectedGeneration) return false
-        if (afterEngine.status !== "cancelled") {
-          const cancelled: DeAiBatchChapter = { ...afterEngine, status: "cancelled", runId: null, error: null, updatedAt: now() }
-          record.chapters[record.chapters.findIndex((item) => item.id === chapterId)] = cancelled
-          await storage.saveChapter(cancelled, record.task.projectPath)
-          if (!isProjectContextCurrent(context)) return false
-        }
-        record.task = taskAfterChapterChange(record.task, record.chapters, now())
-        await storage.saveTask(record.task)
-        if (!isProjectContextCurrent(context)) return false
-        upsertRecord(record, context)
-        return true
-      } finally {
-        endChapterAction(taskId, chapterId, token)
-      }
-    }
-
-    async function confirmChapter(
-      taskId: string,
-      chapterId: string,
-      candidateContent?: string,
-    ): Promise<boolean> {
-      const token = beginChapterAction(taskId, chapterId)
-      if (!token) return false
-      try {
-        const record = findRecord(taskId)
-        if (!record) return false
-        const context = captureProjectContext(record.task.projectPath)
-        if (!context) return false
-        const index = record.chapters.findIndex((item) => item.id === chapterId)
-        let chapter = record.chapters[index]
-        if (!chapter || chapter.status !== "ready") return false
-        const expectedCandidate = candidateContent ?? chapter.candidateContent ?? ""
-        if (!expectedCandidate.trim()) return false
-        const expectedGeneration = chapter.generation
-        const safeSourcePath = assertChapterSourcePath(record.task.projectPath, chapter.sourcePath)
-        if (candidateContent !== undefined && candidateContent !== chapter.candidateContent) {
-          chapter = { ...chapter, candidateContent, updatedAt: now() }
-          record.chapters[index] = chapter
-          await storage.saveChapter(chapter, record.task.projectPath)
-          if (!isProjectContextCurrent(context)) return false
-        }
-        await applyChapter(safeSourcePath, expectedCandidate)
-        if (!isProjectContextCurrent(context)) return false
-        const latest = record.chapters.find((item) => item.id === chapterId)
-        if (!latest || latest.status !== "ready" || latest.generation !== expectedGeneration || latest.candidateContent !== expectedCandidate) return false
-        const confirmed: DeAiBatchChapter = { ...latest, status: "confirmed", error: null, updatedAt: now() }
-        record.chapters[index] = confirmed
-        record.task = taskAfterChapterChange(record.task, record.chapters, now())
-        await storage.saveChapter(confirmed, record.task.projectPath)
-        if (!isProjectContextCurrent(context)) return false
-        await storage.saveTask(record.task)
-        if (!isProjectContextCurrent(context)) return false
-        upsertRecord(record, context)
-        return true
-      } finally {
-        endChapterAction(taskId, chapterId, token)
-      }
-    }
-
-    async function cancelTask(taskId: string): Promise<boolean> {
-      const token = beginTaskAction(taskId)
-      if (!token) return false
-      try {
-        const record = findRecord(taskId)
-        if (!record || record.task.status === "completed") return false
-        const context = captureProjectContext(record.task.projectPath)
-        if (!context) return false
-        scheduler?.cancel(taskId)
-        for (const chapter of record.chapters) {
-          if (chapter.status === "confirmed" || chapter.status === "cancelled") continue
-          const index = record.chapters.findIndex((item) => item.id === chapter.id)
-          const cancelled: DeAiBatchChapter = { ...record.chapters[index], status: "cancelled", runId: null, error: null, updatedAt: now() }
-          record.chapters[index] = cancelled
-          await storage.saveChapter(cancelled, record.task.projectPath)
-          if (!isProjectContextCurrent(context)) return false
-        }
-        record.task = { ...record.task, status: "cancelled", error: null, completedAt: now(), updatedAt: now() }
-        await storage.saveTask(record.task)
-        if (!isProjectContextCurrent(context)) return false
-        upsertRecord(record, context)
-        return true
-      } finally {
-        endTaskAction(taskId, token)
-      }
-    }
-
-    return {
-      projectPath: null,
-      records: [],
-      concurrency: options.concurrency ?? 3,
-      scheduler: { ...EMPTY_SCHEDULER, concurrency: options.concurrency ?? 3 },
-      panelCollapsed: false,
-      reviewOpen: false,
-      reviewTaskId: null,
-      reviewChapterId: null,
-      initializeProject,
-      createBatch,
-      continueTask,
-      regenerateChapter,
-      cancelChapter,
-      confirmChapter,
-      cancelTask,
-      openReview: (taskId, chapterId) => {
-        if (taskActions.has(taskId)) return
-        const record = findRecord(taskId)
-        if (!record) return
-        const targetChapterId = chapterId ?? record.chapters[0]?.id ?? null
-        set({ reviewOpen: true, reviewTaskId: taskId, reviewChapterId: targetChapterId })
-      },
-      closeReview: () => set({ reviewOpen: false }),
-      setReviewChapter: (reviewChapterId) => set({ reviewChapterId }),
-      setPanelCollapsed: (panelCollapsed) => set({ panelCollapsed }),
-      setConcurrency: (value) => {
-        scheduler?.setConcurrency(value)
-        const concurrency = scheduler?.getSnapshot().concurrency ?? Math.min(5, Math.max(1, Math.floor(value)))
-        set({ concurrency, scheduler: scheduler?.getSnapshot() ?? { ...get().scheduler, concurrency } })
-      },
-      dispose: () => {
-        generation += 1
-        chapterActions.clear()
-        taskActions.clear()
-        scheduler?.dispose()
-        scheduler = null
-        for (const engine of engines.values()) engine.dispose()
-        engines.clear()
-      },
-    }
-  })
-}
-
-export const useDeAiBatchStore = createDeAiBatchStore({
-  runner: createDefaultDeAiBatchLlmRunner(),
-})

+ 0 - 4
src/stores/wiki-store.ts

@@ -564,7 +564,6 @@ interface WikiState {
   writingSkillLibraryDraftDirty: boolean
   selectedReviewDimension: string | null
   selectedReviewFilePath: string
-  selectedDismantlingProjectId: string | null
   graphMode: string
   graphDisplayMode: string
   graphColorMode: string
@@ -642,7 +641,6 @@ interface WikiState {
   setWritingSkillLibraryDraftDirty: (dirty: boolean) => void
   setSelectedReviewDimension: (dimension: string | null) => void
   setSelectedReviewFilePath: (path: string) => void
-  setSelectedDismantlingProjectId: (id: string | null) => void
   setGraphMode: (mode: string) => void
   setGraphDisplayMode: (mode: string) => void
   setGraphColorMode: (mode: string) => void
@@ -718,7 +716,6 @@ export const useWikiStore = create<WikiState>((set) => ({
   writingSkillLibraryDraftDirty: false,
   selectedReviewDimension: null,
   selectedReviewFilePath: "",
-  selectedDismantlingProjectId: null,
   graphMode: "overview",
   graphDisplayMode: "graph",
   graphColorMode: "type",
@@ -820,7 +817,6 @@ export const useWikiStore = create<WikiState>((set) => ({
   setWritingSkillLibraryDraftDirty: (writingSkillLibraryDraftDirty) => set({ writingSkillLibraryDraftDirty }),
   setSelectedReviewDimension: (selectedReviewDimension) => set({ selectedReviewDimension }),
   setSelectedReviewFilePath: (selectedReviewFilePath) => set({ selectedReviewFilePath }),
-  setSelectedDismantlingProjectId: (selectedDismantlingProjectId) => set({ selectedDismantlingProjectId }),
   setGraphMode: (graphMode) => set({ graphMode }),
   setGraphDisplayMode: (graphDisplayMode) => set({ graphDisplayMode }),
   setGraphColorMode: (graphColorMode) => set({ graphColorMode }),

+ 0 - 45
xiangliangzaoyinzhili-分支说明.md

@@ -1,45 +0,0 @@
-# 向量噪音治理分支说明
-
-## 分支用途
-
-- 分支名:`xiangliangzaoyinzhili`
-- 基线版本:`91bcaeb`
-- 目标:降低小说章节生成时的向量检索噪音,保留结构化记忆、TOKEN 检索、图谱和正史链路。
-
-## 使用要求
-
-1. 只处理小说写作上下文中的向量召回质量,不改变资料库搜索页的现有行为。
-2. 不删除已有函数,不删除已有向量数据。
-3. 不改变结构化记忆的生成、保存和读取协议。
-4. 先写失败测试,再写最小实现。
-5. 完成后运行源码、旧功能测试、相关测试、typecheck、build 和打包。
-
-## 预计改动
-
-- 新增小说向量相关性门控辅助模块。
-- 小说向量召回使用真正命中的 chunk,而不是默认页面开头。
-- 移除小说混合检索内部重复的向量请求。
-- 按路径合并跨来源结果。
-- 社区摘要只注入达到相关性门槛的命中。
-
-## 更新记录
-
-### 20260718-124614
-
-- 创建独立分支和隔离工作树。
-- 基线 `npm run test:mocks` 通过:345 个测试文件,2496 个用例通过,6 个既有 todo。
-- 已完成设计确认,尚未开始代码实现。
-- Git 状态:未提交。
-
-### 20260718-131731
-
-- 新增小说向量相关性门控,最低原始命中 chunk 分数为 `0.45`。
-- 小说向量结果使用真正命中片段,低分结果不进入写作上下文。
-- TOKEN 与向量分支已分离,统一上下文移除重复向量调用并按路径去重。
-- 社区摘要已接入相关性门控。
-- TDD 聚焦测试:5 个文件、21 项通过。
-- 完整离线回归:349 个文件、2506 项通过、6 项既有 todo。
-- `typecheck`、`build`、源码 HTTP 健康检查和 `build:portable` 通过。
-- 便携产物:`release-portable/QMaiWrite.exe`,版本 `2.2.37`,149139456 字节。
-- 已更新本地 `GenxinLOG/更新日志.md`。
-- Git 状态:已经用户明确要求提交并合并至 `main`;未推送。