Przeglądaj źródła

feat: 完善拆书角色 Skill 选择流程

Mochocyang 1 miesiąc temu
rodzic
commit
cf17e3922a

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

@@ -0,0 +1,40 @@
+# 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。

+ 2 - 1
src/components/novel/book-analysis-character-panel.spec.tsx

@@ -64,13 +64,14 @@ function renderPanel(
         addingToSoul={false}
         onSelectCharacter={vi.fn()}
         onAddSelectedSkillsToSoul={vi.fn()}
+        onOpenSkillSelection={vi.fn()}
         {...props}
       />,
     )
   })
   return {
     container,
-    addButton: container.querySelector("button") as HTMLButtonElement,
+    addButton: Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("自定义灵魂")) as HTMLButtonElement,
     cleanup: () => {
       act(() => root.unmount())
       document.body.removeChild(container)

+ 16 - 11
src/components/novel/book-analysis-character-panel.tsx

@@ -6,21 +6,23 @@ interface BookAnalysisCharacterPanelProps {
   book: BookAnalysisLibraryBook
   selectedCharacterId: string | null
   addingToSoul: boolean
+  onOpenSkillSelection: () => void
   onSelectCharacter: (characterId: string) => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
 }
 
 const categoryLabels: Record<string, string> = {
   protagonist: "主角",
-  antagonist: "反派",
+  antagonist: "配角",
   supporting: "配角",
-  minor: "次要",
+  minor: "次要配角",
 }
 
 export function BookAnalysisCharacterPanel({
   book,
   selectedCharacterId,
   addingToSoul,
+  onOpenSkillSelection,
   onSelectCharacter,
   onAddSelectedSkillsToSoul,
 }: BookAnalysisCharacterPanelProps) {
@@ -44,15 +46,18 @@ export function BookAnalysisCharacterPanel({
           <h3 className="text-sm font-semibold">角色 Skill</h3>
           <p className="mt-1 text-xs text-muted-foreground">选择角色 Skill 加入自定义灵魂库。</p>
         </div>
-        <Button
-          size="sm"
-          variant="outline"
-          onClick={() => selectedSkill && !selectedAuraAdded && onAddSelectedSkillsToSoul(selectedSkill.id)}
-          disabled={addingToSoul || !selectedSkill || selectedAuraAdded}
-        >
-          <Plus className="mr-2 h-4 w-4" />
-          {addButtonLabel}
-        </Button>
+        <div className="flex items-center gap-2">
+          <Button size="sm" variant="outline" onClick={onOpenSkillSelection}>选择角色生成 Skill</Button>
+          <Button
+            size="sm"
+            variant="outline"
+            onClick={() => selectedSkill && !selectedAuraAdded && onAddSelectedSkillsToSoul(selectedSkill.id)}
+            disabled={addingToSoul || !selectedSkill || selectedAuraAdded}
+          >
+            <Plus className="mr-2 h-4 w-4" />
+            {addButtonLabel}
+          </Button>
+        </div>
       </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">

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

@@ -0,0 +1,58 @@
+import { useEffect, useMemo, useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
+import { selectCharacterCandidates } from "@/lib/novel/book-analysis/character-candidate-selection"
+
+interface BookAnalysisCharacterSkillDialogProps {
+  book: BookAnalysisLibraryBook
+  open: boolean
+  generating?: boolean
+  onOpenChange: (open: boolean) => void
+  onSubmit: (characterIds: string[]) => Promise<void> | void
+}
+
+const CATEGORY_LABELS = { protagonist: "主角", supporting: "配角", minor: "次要配角" } as const
+
+export function BookAnalysisCharacterSkillDialog({ book, open, generating = false, onOpenChange, onSubmit }: BookAnalysisCharacterSkillDialogProps) {
+  const candidates = useMemo(() => selectCharacterCandidates(book.characters), [book.characters])
+  const [selectedIds, setSelectedIds] = useState<string[]>([])
+
+  useEffect(() => {
+    if (!open) return
+    setSelectedIds(candidates.filter((item) => !book.skills.some((skill) => skill.characterId === item.id)).map((item) => item.id))
+  }, [book.skills, candidates, open])
+
+  const toggle = (id: string) => setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => !generating && onOpenChange(next)}>
+      <DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[720px]">
+        <DialogHeader>
+          <DialogTitle>选择角色生成 Skill</DialogTitle>
+        </DialogHeader>
+        <div className="min-h-0 flex-1 space-y-2 overflow-y-auto py-3">
+          {candidates.length === 0 ? <p className="text-sm text-muted-foreground">暂无可提取角色信息。</p> : candidates.map((character) => {
+            const hasSkill = book.skills.some((skill) => skill.characterId === character.id || skill.characterName === character.name)
+            return (
+              <label key={character.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-muted/50">
+                <input className="mt-1" type="checkbox" checked={selectedIds.includes(character.id)} onChange={() => toggle(character.id)} disabled={generating} />
+                <span className="min-w-0 flex-1">
+                  <span className="flex items-center justify-between gap-2">
+                    <span className="font-medium">{character.name}</span>
+                    <span className="text-xs text-muted-foreground">{CATEGORY_LABELS[character.candidateCategory]} · 出场 {character.appearanceCount} 次</span>
+                  </span>
+                  <span className="mt-1 block text-xs text-muted-foreground">{hasSkill ? "已生成 Skill,可重新生成" : character.description || "暂无角色简介"}</span>
+                </span>
+              </label>
+            )
+          })}
+        </div>
+        <DialogFooter className="shrink-0">
+          <Button variant="outline" disabled={generating} onClick={() => onOpenChange(false)}>取消</Button>
+          <Button disabled={generating || selectedIds.length === 0} onClick={() => void onSubmit(selectedIds)}>{generating ? "正在生成..." : `生成选中 Skill(${selectedIds.length})`}</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

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

@@ -26,6 +26,7 @@ interface BookAnalysisLibraryLayoutProps {
   onCreateOutlineFromFramework?: (frameworkId: string) => void
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
+  onOpenSkillSelection?: () => void
   onReextractCharacters: () => void
   onReextractSkill?: (skill: AnalysisSkill) => void
   onConfigureAnalysisTask?: () => void
@@ -49,6 +50,7 @@ export function BookAnalysisLibraryLayout({
   onCreateOutlineFromFramework,
   onToggleStyle,
   onAddSelectedSkillsToSoul,
+  onOpenSkillSelection,
   onReextractCharacters,
   onExtractStoryFramework,
   onExtractStyle,
@@ -112,6 +114,7 @@ export function BookAnalysisLibraryLayout({
         onSelectCharacter={onSelectCharacter}
         onToggleStyle={onToggleStyle}
         onAddSelectedSkillsToSoul={onAddSelectedSkillsToSoul}
+        onOpenSkillSelection={onOpenSkillSelection}
         onReextract={(skill) => {
           if (onReextractSkill) onReextractSkill(skill)
           else if (skill === "characters") onReextractCharacters()

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

@@ -18,6 +18,7 @@ interface BookAnalysisModuleViewProps {
   onSelectCharacter: (id: string) => void
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
+  onOpenSkillSelection?: () => void
   onReextract: (skill: AnalysisSkill) => void
   onConfigureTask?: () => void
   onPauseTask?: () => void
@@ -131,7 +132,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
           </div>
         )}
         {active === "characters" && (
-          <BookAnalysisCharacterPanel book={props.book} selectedCharacterId={props.selectedCharacterId} onSelectCharacter={props.onSelectCharacter} addingToSoul={props.addingToSoul} onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul} />
+          <BookAnalysisCharacterPanel book={props.book} selectedCharacterId={props.selectedCharacterId} addingToSoul={props.addingToSoul} onSelectCharacter={props.onSelectCharacter} onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul} onOpenSkillSelection={props.onOpenSkillSelection ?? (() => undefined)} />
         )}
         {active === "story" && props.storyContent}
         {active === "style" && (

+ 5 - 3
src/components/novel/book-analysis-result-viewer.spec.tsx

@@ -70,8 +70,8 @@ describe("BookAnalysisResultViewer", () => {
     expect(html).toContain("添加所选角色到自定义灵魂")
     expect(html).toContain("绑定小说人物")
     expect(html).toContain("林烬")
-    // feature/book-analysis-reuse:顶栏重新提取按钮
-    expect(html).toContain("重新提取角色")
+    // 旧的简单/六维重新提取入口已移除
+    expect(html).not.toContain("重新提取角色")
     // 详情卡两个单角色按钮(detail 部分依赖选中角色,断言存在性)
     // 由于该测试 mock 出 1 个角色且未点击选中,按钮区在没选中时不渲染;
     // 单独 it 验证 selectedCharacter 时显示:
@@ -93,7 +93,9 @@ describe("BookAnalysisResultViewer", () => {
         }}
       />,
     )
-    // 默认未选中时不渲染单角色按钮(详情卡才有),所以这一条只验证空状态
+    // 默认未选中时不渲染角色详情操作,所以这一条只验证空状态
     expect(html).toContain("请从左侧选择角色查看详情")
+    expect(html).not.toContain("再次提取(简单)")
+    expect(html).not.toContain("深度提取(6 维)")
   })
 })

+ 0 - 269
src/components/novel/book-analysis-result-viewer.tsx

@@ -10,7 +10,6 @@ import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { bindCharacterAura, listBindableNovelCharacters } from "@/lib/novel/character-aura"
 import { importBookAnalysisSkillsAsAuras, type ImportedBookAnalysisAura } from "@/lib/novel/book-analysis/aura-adapter"
-import { extractSingleCharacter } from "@/lib/novel/book-analysis/character-extraction-engine"
 import { analyzeWritingStyle } from "@/lib/novel/book-analysis/style-extraction-engine"
 import { STYLE_DIMENSIONS } from "@/lib/novel/book-analysis/style-prompts"
 import { upsertWritingStylePreset, setEnabledWritingStyle, getEnabledWritingStyle } from "@/lib/novel/writing-style-store"
@@ -39,10 +38,6 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
   const [selectedAuraId, setSelectedAuraId] = useState("")
   // feature/fix-viewer-ui:多选小说人物
   const [selectedNovelCharacterIds, setSelectedNovelCharacterIds] = useState<Set<string>>(new Set())
-  // feature/book-analysis-reuse:顶栏「重新提取角色」按钮
-  const [reextractOpen, setReextractOpen] = useState(false)
-  const [reextractDepth, setReextractDepth] = useState<"simple" | "six-dimension">("simple")
-  const [reextractRunning, setReextractRunning] = useState(false)
   // feature/book-style-extraction:作品文风提取 / 启用
   const [styleExtracting, setStyleExtracting] = useState(false)
   const [styleEnabledSourceBook, setStyleEnabledSourceBook] = useState<string | null>(null)
@@ -120,59 +115,6 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     ? [...characters].sort((a, b) => b.importance - a.importance || a.name.localeCompare(b.name, "zh-CN"))
     : characters
 
-  const handleReextractAll = async () => {
-    if (!currentProject?.path || !effectiveResult || reextractRunning) return
-    setReextractRunning(true)
-    try {
-      const reextractStoreState = useWikiStore.getState()
-      const reextractLlmConfig = resolveDefaultModel(reextractStoreState.llmConfig)
-      if (!hasUsableLlm(reextractLlmConfig, reextractStoreState.providerConfigs)) {
-        toast.error("未配置 LLM,请先在设置中配置")
-        return
-      }
-      // 修复:bookPath 实际写入路径是 book-analysis/{bookId},不是 book-analysis/{title}(feature/book-analysis-reuse)
-      const bookId = task?.bookId
-      const bookPath = joinPath(currentProject.path, "book-analysis", bookId ?? "unknown")
-      let updated: ExtractedCharacter[] = []
-      for (const c of characters) {
-        const { character: fresh } = await extractSingleCharacter({
-          bookPath,
-          bookId: bookId ?? "",
-          character: c,
-          mode: reextractDepth === "simple" ? "simple" : "six-dimension",
-          depth: "standard",
-          llmConfig: reextractLlmConfig,
-          signal: undefined,
-        })
-        updated.push(fresh)
-      }
-      // 写回 result metadata 触发展示刷新
-      // 关键修复(fix/character-reextract-and-loading-state v2):
-      //   viewer 的 effectiveResult 已改为优先从 task 派生,所以更新 tasks 即可;
-      //   这里再同步 currentResult,保持数据一致性。
-      const storeState = useBookAnalysisStore.getState()
-      if (storeState.currentResult) {
-        storeState.setCurrentResult({
-          ...storeState.currentResult,
-          characters: updated,
-        })
-      }
-      useBookAnalysisStore.setState((s) => ({
-        tasks: s.tasks.map((t) =>
-          t.projectPath === normalizedProjectPath && t.status === "completed"
-            ? { ...t, characters: updated, updatedAt: Date.now() }
-            : t,
-        ),
-      }))
-      toast.success(`已重新提取 ${updated.length} 个角色`)
-      setReextractOpen(false)
-    } catch (err) {
-      toast.error(`重新提取失败:${err instanceof Error ? err.message : String(err)}`)
-    } finally {
-      setReextractRunning(false)
-    }
-  }
-
   // feature/book-style-extraction:提取作品级写作文风
   const handleExtractStyle = async () => {
     if (!currentProject?.path || styleExtracting) return
@@ -225,123 +167,6 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     }
   }
 
-  // feature/book-analysis-reuse:详情卡「单角色再次提取 / 深度提取」- 改为后台任务
-  // 修复(fix/character-reextract-and-loading-state):
-  //   - 改为按角色 id 跟踪后台提取状态,支持同时跑多个单角色提取
-  //   - 详情卡按钮根据当前 selectedCharacter 自己的 reextracting 状态显示「提取中...」
-  //   - 传入 `bookTitle` 让 six-dimension 模式的 prompt 正确
-  //   - 后台跑时 toast 显式提示「可在查看其他角色或关闭此页面」
-  const [singleReextractingIds, setSingleReextractingIds] = useState<Set<string>>(new Set())
-  const handleSingleReextract = async (
-    character: ExtractedCharacter,
-    mode: "simple" | "six-dimension",
-  ) => {
-    console.log('[单角色提取] 开始:', character.name, mode)
-
-    if (!currentProject?.path || !effectiveResult) {
-      console.log('[单角色提取] 缺少项目路径或结果')
-      toast.error("缺少项目信息")
-      return
-    }
-
-    const storeState = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(storeState.llmConfig)
-    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
-      console.log('[单角色提取] 未配置LLM')
-      toast.error("未配置 LLM,请先在设置中配置")
-      return
-    }
-
-    const bookId = task?.bookId
-    if (!bookId) {
-      console.log('[单角色提取] 未找到bookId')
-      toast.error("未找到作品标识")
-      return
-    }
-
-    const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
-    const bookTitle = effectiveResult?.metadata?.title
-    const bookAuthor = (effectiveResult?.metadata as any)?.author
-    console.log('[单角色提取] bookPath:', bookPath, 'bookTitle:', bookTitle)
-
-    // 标记此角色正在后台提取(fix/character-reextract-and-loading-state)
-    setSingleReextractingIds((prev) => {
-      const next = new Set(prev)
-      next.add(character.id)
-      return next
-    })
-
-    // 启动后台任务
-    toast.success(
-      `已开始后台${mode === "simple" ? "简单" : "深度"}提取「${character.name}」,可切换到其他角色或关闭此页面`,
-    )
-
-    // 异步执行,不阻塞UI
-    ;(async () => {
-      try {
-        console.log('[单角色提取] 调用extractSingleCharacter')
-        const { character: fresh } = await extractSingleCharacter({
-          bookPath,
-          bookId,
-          character,
-          mode,
-          depth: mode === "simple" ? "fast" : "deep",
-          llmConfig,
-          bookTitle,
-          bookAuthor,
-          signal: undefined,
-        })
-
-        console.log('[单角色提取] 提取完成:', fresh.name)
-
-        // 关键修复(fix/character-reextract-and-loading-state v2):
-        //   - viewer 的 effectiveResult 已改为优先从 task 派生(task 是 source of truth),
-        //     所以更新 tasks 数组即可让 UI 刷新。
-        //   - 顺带同步 currentResult 快照,避免其他依赖 currentResult 的逻辑出现数据不一致。
-        const storeState = useBookAnalysisStore.getState()
-        const currentResult = storeState.currentResult
-        if (currentResult) {
-          storeState.setCurrentResult({
-            ...currentResult,
-            characters: (currentResult.characters ?? []).map((c) =>
-              c.id === fresh.id ? fresh : c,
-            ),
-          })
-        }
-        useBookAnalysisStore.setState((s) => ({
-          tasks: s.tasks.map((t) =>
-            t.projectPath === normalizedProjectPath && t.status === "completed"
-              ? {
-                  ...t,
-                  characters: (t.characters ?? []).map((c) => (c.id === fresh.id ? fresh : c)),
-                  updatedAt: Date.now(),
-                }
-              : t,
-          ),
-        }))
-
-        // 如果用户还在查看这个角色,更新显示
-        setSelectedCharacter((prev) => (prev?.id === fresh.id ? fresh : prev))
-
-        toast.success(`「${character.name}」${mode === "simple" ? "简单" : "深度"}提取完成`)
-      } catch (err) {
-        console.error('[单角色提取] 错误:', err)
-        toast.error(
-          `「${character.name}」${mode === "simple" ? "简单" : "深度"}提取失败:${
-            err instanceof Error ? err.message : String(err)
-          }`,
-        )
-      } finally {
-        // 解除后台提取标记(fix/character-reextract-and-loading-state)
-        setSingleReextractingIds((prev) => {
-          const next = new Set(prev)
-          next.delete(character.id)
-          return next
-        })
-      }
-    })()
-  }
-
   const handleAddSkillsToSoul = async () => {
     if (!currentProject?.path || !effectiveResult) {
       toast.error("未找到当前项目或分析结果")
@@ -464,42 +289,6 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
             </p>
           </div>
           <div className="flex items-center gap-2">
-            <div className="relative">
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={() => setReextractOpen((v) => !v)}
-                disabled={reextractRunning || characters.length === 0}
-              >
-                {reextractRunning ? "提取中..." : "重新提取角色"}
-              </Button>
-              {reextractOpen && (
-                <div className="absolute right-0 top-full mt-1 z-10 w-56 rounded-md border bg-background p-2 shadow-md space-y-2">
-                  <div className="text-xs text-muted-foreground px-1">选择提取方式</div>
-                  <label className="flex items-center gap-2 px-1 text-sm">
-                    <input
-                      type="radio"
-                      name="reextract"
-                      checked={reextractDepth === "simple"}
-                      onChange={() => setReextractDepth("simple")}
-                    />
-                    简单提取(快速)
-                  </label>
-                  <label className="flex items-center gap-2 px-1 text-sm">
-                    <input
-                      type="radio"
-                      name="reextract"
-                      checked={reextractDepth === "six-dimension"}
-                      onChange={() => setReextractDepth("six-dimension")}
-                    />
-                    深度提取(6 维)
-                  </label>
-                  <Button size="sm" className="w-full" onClick={handleReextractAll} disabled={reextractRunning}>
-                    开始
-                  </Button>
-                </div>
-              )}
-            </div>
             <Button variant="ghost" size="icon" onClick={onClose}>
               <X className="h-5 w-5" />
             </Button>
@@ -819,64 +608,6 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
                       </div>
                     </div>
 
-                    {/* feature/book-analysis-reuse:详情卡底部「单角色重提」按钮 */}
-                    {/* 修复(fix/character-reextract-and-loading-state):按钮根据当前角色是否在后台提取显示不同文案 */}
-                    <div className="pt-4 border-t" onClick={(e) => e.stopPropagation()}>
-                      <div className="text-xs text-muted-foreground mb-2">单角色重提</div>
-                      {selectedCharacter && singleReextractingIds.has(selectedCharacter.id) && (
-                        <div className="text-xs text-primary mb-2">
-                          当前角色「{selectedCharacter.name}」正在后台提取,可切换到其他角色或关闭此页面
-                        </div>
-                      )}
-                      <div className="flex gap-2">
-                        <Button
-                          size="sm"
-                          variant="outline"
-                          disabled={
-                            reextractRunning ||
-                            (!!selectedCharacter && singleReextractingIds.has(selectedCharacter.id))
-                          }
-                          onClick={(e) => {
-                            e.stopPropagation()
-                            console.log('[按钮点击] 再次提取(简单) - 事件触发', e)
-                            console.log('[按钮点击] selectedCharacter:', selectedCharacter)
-                            if (!selectedCharacter) {
-                              console.error('[按钮点击] selectedCharacter 为空!')
-                              toast.error("未选择角色")
-                              return
-                            }
-                            handleSingleReextract(selectedCharacter, "simple")
-                          }}
-                        >
-                          {selectedCharacter && singleReextractingIds.has(selectedCharacter.id)
-                            ? "提取中..."
-                            : "再次提取(简单)"}
-                        </Button>
-                        <Button
-                          size="sm"
-                          variant="outline"
-                          disabled={
-                            reextractRunning ||
-                            (!!selectedCharacter && singleReextractingIds.has(selectedCharacter.id))
-                          }
-                          onClick={(e) => {
-                            e.stopPropagation()
-                            console.log('[按钮点击] 深度提取(6维) - 事件触发', e)
-                            console.log('[按钮点击] selectedCharacter:', selectedCharacter)
-                            if (!selectedCharacter) {
-                              console.error('[按钮点击] selectedCharacter 为空!')
-                              toast.error("未选择角色")
-                              return
-                            }
-                            handleSingleReextract(selectedCharacter, "six-dimension")
-                          }}
-                        >
-                          {selectedCharacter && singleReextractingIds.has(selectedCharacter.id)
-                            ? "提取中..."
-                            : "深度提取(6 维)"}
-                        </Button>
-                      </div>
-                    </div>
                   </div>
                 ) : (
                   <div className="flex items-center justify-center h-full text-muted-foreground">

+ 7 - 4
src/components/novel/book-analysis-run-dialog.tsx

@@ -54,6 +54,7 @@ export function BookAnalysisRunDialog({
   }, [end, start])
   const count = range ? range.endOrder - range.startOrder + 1 : 0
   const available = new Set(chapters.map((chapter) => chapter.order))
+  const totalChapters = chapters.length
   const missingChapter = range && count > 0
     ? Array.from({ length: count }, (_, index) => range.startOrder + index).find((order) => !available.has(order))
     : undefined
@@ -65,8 +66,10 @@ export function BookAnalysisRunDialog({
         ? "结束章节不能小于起始章节"
         : count > MAX_ANALYSIS_CHAPTERS
           ? "单次最多分析 100 章,请分批处理"
+          : range.endOrder > totalChapters
+            ? `本作品共 ${totalChapters} 章,最多只能选择第 ${totalChapters} 章`
           : missingChapter !== undefined
-            ? `未找到第 ${missingChapter} 章`
+            ? `第 ${missingChapter} 章不存在,请根据作品实际章节范围选择`
             : ""
   const canSubmit = Boolean(range && !error && skills.length > 0 && !submitting)
 
@@ -91,15 +94,15 @@ export function BookAnalysisRunDialog({
           <div className="grid grid-cols-2 gap-3">
             <label className="space-y-1 text-sm">
               <span>起始章节</span>
-              <input aria-label="起始章节" type="number" min={1} value={start} onChange={(event) => setStart(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
+              <input aria-label="起始章节" type="number" min={1} max={Math.min(totalChapters, MAX_ANALYSIS_CHAPTERS)} value={start} onChange={(event) => setStart(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
             </label>
             <label className="space-y-1 text-sm">
               <span>结束章节</span>
-              <input aria-label="结束章节" type="number" min={1} value={end} onChange={(event) => setEnd(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
+              <input aria-label="结束章节" type="number" min={1} max={Math.min(totalChapters, MAX_ANALYSIS_CHAPTERS)} value={end} onChange={(event) => setEnd(event.target.value)} className="h-9 w-full rounded-md border bg-background px-3" />
             </label>
           </div>
           <p className="text-xs text-muted-foreground">
-            单次最多分析 100 章。当前作品共 {chapters.length} 章,请分批选择章节范围
+            当前作品共 {totalChapters} 章;单次最多分析 {MAX_ANALYSIS_CHAPTERS} 章,请选择第 1~{Math.min(totalChapters, MAX_ANALYSIS_CHAPTERS)} 章
           </p>
           {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
           <fieldset>

+ 45 - 6
src/components/novel/book-analysis-view.tsx

@@ -40,7 +40,10 @@ import type { AnalysisSkill } from "@/lib/novel/book-analysis/analysis-pipeline-
 import type { AnalysisChapterRange } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { loadChapterList } from "@/lib/novel/book-analysis/analysis-engine"
 import { BookAnalysisRunDialog } from "./book-analysis-run-dialog"
+import { BookAnalysisCharacterSkillDialog } from "./book-analysis-character-skill-dialog"
 import { OutlineCreatorDialog } from "./outline-editor"
+import { generateSkillsForCharacters } from "@/lib/novel/book-analysis/skill-generator"
+import { selectCharacterCandidates } from "@/lib/novel/book-analysis/character-candidate-selection"
 
 interface StoryFrameworkSelectionData {
   book: BookAnalysisLibraryBook
@@ -95,6 +98,8 @@ export function BookAnalysisView() {
     lockedSkills?: AnalysisSkill[]
     initialRange?: AnalysisChapterRange | null
   } | null>(null)
+  const [characterSkillDialogOpen, setCharacterSkillDialogOpen] = useState(false)
+  const [characterSkillGenerating, setCharacterSkillGenerating] = useState(false)
 
   const currentProject = useWikiStore((s) => s.project)
   const storeSelectedBookId = useBookAnalysisStore((s) => s.selectedLibraryBookId)
@@ -196,6 +201,27 @@ export function BookAnalysisView() {
     startTask,
   })
 
+  const handleOpenCharacterSkillSelection = useCallback(() => {
+    if (selectedLibraryBook) setCharacterSkillDialogOpen(true)
+  }, [selectedLibraryBook])
+
+  const handleGenerateSelectedCharacterSkills = useCallback(async (characterIds: string[]) => {
+    if (!selectedLibraryBook || characterIds.length === 0) return
+    const selected = selectCharacterCandidates(selectedLibraryBook.characters).filter((character) => characterIds.includes(character.id))
+    if (selected.length === 0) return
+    setCharacterSkillGenerating(true)
+    try {
+      await generateSkillsForCharacters(selected, selectedLibraryBook.metadata, selectedLibraryBook.path, llmConfig)
+      await reloadLibraryState()
+      setCharacterSkillDialogOpen(false)
+      toast.success(`已生成 ${selected.length} 个角色 Skill,可在角色面板中查看并加入自定义灵魂库。`)
+    } catch (error) {
+      toast.error(`角色 Skill 生成失败:${error instanceof Error ? error.message : String(error)}`)
+    } finally {
+      setCharacterSkillGenerating(false)
+    }
+  }, [llmConfig, reloadLibraryState, selectedLibraryBook])
+
   // 角色识别钩子
   const {
     handleChapterSelectionConfirm,
@@ -218,8 +244,6 @@ export function BookAnalysisView() {
 
   // 角色特征提取钩子
   const {
-    handleDeepExtract,
-    handleSimpleExtract,
     handleResumeFailedExtraction,
   } = useCharacterExtraction({
     chapterSelectionData,
@@ -399,7 +423,6 @@ export function BookAnalysisView() {
     setRecognizedCharacters(existingCharacters)
     setSelectedCharacterIds(
       existingCharacters
-        .filter((c) => c.category === "主角" || c.category === "配角")
         .map((c) => c.id)
     )
     setRecognitionStatus("done")
@@ -609,6 +632,7 @@ export function BookAnalysisView() {
       onCreateOutlineFromFramework={(frameworkId) => setOutlineCreatorFrameworkId(frameworkId)}
       onToggleStyle={handleLibraryToggleStyle}
       onAddSelectedSkillsToSoul={handleLibraryAddSkillsToSoul}
+      onOpenSkillSelection={handleOpenCharacterSkillSelection}
       onReextractCharacters={handleLibraryReextractCharacters}
       onReextractSkill={openPipelineDialog}
       onConfigureAnalysisTask={openExistingPipelineDialog}
@@ -636,12 +660,29 @@ export function BookAnalysisView() {
         onSubmit={async ({ range, selectedSkills }) => {
           await configureTaskRange(pipelineDialog.taskId, range, selectedSkills)
           setPipelineDialog(null)
-          void startPipelineTask(pipelineDialog.taskId).then(reloadLibraryState).catch((error) => {
+          void startPipelineTask(pipelineDialog.taskId).then(async () => {
+            await reloadLibraryState()
+            if (selectedSkills.includes("characters")) {
+              toast.success("角色信息已提取完成,可选择角色生成 Skill。", {
+                persistent: true,
+                action: { label: "打开角色信息", onClick: handleOpenCharacterSkillSelection },
+              })
+            }
+          }).catch((error) => {
             toast.error(`分析失败:${error instanceof Error ? error.message : String(error)}`)
           })
         }}
       />
     )}
+    {selectedLibraryBook && (
+      <BookAnalysisCharacterSkillDialog
+        book={selectedLibraryBook}
+        open={characterSkillDialogOpen}
+        generating={characterSkillGenerating}
+        onOpenChange={setCharacterSkillDialogOpen}
+        onSubmit={handleGenerateSelectedCharacterSkills}
+      />
+    )}
     </>
   )
 
@@ -939,8 +980,6 @@ export function BookAnalysisView() {
           onToggleCharacter={handleToggleCharacter}
           onSelectAllMain={handleSelectAllMain}
           onClearSelection={handleClearSelection}
-          onDeepExtract={handleDeepExtract}
-          onSimpleExtract={handleSimpleExtract}
           onCharacterPickerClose={clearRecognition}
           // 提取进度
           extractionPhase={chapterSelectionData.extractionPhase ?? null}

+ 1 - 9
src/components/novel/chapter-selection-panel.tsx

@@ -36,8 +36,6 @@ interface ChapterSelectionPanelProps {
   onToggleCharacter?: (id: string) => void
   onSelectAllMain?: () => void
   onClearSelection?: () => void
-  onDeepExtract?: () => void
-  onSimpleExtract?: () => void
   /** 关闭"角色选择"弹窗:回到章节选择页,不取消任务 */
   onCharacterPickerClose?: () => void
   /** 同步"分析中"状态给父组件 */
@@ -68,8 +66,6 @@ export function ChapterSelectionPanel({
   onToggleCharacter,
   onSelectAllMain,
   onClearSelection,
-  onDeepExtract,
-  onSimpleExtract,
   onCharacterPickerClose,
   onAnalyzingChange,
   extractionPhase,
@@ -147,9 +143,7 @@ export function ChapterSelectionPanel({
     !extractionPhase &&
     !!onToggleCharacter &&
     !!onSelectAllMain &&
-    !!onClearSelection &&
-    !!onDeepExtract &&
-    !!onSimpleExtract
+    !!onClearSelection
 
   // 是否正在提取中
   const isExtracting = !!extractionPhase && !extractionProgress?.isCompleted
@@ -449,8 +443,6 @@ export function ChapterSelectionPanel({
           onToggle={onToggleCharacter!}
           onSelectAllMain={onSelectAllMain!}
           onClear={onClearSelection!}
-          onDeepExtract={onDeepExtract!}
-          onSimpleExtract={onSimpleExtract!}
           onCancel={onCancel}
           onClose={onCharacterPickerClose}
         />

+ 5 - 19
src/components/novel/character-selection-panel.spec.tsx

@@ -51,8 +51,6 @@ describe("CharacterSelectionPanel", () => {
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
@@ -70,8 +68,6 @@ describe("CharacterSelectionPanel", () => {
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
@@ -79,15 +75,13 @@ describe("CharacterSelectionPanel", () => {
     cleanup()
   })
 
-  it("未选时两个提取按钮显示 0 个角色", async () => {
+  it("未选时仍显示当前选中数量", async () => {
     const { cleanup } = renderPanel({
       characters,
       selectedIds: [],
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
@@ -95,15 +89,13 @@ describe("CharacterSelectionPanel", () => {
     cleanup()
   })
 
-  it("已选 1 个时按钮显示 1 个角色", async () => {
+  it("已选 1 个时显示选中数量", async () => {
     const { cleanup } = renderPanel({
       characters,
       selectedIds: ["1"],
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
@@ -118,8 +110,6 @@ describe("CharacterSelectionPanel", () => {
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
@@ -127,21 +117,19 @@ describe("CharacterSelectionPanel", () => {
     cleanup()
   })
 
-  it("包含深度和简单提取两个按钮", async () => {
+  it("不再显示简单提取和六维提取入口", async () => {
     const { cleanup } = renderPanel({
       characters,
       selectedIds: ["1"],
       onToggle: vi.fn(),
       onSelectAllMain: vi.fn(),
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()
     const html = getAllBodyHtml()
-    expect(html).toContain("深度 6 维提取")
-    expect(html).toContain("简单提取")
+    expect(html).not.toContain("深度 6 维提取")
+    expect(html).not.toContain("简单提取")
     cleanup()
   })
 
@@ -153,8 +141,6 @@ describe("CharacterSelectionPanel", () => {
       onToggle: vi.fn(),
       onSelectAllMain,
       onClear: vi.fn(),
-      onDeepExtract: vi.fn(),
-      onSimpleExtract: vi.fn(),
       onCancel: vi.fn(),
     })
     await flushAsync()

+ 0 - 21
src/components/novel/character-selection-panel.tsx

@@ -19,8 +19,6 @@ export interface CharacterSelectionPanelProps {
   onToggle: (id: string) => void
   onSelectAllMain: () => void
   onClear: () => void
-  onDeepExtract: () => void
-  onSimpleExtract: () => void
   onCancel: () => void
   /** 关闭弹窗(X / 返回):应回到章节选择页,而不是取消整个任务。默认回退到 onCancel。 */
   onClose?: () => void
@@ -36,8 +34,6 @@ export function CharacterSelectionPanel(props: CharacterSelectionPanelProps) {
     onToggle,
     onSelectAllMain,
     onClear,
-    onDeepExtract,
-    onSimpleExtract,
     onCancel,
     onClose,
   } = props
@@ -57,8 +53,6 @@ export function CharacterSelectionPanel(props: CharacterSelectionPanelProps) {
   }, [characters, search, sortBy])
 
   const selectedCount = selectedIds.length
-  const canExtract = selectedCount > 0
-
   return (
     <Dialog open onOpenChange={(o) => !o && dismiss()}>
       <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
@@ -141,21 +135,6 @@ export function CharacterSelectionPanel(props: CharacterSelectionPanelProps) {
 
         <DialogFooter className="gap-2">
           <Button variant="ghost" onClick={dismiss}>返回章节</Button>
-          <Button
-            disabled={!canExtract}
-            onClick={onDeepExtract}
-            variant="default"
-          >
-            🎯 深度 6 维提取 {selectedCount} 个角色
-          </Button>
-          <Button
-            disabled={!canExtract}
-            onClick={onSimpleExtract}
-            variant="default"
-            className="bg-amber-500 hover:bg-amber-600"
-          >
-            ⚡ 简单提取 {selectedCount} 个角色
-          </Button>
         </DialogFooter>
       </DialogContent>
     </Dialog>

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

@@ -302,6 +302,10 @@ export async function loadChapterList(bookPath: string): Promise<ChapterSelectio
 
     // 按顺序排序
     chapters.sort((a, b) => a.order - b.order)
+    // 用户选择的是作品中的连续章节序号;导入旧数据可能存在 0 起始或断号,统一为 1..N。
+    chapters.forEach((chapter, index) => {
+      chapter.order = index + 1
+    })
 
     return chapters
   } catch (error) {

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

@@ -70,7 +70,7 @@ describe("character analysis adapter", () => {
     expect(merged[0].growthArc).toContain("从退让到承担")
   })
 
-  it("发布时统一写角色、生成一次 Skill 并保存角色证据", async () => {
+  it("发布时只保存角色信息,等待用户选择角色后再生成 Skill", async () => {
     const persistCharacter = vi.fn(async () => {})
     const generateSkills = vi.fn(async () => [])
     const replaceEvidence = vi.fn(async () => ({ version: 1 as const, bookId: "book-1", snippets: [], updatedAt: 1 }))
@@ -124,7 +124,7 @@ describe("character analysis adapter", () => {
     })
 
     expect(persistCharacter).toHaveBeenCalledTimes(1)
-    expect(generateSkills).toHaveBeenCalledTimes(1)
+    expect(generateSkills).not.toHaveBeenCalled()
     expect(replaceEvidence).toHaveBeenCalledWith(inputTask.bookPath, "characters", evidence)
     expect(saveManifest).toHaveBeenCalledTimes(1)
     expect(rebuildContextIndex).toHaveBeenCalledWith(inputTask.projectPath)

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

@@ -6,6 +6,7 @@ import { generateSkillsForCharacters } from "./skill-generator"
 import { replaceAutomaticEvidence } from "./analysis-evidence-store"
 import { rebuildBookAnalysisContextIndex } from "./analysis-context-index"
 import { loadAnalysisManifest, saveAnalysisManifest } from "./analysis-pipeline-storage"
+import { selectCharacterCandidates } from "./character-candidate-selection"
 import type {
   AnalysisEvidenceSnippet,
   BookAnalysisModuleManifest,
@@ -229,17 +230,18 @@ export function createCharacterAnalysisAdapter(
     },
     async aggregate({ chunks }) {
       const merged = mergeCharacterChunkResults(chunks.map((chunk) => chunk.characters))
-      if (merged.length === 0) {
+      const candidates = selectCharacterCandidates(merged)
+      if (candidates.length === 0) {
         throw new Error("所选章节未识别到可提取角色,请确认章节正文包含有姓名的重要角色")
       }
-      return merged
+      return candidates
     },
-    async publish({ task, bookPath, projectPath, llmConfig, result, evidence, signal }) {
+    async publish({ task, bookPath, projectPath, result, evidence }) {
       const metadata = await dependencies.loadMetadata(bookPath)
       if (!metadata) throw new Error("未找到作品元数据,无法发布角色分析")
       for (const character of result) await dependencies.persistCharacter(bookPath, character)
-      await dependencies.generateSkills(result, metadata, bookPath, llmConfig, undefined, signal)
-      await dependencies.replaceEvidence(bookPath, "characters", evidence)
+      const characterNames = new Set(result.map((character) => character.name))
+      await dependencies.replaceEvidence(bookPath, "characters", evidence.filter((item) => item.tags.some((tag) => characterNames.has(tag))))
 
       const resultPath = normalizePath(joinPath(bookPath, "characters"))
       const updatedAt = dependencies.now()
@@ -253,7 +255,7 @@ export function createCharacterAnalysisAdapter(
             ...task.modules.characters,
             status: "completed",
             resultPath,
-            summary: `提取 ${result.length} 个角色,覆盖第 ${task.modules.characters.range.startOrder}~${task.modules.characters.range.endOrder} 章。`,
+            summary: `识别 ${result.length} 个候选角色,覆盖第 ${task.modules.characters.range.startOrder}~${task.modules.characters.range.endOrder} 章,等待用户选择生成 Skill。`,
             updatedAt,
           },
         },

+ 33 - 0
src/lib/novel/book-analysis/character-candidate-selection.spec.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+import { selectCharacterCandidates } from "./character-candidate-selection"
+import type { ExtractedCharacter } from "./types"
+
+const character = (overrides: Partial<ExtractedCharacter>): ExtractedCharacter => ({
+  id: "character",
+  name: "角色",
+  aliases: [],
+  importance: 4,
+  category: "minor",
+  firstAppearance: 1,
+  lastAppearance: 4,
+  appearanceCount: 2,
+  description: "有明确身份",
+  personality: "谨慎",
+  speechStyle: "短句",
+  relationships: [],
+  keyEvents: [],
+  ...overrides,
+})
+
+describe("角色候选筛选", () => {
+  it("过滤一次出现且资料不足的路人角色,并给保留角色分类", () => {
+    const candidates = selectCharacterCandidates([
+      character({ id: "hero", name: "主角", importance: 9, category: "protagonist", appearanceCount: 1 }),
+      character({ id: "supporting", name: "配角", importance: 5, category: "supporting", appearanceCount: 2 }),
+      character({ id: "extra", name: "路人甲", importance: 1, appearanceCount: 1, description: "", personality: "", speechStyle: "" }),
+    ])
+
+    expect(candidates.map((item) => item.id)).toEqual(["hero", "supporting"])
+    expect(candidates.map((item) => item.candidateCategory)).toEqual(["protagonist", "supporting"])
+  })
+})

+ 42 - 0
src/lib/novel/book-analysis/character-candidate-selection.ts

@@ -0,0 +1,42 @@
+import type { ExtractedCharacter } from "./types"
+
+export type CharacterCandidateCategory = "protagonist" | "supporting" | "minor"
+
+export interface CharacterCandidate extends ExtractedCharacter {
+  candidateCategory: CharacterCandidateCategory
+  candidateScore: number
+}
+
+function detailScore(character: ExtractedCharacter): number {
+  return [
+    character.description,
+    character.personality,
+    character.motivation,
+    character.speechStyle,
+    character.growthArc,
+    character.behaviorPatterns,
+  ].filter((value) => Boolean(value?.trim())).length
+    + Math.min(2, character.keyEvents.length)
+    + Math.min(2, character.representativeQuotes?.length ?? 0)
+}
+
+export function classifyCharacterCandidate(character: ExtractedCharacter): CharacterCandidate | null {
+  const details = detailScore(character)
+  const score = character.appearanceCount * 2 + character.importance + details * 2
+  if (character.appearanceCount <= 1 && character.importance <= 2 && details <= 1) return null
+
+  const candidateCategory: CharacterCandidateCategory = character.category === "protagonist" || character.importance >= 8
+    ? "protagonist"
+    : character.category === "antagonist" || character.category === "supporting" || character.importance >= 5 || character.appearanceCount >= 3
+      ? "supporting"
+      : "minor"
+
+  return { ...character, candidateCategory, candidateScore: score }
+}
+
+export function selectCharacterCandidates(characters: ExtractedCharacter[]): CharacterCandidate[] {
+  return characters
+    .map(classifyCharacterCandidate)
+    .filter((character): character is CharacterCandidate => character !== null)
+    .sort((left, right) => right.candidateScore - left.candidateScore)
+}