소스 검색

merge: 合并章节提取修复与拆书库删除回收站功能

Mochocyang 1 개월 전
부모
커밋
4a74d0f133
26개의 변경된 파일과 663개의 추가작업 그리고 580개의 파일을 삭제
  1. 39 0
      shanchubutton-分支说明.md
  2. 0 1
      src/components/chat/chat-message.tsx
  3. 27 25
      src/components/chat/context-trace-panel.spec.tsx
  4. 2 3
      src/components/chat/context-trace-panel.tsx
  5. 18 101
      src/components/common/context-hub-details.spec.tsx
  6. 9 198
      src/components/common/context-hub-details.tsx
  7. 17 207
      src/components/common/context-hub-stats-summary.tsx
  8. 1 1
      src/components/layout/preview-panel.tsx
  9. 2 2
      src/components/layout/trash-panel.tsx
  10. 23 9
      src/components/novel/book-analysis-character-panel.tsx
  11. 49 5
      src/components/novel/book-analysis-library-layout.tsx
  12. 4 0
      src/components/novel/book-analysis-module-view.tsx
  13. 9 2
      src/components/novel/book-analysis-style-card.tsx
  14. 29 1
      src/components/novel/book-analysis-view.tsx
  15. 56 1
      src/components/novel/hooks/use-library-operations.ts
  16. 3 1
      src/components/sources/outline-chat-panel.spec.tsx
  17. 0 1
      src/components/sources/outline-chat-panel.tsx
  18. 71 0
      src/lib/novel/book-analysis/analysis-engine.spec.ts
  19. 11 4
      src/lib/novel/book-analysis/analysis-engine.ts
  20. 53 0
      src/lib/novel/book-analysis/story-analysis-adapter.spec.ts
  21. 8 0
      src/lib/novel/book-analysis/story-analysis-adapter.ts
  22. 71 1
      src/lib/novel/book-analysis/story-map-history.spec.ts
  23. 42 0
      src/lib/novel/book-analysis/story-map-history.ts
  24. 53 16
      src/lib/trash.ts
  25. 23 1
      src/test-helpers/fs-temp.ts
  26. 43 0
      zhangjietiquxiufu-分支说明.md

+ 39 - 0
shanchubutton-分支说明.md

@@ -0,0 +1,39 @@
+# shanchubutton 分支说明
+
+## 分支用途
+
+本分支为「拆书库生成内容删除按钮」功能分支。
+
+## 使用要求
+
+1. 本分支只开发「拆书库删除按钮」相关功能,不混入其他功能改动。
+2. 拆书库中生成的内容(文风 Skill、角色 Skill、故事 Skill)均可删除。
+3. 所有删除操作必须带二次确认弹窗;删除后内容移入「应用内回收站」(项目根目录 .trash,保留 30 天可恢复),不再永久删除。
+4. 删除范围:
+   - 文风 Skill:删除 style-profile.json 及配套 style.md;若该文风已启用,同时取消启用。
+   - 角色 Skill:按角色删除,删除角色档案(characters/<id>.json)与对应生成的 Skill(skills/<名字>-skill.md)。
+   - 故事 Skill:每张历史导图卡片可单独删除(整目录移入回收站);删除最新一张时同步刷新根目录 story-map 引用。
+5. 本分支必须保持稳定、可打包。
+6. 开发完成后先运行源码,再验证旧功能不回退,最后再考虑打包。
+
+## 更新记录
+
+- 2026-08-26:创建分支,开始开发拆书库删除按钮功能。
+- 2026-08-26:完成三个 Skill 的删除功能开发:
+  - 文风 Skill:文风卡片新增「删除文风」按钮;删除 style-profile.json + style.md;若该文风当前已启用,删除时一并取消启用。
+  - 角色 Skill:角色列表每个角色新增删除按钮(含删除角色档案与对应 Skill);删除当前选中角色时自动清空选中态。
+  - 故事 Skill:每张历史导图卡片新增「删除」按钮;删除最新一张时根目录 story-map 引用同步为剩余历史的最新一份,无剩余时移除根引用;非法目录名被拒绝。
+  - 全部删除操作均带 window.confirm 二次确认弹窗。
+  - 新增 deleteStoryMapHistory 单元测试 5 条;测试 mock deleteFile 改为支持递归删除目录(贴近真实命令行为)。
+  - 验证:typecheck 通过;拆书库目录 43 个测试文件 340 条用例全部通过。
+- 2026-08-26:删除改为移入应用内回收站(.trash),可恢复:
+  - trash.ts 支持整目录移入与恢复(moveFileToTrash / restoreTrashItem);故事导图整目录(story-map.json + html)一起回收。
+  - deleteStoryMapHistory 增加 projectPath 参数,传入则移入回收站,否则保持原永久删除(兼容旧调用)。
+  - 文风/角色/故事导图三个删除入口全部改走回收站,二次确认文案更新为「删除后将移入回收站,可恢复」。
+  - 回收站列表与预览新增 kind 标签支持(skill → 技能/画像,storymap → 故事导图)。
+  - 测试:trash.test.ts 新增目录移入/恢复 2 条;story-map-history.spec.ts 新增走回收站 1 条;共 17 条相关用例全部通过。
+  - 验证:typecheck 通过;vite build 通过;tauri build 成功(release-portable/QMaiWrite.exe + version-info.json)。
+
+## 提交状态
+
+- 尚未提交(开发中)。

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

@@ -308,7 +308,6 @@ export function ChatMessage({
               ) : currentContextHubSnapshot ? (
                 <ContextHubDetails
                   reference={currentContextHubSnapshot}
-                  projectPath={projectPath}
                 />
               ) : null}
             </div>

+ 27 - 25
src/components/chat/context-trace-panel.spec.tsx

@@ -85,18 +85,14 @@ describe("ContextTracePanel selected skills", () => {
 
     expect(html).toContain("上下文中控")
     expect(html).not.toContain("4ms")
-    expect(html).toContain("本轮数据源:命中 4,重载 1,无数据 0,fallback 0,失败 0")
-    expect(html).toContain("稳定核心 1,200 Token")
-    expect(html).toContain("会话摘要 180 Token")
-    expect(html).toContain("动态片段 420 Token")
-    expect(html).toContain("上下文压缩预计减少 1,400 Token(44%)")
-    expect(html).toContain("已发送本地稳定核心,是否命中以供应商返回为准")
-    expect(html).toContain("供应商前缀:不可判断")
-    expect(html).toContain("实际用量不可用")
+    expect(html).toContain("本次命中 4 项")
+    expect(html).toContain("命中率 80%")
+    expect(html).toContain("节省约 1,400 Token")
+    expect(html).not.toContain("本轮数据源")
     expect(html).not.toContain("供应商已确认命中")
   })
 
-  it("only reports a confirmed provider hit when cached token usage exists", () => {
+  it("shows single-line summary with zero hits when provider cache was not hit", () => {
     const trace: ContextTrace = {
       id: "trace-provider-cache-hit",
       startedAt: 1,
@@ -131,12 +127,14 @@ describe("ContextTracePanel selected skills", () => {
 
     const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
 
-    expect(html).toContain("低置信度扩展:已启用")
-    expect(html).toContain("供应商已确认命中 768 Token(输入占比 50%)")
-    expect(html).toContain("供应商新写入缓存 256 Token")
+    expect(html).toContain("本次命中 0 项")
+    expect(html).toContain("命中率 0%")
+    expect(html).toContain("节省约 600 Token")
+    expect(html).not.toContain("低置信度扩展")
+    expect(html).not.toContain("供应商已确认命中")
   })
 
-  it("labels Codex thread totals and does not show the outer round as a request count", () => {
+  it("shows single-line summary and hides Codex thread diagnostics", () => {
     const trace: ContextTrace = {
       id: "trace-codex-thread-total",
       startedAt: 1,
@@ -179,12 +177,13 @@ describe("ContextTracePanel selected skills", () => {
 
     const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
 
-    expect(html).toContain("Codex 线程累计实际用量:内部请求数不可判断")
-    expect(html).toContain("输入 3,676,375")
-    expect(html).not.toContain("请求 1")
+    expect(html).toContain("本次命中 1 项")
+    expect(html).toContain("命中率 100%")
+    expect(html).toContain("节省约 0 Token")
+    expect(html).not.toContain("Codex 线程累计实际用量")
   })
 
-  it("uses the shared cache viewer when a persisted snapshot reference exists", () => {
+  it("shows single-line summary when a persisted snapshot reference exists", () => {
     const trace: ContextTrace = {
       id: "trace-snapshot",
       startedAt: 1,
@@ -221,11 +220,14 @@ describe("ContextTracePanel selected skills", () => {
       <ContextTracePanel trace={trace} contextHubSnapshot={contextHubSnapshot} />,
     )
 
-    expect(html).toContain("展开上下文中控")
-    expect(html).toContain("本轮数据源:命中 2,重载 1,无数据 0,fallback 0,失败 0")
+    expect(html).toContain("上下文中控")
+    expect(html).toContain("本次命中 2 项")
+    expect(html).toContain("命中率 67%")
+    expect(html).toContain("节省约 150 Token")
+    expect(html).not.toContain("展开上下文中控")
   })
 
-  it("renders sanitized per-request prefix, timing and cache diagnostics", () => {
+  it("shows single-line summary and hides request cache diagnostics", () => {
     const trace: ContextTrace = {
       id: "trace-request-cache",
       startedAt: 1,
@@ -290,11 +292,11 @@ describe("ContextTracePanel selected skills", () => {
 
     const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
 
-    expect(html).toContain("供应商前缀:未变化")
-    expect(html).toContain("请求缓存与间隔(2,另省略 4)")
-    expect(html).toContain("开始间隔")
-    expect(html).toContain("TTFT")
-    expect(html).toContain("abcdef0123")
+    expect(html).toContain("本次命中 1 项")
+    expect(html).toContain("命中率 100%")
+    expect(html).toContain("节省约 50 Token")
+    expect(html).not.toContain("供应商前缀")
+    expect(html).not.toContain("请求缓存与间隔")
   })
 
   it("renders web search trace entries in the overview", () => {

+ 2 - 3
src/components/chat/context-trace-panel.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from "react"
+import { useState, useRef, useEffect } from "react"
 import {
   X,
   Zap,
@@ -396,7 +396,7 @@ function OverviewTab({
   }
   if (!contextInfo) {
     if (currentHubSnapshot) {
-      return <ContextHubDetails reference={currentHubSnapshot} projectPath={projectPath} className="mt-0 border-t-0 pt-0" />
+      return <ContextHubDetails reference={currentHubSnapshot} className="mt-0 border-t-0 pt-0" />
     }
     return (
       <div className="flex flex-col items-center justify-center py-12 text-center">
@@ -490,7 +490,6 @@ function OverviewTab({
           <div className="my-1 h-px bg-border/60" />
           <ContextHubDetails
             reference={currentHubSnapshot}
-            projectPath={projectPath}
             className="mt-0 border-t-0 pt-2"
           />
         </>

+ 18 - 101
src/components/common/context-hub-details.spec.tsx

@@ -2,7 +2,7 @@
 
 import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import { ContextHubDetails } from "./context-hub-details"
 import { CONTEXT_CACHE_SCHEMA_VERSION, type ContextHubSnapshot } from "@/lib/context-hub/types"
 
@@ -83,75 +83,32 @@ describe("ContextHubDetails", () => {
     host.remove()
   })
 
-  it("loads the persisted snapshot and shows cache items plus all composed sections", async () => {
-    const loadSnapshot = vi.fn(async () => snapshot)
+  it("展示标题与单行摘要(本次命中/命中率/节省 token)", async () => {
     await act(async () => {
-      root.render(
-        <ContextHubDetails
-          reference={reference}
-          loadSnapshot={loadSnapshot}
-        />,
-      )
+      root.render(<ContextHubDetails reference={reference} />)
     })
 
     expect(host.textContent).toContain("上下文中控")
-    expect(host.textContent).toContain("本轮数据源:命中 3,重载 2,无数据 0,fallback 0,失败 0")
-    expect(host.textContent).toContain("Token")
-    expect(host.textContent).not.toContain("稳定核心正文")
-
-    const expandButton = host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
-    await act(async () => {
-      expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
-    })
-
-    expect(loadSnapshot).toHaveBeenCalledWith(reference)
-    expect(host.textContent).toContain("大纲资料")
-    expect(host.textContent).toContain("拆书库分析")
-    expect(host.textContent).toContain("稳定核心前缀")
-    expect(host.textContent).toContain("缓存失效范围预览")
-    expect(host.textContent).toContain("wiki/outlines/main.md")
-    expect(host.textContent).toContain("另有 2 个文件")
-    expect(host.textContent).toContain("稳定核心正文")
-    expect(host.textContent).toContain("供应商已确认命中 800 Token(输入占比 50%)")
-    expect(host.textContent).toContain("供应商新写入缓存 200 Token")
-    expect(host.innerHTML).toContain("max-h-96")
-    expect(host.innerHTML).toContain("overflow-y-auto")
-
-    const summaryTab = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent === "会话摘要")
-    await act(async () => {
-      summaryTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
-    })
-    expect(host.textContent).toContain("会话摘要正文")
-
-    const dynamicTab = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent === "动态片段")
-    await act(async () => {
-      dynamicTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
-    })
-    expect(host.textContent).toContain("动态片段正文")
+    expect(host.textContent).toContain("本次命中 3 项")
+    expect(host.textContent).toContain("命中率 60%")
+    expect(host.textContent).toContain("节省约 1,320 Token")
+    expect(host.textContent).not.toContain("本轮数据源")
+    expect(host.textContent).not.toContain("供应商已确认命中")
+    expect(host.textContent).not.toContain("展开上下文中控")
   })
 
-  it("keeps summary statistics visible when the snapshot cannot be read", async () => {
-    await act(async () => {
-      root.render(
-        <ContextHubDetails
-          reference={reference}
-          loadSnapshot={async () => null}
-        />,
-      )
-    })
-
-    const expandButton = host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
+  it("无数据源时显示友好文案", async () => {
+    const emptyStats = {
+      ...snapshot.stats,
+      cacheHits: 0, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
+    }
     await act(async () => {
-      expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      root.render(<ContextHubDetails reference={{ ...reference, stats: emptyStats }} />)
     })
-
-    expect(host.textContent).toContain("命中 3")
-    expect(host.textContent).toContain("上下文快照不可用")
+    expect(host.textContent).toContain("本轮无上下文数据")
   })
 
-  it("hides legacy hits/refreshed/failures snapshot refs", async () => {
+  it("旧格式 stats 不渲染", async () => {
     const legacyReference = {
       id: "assistant:legacy",
       surface: "ai-chat" as const,
@@ -172,49 +129,9 @@ describe("ContextHubDetails", () => {
     }
 
     await act(async () => {
-      root.render(
-        <ContextHubDetails
-          reference={legacyReference as never}
-          loadSnapshot={async () => null}
-        />,
-      )
+      root.render(<ContextHubDetails reference={legacyReference as never} />)
     })
 
     expect(host.textContent).toBe("")
   })
-
-  it("reloads an expanded snapshot when the same message receives a newer snapshot", async () => {
-    const newerSnapshot: ContextHubSnapshot = {
-      ...snapshot,
-      createdAt: 20,
-      stableCore: "续传后的稳定核心",
-    }
-    let currentSnapshot = snapshot
-    const loadSnapshot = vi.fn(async () => currentSnapshot)
-
-    await act(async () => {
-      root.render(<ContextHubDetails reference={reference} loadSnapshot={loadSnapshot} />)
-    })
-    await act(async () => {
-      host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
-        ?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
-    })
-    expect(host.textContent).toContain("稳定核心正文")
-
-    currentSnapshot = newerSnapshot
-    const newerReference = {
-      id: "assistant:1:resume-2",
-      surface: "ai-chat" as const,
-      createdAt: newerSnapshot.createdAt,
-      stats: newerSnapshot.stats,
-    }
-    currentSnapshot = { ...newerSnapshot, id: newerReference.id }
-    await act(async () => {
-      root.render(<ContextHubDetails reference={newerReference} loadSnapshot={loadSnapshot} />)
-    })
-
-    expect(loadSnapshot).toHaveBeenLastCalledWith(newerReference)
-    expect(host.textContent).toContain("续传后的稳定核心")
-    expect(host.textContent).not.toContain("稳定核心正文")
-  })
 })

+ 9 - 198
src/components/common/context-hub-details.tsx

@@ -1,222 +1,33 @@
-import { useEffect, useState } from "react"
-import { ChevronDown, ChevronUp, Database, FileText } from "lucide-react"
+import { Database } from "lucide-react"
 import { cn } from "@/lib/utils"
-import { getContextHub } from "@/lib/context-hub/context-hub"
 import {
   isCurrentContextHubStats,
-  type ContextCacheItemStatus,
-  type ContextCacheItemTrace,
-  type ContextHubSnapshot,
   type ContextHubSnapshotRef,
   type ContextHubStats,
 } from "@/lib/context-hub/types"
 import { ContextHubStatsSummary } from "./context-hub-stats-summary"
 
-
-
-type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
-
 interface ContextHubDetailsProps {
   reference: ContextHubSnapshotRef
-  projectPath?: string | null
-  loadSnapshot?: (reference: ContextHubSnapshotRef) => Promise<ContextHubSnapshot | null>
   className?: string
 }
 
-const SOURCE_LABELS: Record<string, string> = {
-  outline: "大纲资料",
-  chapterOutline: "章节大纲",
-  volumeContext: "分卷上下文",
-  snapshots: "章节快照",
-  recentChapterContents: "最近章节正文",
-  fallbackRecentSummaries: "最近章节摘要",
-  fallbackPreviousEnding: "上一章结尾",
-  fallbackCharacterStates: "人物当前状态",
-  fallbackForeshadowingStates: "伏笔状态",
-  fallbackTimeline: "故事时间线",
-  relatedSettings: "相关设定",
-  canonRules: "硬性世界规则",
-  writingStyle: "写作风格",
-  bookAnalysisReferences: "拆书库分析",
-  searchResults: "任务检索结果",
-  graphSearchResults: "关系图检索结果",
-  revisionFeedback: "修订反馈",
-  cognitionText: "人物认知",
-  soulDoc: "作品灵魂",
-  sectionBriefing: "本节简报",
-  storyFrameworkBinding: "故事框架绑定",
-  retrieval: "检索索引",
-  stableCore: "稳定核心前缀",
-}
-
-const STATUS_LABELS: Record<ContextCacheItemStatus, string> = {
-  cache_hit: "命中",
-  reloaded: "重载",
-  empty: "无数据",
-  fallback_used: "fallback",
-  read_failed: "读取失败",
-  write_failed: "写入失败",
-}
-
-const STATUS_ORDER: ContextCacheItemStatus[] = [
-  "cache_hit",
-  "reloaded",
-  "empty",
-  "fallback_used",
-  "write_failed",
-  "read_failed",
-]
-
-const SECTION_LABELS: Record<ContextSection, string> = {
-  stableCore: "稳定核心",
-  sessionSummary: "会话摘要",
-  dynamicContext: "动态片段",
-}
-
-function getSourceLabel(sourceName: string): string {
-  return SOURCE_LABELS[sourceName] ?? sourceName
-}
-
-function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; items: ContextCacheItemTrace[] }) {
-  if (items.length === 0) return null
-  return (
-    <section className="border-t border-border/60 py-2 first:border-t-0">
-      <div className="mb-1 text-[11px] font-medium text-foreground">
-        {STATUS_LABELS[status]}({items.length})
-      </div>
-      <div className="space-y-2">
-        {items.map((item, index) => (
-          <div key={`${item.key}:${item.status}:${index}`} className="min-w-0 text-[11px] text-muted-foreground">
-            <div className="flex min-w-0 items-center gap-1.5">
-              <FileText aria-hidden="true" className="h-3 w-3 shrink-0" />
-              <span className="min-w-0 break-words text-foreground/80">{getSourceLabel(item.sourceName)}</span>
-            </div>
-            {item.dependencyPaths.length > 0 && (
-              <ul className="ml-4 mt-1 space-y-0.5 border-l border-border/70 pl-2">
-                <li className="text-foreground/60">缓存失效范围预览</li>
-                {item.dependencyPaths.map((path) => (
-                  <li key={path} className="break-all">{path}</li>
-                ))}
-                {item.dependencyPathsTruncated ? (
-                  <li className="text-foreground/60">
-                    另有 {Math.max(0, item.dependencyStamp.sourceCount - item.dependencyPaths.length).toLocaleString()} 个文件,按集合指纹校验
-                  </li>
-                ) : null}
-              </ul>
-            )}
-          </div>
-        ))}
-      </div>
-    </section>
-  )
-}
-
-export function ContextHubDetails({
-  reference,
-  projectPath,
-  loadSnapshot,
-  className,
-}: ContextHubDetailsProps) {
-  const [expanded, setExpanded] = useState(false)
-  const [snapshot, setSnapshot] = useState<ContextHubSnapshot | null | undefined>(undefined)
-  const [loading, setLoading] = useState(false)
-  const [activeSection, setActiveSection] = useState<ContextSection>("stableCore")
-  // Legacy hits/refreshed/failures refs are display-only history — drop them.
+/** 上下文中控:仅展示单行摘要,不再提供快照展开与技术详情。 */
+export function ContextHubDetails({ reference, className }: ContextHubDetailsProps) {
   const stats = isCurrentContextHubStats(reference.stats) ? reference.stats : null
-
-  useEffect(() => {
-    if (!stats || !expanded) return
-    let cancelled = false
-    setSnapshot(undefined)
-    setLoading(true)
-    const read = async () => {
-      const loader = loadSnapshot
-        ?? (projectPath ? (value: ContextHubSnapshotRef) => getContextHub(projectPath).readSnapshot(value) : undefined)
-      try {
-        const value = loader ? await loader(reference) : null
-        if (!cancelled) setSnapshot(value)
-      } catch {
-        if (!cancelled) setSnapshot(null)
-      } finally {
-        if (!cancelled) setLoading(false)
-      }
-    }
-    void read()
-    return () => { cancelled = true }
-  }, [expanded, loadSnapshot, projectPath, reference.createdAt, reference.id, stats])
-
-  const warnings = snapshot?.warnings ?? []
   if (!stats) return null
 
   return (
     <div className={cn("mt-2 min-w-0 border-t border-border/60 pt-2", className)}>
-      <button
-        type="button"
-        aria-label={expanded ? "收起上下文中控" : "展开上下文中控"}
-        aria-expanded={expanded}
-        onClick={() => setExpanded((value) => !value)}
-        className="flex w-full min-w-0 items-start gap-2 text-left"
-      >
+      <div className="flex w-full min-w-0 items-start gap-2">
         <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-100 text-teal-600 dark:bg-teal-950/40 dark:text-teal-400">
           <Database aria-hidden="true" className="h-3.5 w-3.5" />
         </span>
-        <span className="min-w-0 flex-1">
-          <span className="block text-xs font-medium text-foreground">上下文中控</span>
-          <ContextHubStatsSummary
-            stats={stats}
-            warnings={expanded ? warnings : []}
-            showComposed
-            showMemory
-          />
-        </span>
-        {expanded
-          ? <ChevronUp aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
-          : <ChevronDown aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />}
-      </button>
-
-      {expanded && (
-        <div className="ml-8 mt-2 min-w-0">
-          {loading ? (
-            <div className="py-3 text-[11px] text-muted-foreground">正在读取上下文快照...</div>
-          ) : snapshot === null ? (
-            <div className="py-3 text-[11px] text-amber-700 dark:text-amber-300">上下文快照不可用</div>
-          ) : snapshot ? (
-            <div className="mt-2 min-w-0">
-              <div className="max-h-48 overflow-y-auto border-y border-border/60 pr-1">
-                {STATUS_ORDER.map((status) => (
-                  <CacheItemGroup
-                    key={status}
-                    status={status}
-                    items={snapshot.items.filter((item) => item.status === status)}
-                  />
-                ))}
-              </div>
-              <div className="flex border-b border-border/60" role="tablist" aria-label="上下文内容">
-                {(Object.keys(SECTION_LABELS) as ContextSection[]).map((section) => (
-                  <button
-                    key={section}
-                    type="button"
-                    role="tab"
-                    aria-selected={activeSection === section}
-                    onClick={() => setActiveSection(section)}
-                    className={cn(
-                      "border-b-2 px-2 py-1.5 text-[11px] font-medium",
-                      activeSection === section
-                        ? "border-primary text-foreground"
-                        : "border-transparent text-muted-foreground hover:text-foreground",
-                    )}
-                  >
-                    {SECTION_LABELS[section]}
-                  </button>
-                ))}
-              </div>
-              <pre className="max-h-96 min-w-0 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words bg-muted/30 p-2 text-[11px] leading-relaxed text-foreground/80">
-                {snapshot[activeSection] || `本轮无${SECTION_LABELS[activeSection]}内容`}
-              </pre>
-            </div>
-          ) : null}
+        <div className="min-w-0 flex-1">
+          <div className="text-xs font-medium text-foreground">上下文中控</div>
+          <ContextHubStatsSummary stats={stats} />
         </div>
-      )}
+      </div>
     </div>
   )
 }
@@ -238,7 +49,7 @@ export function ContextHubStatsOnly({
         </span>
         <div className="min-w-0 flex-1">
           <div className="text-xs font-medium text-foreground">上下文中控</div>
-          <ContextHubStatsSummary stats={stats} showComposed showMemory />
+          <ContextHubStatsSummary stats={stats} />
         </div>
       </div>
     </div>

+ 17 - 207
src/components/common/context-hub-stats-summary.tsx

@@ -1,222 +1,32 @@
-import type {
-  ContextHubStats,
-  StablePrefixStatus,
-} from "@/lib/context-hub/types"
-import type { LlmRequestCacheTrace } from "@/lib/llm-request-trace"
+import type { ContextHubStats } from "@/lib/context-hub/types"
 
-const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
-  unchanged: "未变化",
-  updated: "已更新",
-  persist_failed: "持久化失败",
+function formatTokens(tokens: number): string {
+  return `${tokens.toLocaleString()} Token`
 }
 
 interface ContextHubStatsSummaryProps {
   stats: ContextHubStats
-  warnings?: string[]
-  /** Show composed/budget row (default true). */
-  showComposed?: boolean
-  /** Show memory row when present (default true). */
-  showMemory?: boolean
   className?: string
 }
 
-function formatTokens(tokens: number): string {
-  return `${tokens.toLocaleString()} Token`
-}
-
-function formatDuration(milliseconds: number | undefined): string {
-  if (milliseconds === undefined) return "—"
-  if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
-  return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
-}
-
-function getProviderPrefixStatus(requests: LlmRequestCacheTrace[]): "未变化" | "已变化" | "不可判断" {
-  const fingerprints = requests
-    .map((request) => request.prefixFingerprint)
-    .filter((value): value is string => Boolean(value))
-  if (fingerprints.length < 2) return "不可判断"
-  return new Set(fingerprints).size === 1 ? "未变化" : "已变化"
-}
-
-function requestPrefixStatus(
-  request: LlmRequestCacheTrace,
-  previous: LlmRequestCacheTrace | undefined,
-): "未变化" | "已变化" | "不可判断" {
-  if (!request.prefixFingerprint || !previous?.prefixFingerprint) return "不可判断"
-  return request.prefixFingerprint === previous.prefixFingerprint ? "未变化" : "已变化"
-}
-
-const REQUEST_STATUS_LABELS: Record<LlmRequestCacheTrace["status"], string> = {
-  success: "成功",
-  error: "供应商错误",
-  cancelled: "已取消",
-  network_error: "网络错误",
-}
-
-function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
-  const cachedTokens = stats.providerCachedTokens
-  const inputTokens = stats.providerInputTokens
-  const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0
-    ? Math.min(100, Math.round((cachedTokens / inputTokens) * 100))
-    : null
-
-  return (
-    <>
-      {cachedTokens !== undefined ? (
-        cachedTokens > 0 ? (
-          <div className="font-medium text-green-600 dark:text-green-400">
-            供应商已确认命中 {cachedTokens.toLocaleString()} Token
-            {hitPercent !== null ? `(输入占比 ${hitPercent}%)` : ""}
-          </div>
-        ) : (
-          <div>供应商已确认本次未命中缓存(0 Token)</div>
-        )
-      ) : stats.providerUsageReported ? (
-        <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
-      ) : stats.providerCacheEnabled ? (
-        <div>已发送本地稳定核心,是否命中以供应商返回为准</div>
-      ) : null}
-      {(stats.providerCacheWriteTokens ?? 0) > 0 && (
-        <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
-      )}
-    </>
-  )
-}
+/** 上下文中控单行摘要:只展示普通用户可读的命中与节省信息。 */
+export function ContextHubStatsSummary({ stats, className }: ContextHubStatsSummaryProps) {
+  const total =
+    stats.cacheHits
+    + stats.reloaded
+    + stats.empty
+    + stats.fallbackUsed
+    + stats.readFailed
+    + stats.writeFailed
 
-export function ContextHubStatsSummary({
-  stats,
-  warnings = [],
-  showComposed = true,
-  showMemory = true,
-  className,
-}: ContextHubStatsSummaryProps) {
-  const failures = stats.readFailed + stats.writeFailed
-  const diagnostics = stats.requestDiagnostics
-  const usageScopeLabel = diagnostics?.usageScope === "provider_thread"
-    ? "Codex 线程累计实际用量"
-    : "工作流累计实际用量"
+  if (total === 0) {
+    return <div className={className}>本轮无上下文数据</div>
+  }
 
+  const hitRate = Math.round((stats.cacheHits / total) * 100)
   return (
     <div className={className}>
-      <div className="mt-0.5 text-[11px] text-muted-foreground">
-        {`本轮数据源:命中 ${stats.cacheHits.toLocaleString()},重载 ${stats.reloaded.toLocaleString()},无数据 ${stats.empty.toLocaleString()},fallback ${stats.fallbackUsed.toLocaleString()},失败 ${failures.toLocaleString()}`}
-      </div>
-      {stats.stablePrefixStatus ? (
-        <div className="mt-0.5 text-[11px] text-muted-foreground">
-          本地稳定核心:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
-        </div>
-      ) : null}
-      <div className="mt-0.5 text-[11px] text-muted-foreground">
-        {`稳定核心 ${formatTokens(stats.stableTokens)} 会话摘要 ${formatTokens(stats.summaryTokens)} 动态片段 ${formatTokens(stats.dynamicTokens)}`}
-      </div>
-      {showComposed && stats.composedTokens !== undefined && stats.budgetTokens !== undefined ? (
-        <div className="mt-0.5 text-[11px] text-muted-foreground">
-          中控注入 {stats.composedTokens.toLocaleString()} / 中控预算 {stats.budgetTokens.toLocaleString()}
-          ({stats.utilizationPercent ?? 0}%)
-        </div>
-      ) : null}
-      {showMemory && stats.memoryCandidateCount !== undefined ? (
-        <div className="mt-0.5 text-[11px] text-muted-foreground">
-          用户记忆:候选 {stats.memoryCandidateCount},命中 {stats.memorySelectedCount ?? 0},
-          过滤 {stats.memoryFilteredCount ?? 0},注入约 {stats.memoryEstimatedTokens ?? 0} Token
-        </div>
-      ) : null}
-      <div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
-        <div>
-          上下文压缩预计减少 {formatTokens(stats.estimatedSavedTokens)}({stats.estimatedSavedPercent}%)
-        </div>
-        <div>低置信度扩展:{stats.expanded ? "已启用" : "未启用"}</div>
-        <ProviderCacheUsage stats={stats} />
-        {diagnostics ? (
-          diagnostics.providerUsageAvailable ? (
-            <div>
-              {usageScopeLabel}:{diagnostics.requestCountAvailable === false
-                ? "内部请求数不可判断"
-                : `请求 ${diagnostics.requestCount}`},
-              输入 {(diagnostics.inputTokens ?? 0).toLocaleString()},
-              输出 {(diagnostics.outputTokens ?? 0).toLocaleString()},
-              缓存读 {(diagnostics.cacheReadTokens ?? 0).toLocaleString()},
-              缓存写 {(diagnostics.cacheWriteTokens ?? 0).toLocaleString()}
-            </div>
-          ) : (
-            <div>实际用量不可用</div>
-          )
-        ) : stats.providerUsageReported ? (
-          <div>
-            工作流累计实际用量:输入 {(stats.providerInputTokens ?? 0).toLocaleString()}
-            {stats.providerCachedTokens !== undefined
-              ? `,缓存读 ${stats.providerCachedTokens.toLocaleString()}`
-              : ""}
-          </div>
-        ) : (
-          <div>实际用量不可用</div>
-        )}
-        <div>
-          供应商前缀:{getProviderPrefixStatus(diagnostics?.requests ?? [])}
-        </div>
-        {(diagnostics?.requests?.length ?? 0) > 0 ? (
-          <details className="mt-1">
-            <summary className="cursor-pointer select-none font-medium">
-              请求缓存与间隔({diagnostics?.requests?.length ?? 0}
-              {(diagnostics?.omittedRequestCount ?? 0) > 0
-                ? `,另省略 ${diagnostics?.omittedRequestCount}`
-                : ""})
-            </summary>
-            <div className="mt-1 overflow-x-auto">
-              <table className="w-full min-w-[720px] border-collapse text-left text-[10px]">
-                <thead>
-                  <tr className="border-b border-border/60">
-                    <th className="py-1 pr-2 font-medium">请求</th>
-                    <th className="py-1 pr-2 font-medium">供应商前缀</th>
-                    <th className="py-1 pr-2 font-medium">开始间隔</th>
-                    <th className="py-1 pr-2 font-medium">空闲间隔</th>
-                    <th className="py-1 pr-2 font-medium">耗时</th>
-                    <th className="py-1 pr-2 font-medium">TTFT</th>
-                    <th className="py-1 pr-2 font-medium">输入/输出</th>
-                    <th className="py-1 pr-2 font-medium">缓存读/写</th>
-                    <th className="py-1 font-medium">状态</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  {diagnostics?.requests?.map((request, index, requests) => (
-                    <tr
-                      key={`${request.startedAt}:${index}`}
-                      className="border-b border-border/30 last:border-b-0"
-                    >
-                      <td className="whitespace-nowrap py-1 pr-2">
-                        #{index + 1} {request.provider}/{request.model}
-                      </td>
-                      <td className="whitespace-nowrap py-1 pr-2">
-                        {requestPrefixStatus(request, requests[index - 1])}
-                        {request.prefixFingerprint ? ` · ${request.prefixFingerprint.slice(0, 10)}` : ""}
-                      </td>
-                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.startGapMs)}</td>
-                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.idleGapMs)}</td>
-                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.durationMs)}</td>
-                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.firstResponseMs)}</td>
-                      <td className="whitespace-nowrap py-1 pr-2">
-                        {request.inputTokens?.toLocaleString() ?? "—"}/{request.outputTokens?.toLocaleString() ?? "—"}
-                      </td>
-                      <td className="whitespace-nowrap py-1 pr-2">
-                        {request.cacheReadTokens?.toLocaleString() ?? "—"}/{request.cacheWriteTokens?.toLocaleString() ?? "—"}
-                      </td>
-                      <td className="whitespace-nowrap py-1">{REQUEST_STATUS_LABELS[request.status]}</td>
-                    </tr>
-                  ))}
-                </tbody>
-              </table>
-            </div>
-          </details>
-        ) : null}
-      </div>
-      {warnings.length > 0 ? (
-        <div className="mt-1 space-y-0.5 text-[11px] text-amber-700 dark:text-amber-300">
-          <div className="font-medium">警告({warnings.length})</div>
-          {warnings.map((warning) => (
-            <div key={warning}>{warning}</div>
-          ))}
-        </div>
-      ) : null}
+      {`本次命中 ${stats.cacheHits.toLocaleString()} 项 · 命中率 ${hitRate}% · 节省约 ${formatTokens(stats.estimatedSavedTokens)}`}
     </div>
   )
 }

+ 1 - 1
src/components/layout/preview-panel.tsx

@@ -1266,7 +1266,7 @@ export function PreviewPanel() {
               <div className="flex flex-col">
                 <div className="text-sm font-medium truncate">{selectedTrashItem.name}</div>
                 <div className="text-xs text-muted-foreground truncate">
-                  {t("trash.deletedItem", { defaultValue: "已删除项目" })} · {selectedTrashItem.kind === "chapter" ? t("trash.kindChapter", { defaultValue: "章节" }) : selectedTrashItem.kind === "outline" ? t("trash.kindOutline", { defaultValue: "大纲" }) : selectedTrashItem.kind === "history" ? t("trash.kindHistory", { defaultValue: "历史记录" }) : t("trash.kindPage", { defaultValue: "页面" })}
+                  {t("trash.deletedItem", { defaultValue: "已删除项目" })} · {selectedTrashItem.kind === "chapter" ? t("trash.kindChapter", { defaultValue: "章节" }) : selectedTrashItem.kind === "outline" ? t("trash.kindOutline", { defaultValue: "大纲" }) : selectedTrashItem.kind === "history" ? t("trash.kindHistory", { defaultValue: "历史记录" }) : selectedTrashItem.kind === "skill" ? t("trash.kindSkill", { defaultValue: "技能/画像" }) : selectedTrashItem.kind === "storymap" ? t("trash.kindStoryMap", { defaultValue: "故事导图" }) : t("trash.kindPage", { defaultValue: "页面" })}
                 </div>
                 <div className="text-xs text-muted-foreground truncate">
                   {t("trash.originalPath", { defaultValue: "原路径" })}: {selectedTrashItem.originalPath}

+ 2 - 2
src/components/layout/trash-panel.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react"
+import { useCallback, useEffect, useState } from "react"
 import { RotateCcw, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { listDirectory } from "@/commands/fs"
@@ -167,7 +167,7 @@ export function TrashPanel() {
                     <div className="min-w-0 flex-1">
                       <div className="truncate text-sm font-medium" title={item.originalPath}>{item.name}</div>
                       <div className="mt-0.5 text-xs text-muted-foreground">
-                        {t("trash.remainingDays", { days: remainingDays, defaultValue: "剩余{{days}}天" })} · {item.kind === "chapter" ? t("trash.kindChapter", { defaultValue: "章节" }) : item.kind === "outline" ? t("trash.kindOutline", { defaultValue: "大纲" }) : item.kind === "history" ? t("trash.kindHistory", { defaultValue: "历史记录" }) : t("trash.kindPage", { defaultValue: "页面" })}
+                        {t("trash.remainingDays", { days: remainingDays, defaultValue: "剩余{{days}}天" })} · {item.kind === "chapter" ? t("trash.kindChapter", { defaultValue: "章节" }) : item.kind === "outline" ? t("trash.kindOutline", { defaultValue: "大纲" }) : item.kind === "history" ? t("trash.kindHistory", { defaultValue: "历史记录" }) : item.kind === "skill" ? t("trash.kindSkill", { defaultValue: "技能/画像" }) : item.kind === "storymap" ? t("trash.kindStoryMap", { defaultValue: "故事导图" }) : t("trash.kindPage", { defaultValue: "页面" })}
                       </div>
                     </div>
                     <Button

+ 23 - 9
src/components/novel/book-analysis-character-panel.tsx

@@ -1,5 +1,5 @@
 import { useMemo } from "react"
-import { Plus, User } from "lucide-react"
+import { Plus, Trash2, User } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
 
@@ -10,6 +10,7 @@ interface BookAnalysisCharacterPanelProps {
   onOpenSkillSelection: () => void
   onSelectCharacter: (characterId: string) => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
+  onDeleteCharacter: (characterId: string) => void
 }
 
 const categoryLabels: Record<string, string> = {
@@ -33,6 +34,7 @@ export function BookAnalysisCharacterPanel({
   onOpenSkillSelection,
   onSelectCharacter,
   onAddSelectedSkillsToSoul,
+  onDeleteCharacter,
 }: BookAnalysisCharacterPanelProps) {
   const sortedCharacters = useMemo(
     () =>
@@ -86,26 +88,38 @@ export function BookAnalysisCharacterPanel({
               const active = selectedCharacter?.id === character.id
               const hasSkill = book.skills.some((skill) => skill.characterId === character.id || skill.characterName === character.name)
               return (
-                <button
+                <div
                   key={character.id}
-                  type="button"
-                  onClick={() => onSelectCharacter(character.id)}
-                  className={`w-full rounded-lg border p-3 text-left transition ${
+                  className={`w-full rounded-lg border p-3 transition ${
                     active ? "border-primary bg-primary/5" : "hover:bg-muted"
                   }`}
                 >
-                  <div className="flex items-start justify-between gap-2">
-                    <div className="min-w-0">
+                  <div className="flex items-center justify-between gap-2">
+                    <button
+                      type="button"
+                      onClick={() => onSelectCharacter(character.id)}
+                      className="min-w-0 flex-1 text-left"
+                    >
                       <div className="truncate text-sm font-medium">{character.name}</div>
                       <div className="mt-1 text-xs text-muted-foreground">
                         {categoryLabels[character.category] ?? character.category} · 重要度 {character.importance}/10
                       </div>
-                    </div>
+                    </button>
                     <span className="shrink-0 rounded-full border px-2 py-0.5 text-xs">
                       {hasSkill ? "已生成" : "未生成"}
                     </span>
+                    <Button
+                      size="sm"
+                      variant="ghost"
+                      className="h-7 shrink-0 px-2 text-destructive hover:bg-destructive/10"
+                      aria-label={`删除角色${character.name}`}
+                      title="删除角色(含角色档案与 Skill)"
+                      onClick={() => onDeleteCharacter(character.id)}
+                    >
+                      <Trash2 className="h-3.5 w-3.5" />
+                    </Button>
                   </div>
-                </button>
+                </div>
               )
             })
           )}

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

@@ -1,5 +1,6 @@
 import type { ReactNode } from "react"
 import { useEffect, useState } from "react"
+import { Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
@@ -33,7 +34,16 @@ interface StoryMapCardData {
  * 每张卡片含大标题 + 预览,点击「查看全部」展开到完整高度阅读。
  * 兼容旧数据:若只有根目录 story-map.html(无历史目录),按单张方式展示。
  */
-export function StoryMapContent({ bookPath, refreshKey = 0 }: { bookPath: string; refreshKey?: number }) {
+export function StoryMapContent({
+  bookPath,
+  refreshKey = 0,
+  onDeleteStoryMap,
+}: {
+  bookPath: string
+  refreshKey?: number
+  /** 删除某张历史导图(id 为历史目录名或 legacy);由父级实际执行磁盘删除 */
+  onDeleteStoryMap?: (id: string) => Promise<void> | void
+}) {
   const [cards, setCards] = useState<StoryMapCardData[] | null>(null)
   const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
 
@@ -103,6 +113,18 @@ export function StoryMapContent({ bookPath, refreshKey = 0 }: { bookPath: string
     })
   }
 
+  const handleDeleteMap = async (card: StoryMapCardData) => {
+    const title = card.title ? `《${card.title}》故事导图` : "故事导图"
+    if (!window.confirm(`确认删除「${title}」吗?\n\n删除后不可恢复。`)) return
+    if (!onDeleteStoryMap) return
+    try {
+      await onDeleteStoryMap(card.id)
+      setCards((prev) => (prev ? prev.filter((item) => item.id !== card.id) : prev))
+    } catch (error) {
+      console.error("[story-map] 删除故事导图失败", error)
+    }
+  }
+
   if (cards === null) {
     return <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">正在加载故事导图…</div>
   }
@@ -136,9 +158,20 @@ export function StoryMapContent({ bookPath, refreshKey = 0 }: { bookPath: string
                   </div>
                 )}
               </div>
-              <Button size="sm" variant="outline" onClick={() => toggleExpand(card.id)}>
-                {expanded ? "收起" : "查看全部"}
-              </Button>
+              <div className="flex shrink-0 items-center gap-2">
+                <Button size="sm" variant="outline" onClick={() => toggleExpand(card.id)}>
+                  {expanded ? "收起" : "查看全部"}
+                </Button>
+                <Button
+                  size="sm"
+                  variant="ghost"
+                  className="text-destructive hover:bg-destructive/10"
+                  onClick={() => void handleDeleteMap(card)}
+                >
+                  <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+                  删除
+                </Button>
+              </div>
             </div>
             {card.html ? (
               <iframe
@@ -194,6 +227,12 @@ interface BookAnalysisLibraryLayoutProps {
   onSelectAnalysisCharacters?: () => void
   onRetryAnalysisChunk?: (skill: AnalysisSkill, chunkId: string) => void
   onDeleteBook: (bookId: string) => void
+  /** 删除单个角色(档案 + Skill) */
+  onDeleteCharacter?: (characterId: string) => void
+  /** 删除作品文风画像 */
+  onDeleteStyle?: () => void
+  /** 删除某张历史故事导图(id 为历史目录名或 legacy) */
+  onDeleteStoryMap?: (id: string) => Promise<void> | void
   /** 受控页签:任务完成「查看结果」时可从外部切换到对应 Skill 页签 */
   analysisActiveTab?: BookAnalysisModuleTab
   onAnalysisActiveTabChange?: (tab: BookAnalysisModuleTab) => void
@@ -227,6 +266,9 @@ export function BookAnalysisLibraryLayout({
   onConfigureAnalysisTask,
   onSelectAnalysisCharacters,
   onRetryAnalysisChunk,
+  onDeleteCharacter,
+  onDeleteStyle,
+  onDeleteStoryMap,
   analysisActiveTab,
   onAnalysisActiveTabChange,
   storyMapRefreshKey,
@@ -242,7 +284,7 @@ export function BookAnalysisLibraryLayout({
   }
 
   const storyContent = (
-    <StoryMapContent bookPath={selectedBook.path} refreshKey={storyMapRefreshKey} />
+    <StoryMapContent bookPath={selectedBook.path} refreshKey={storyMapRefreshKey} onDeleteStoryMap={onDeleteStoryMap} />
   )
 
   return (
@@ -282,6 +324,8 @@ export function BookAnalysisLibraryLayout({
           else if (skill === "story") onExtractStoryFramework()
           else onExtractStyle()
         }}
+        onDeleteCharacter={onDeleteCharacter}
+        onDeleteStyle={onDeleteStyle}
         onConfigureTask={onConfigureAnalysisTask}
         onSelectCharacters={onSelectAnalysisCharacters}
         onPauseTask={onParallelPauseTask}

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

@@ -38,6 +38,8 @@ interface BookAnalysisModuleViewProps {
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
   onOpenSkillSelection?: () => void
+  onDeleteCharacter?: (characterId: string) => void
+  onDeleteStyle?: () => void
   onReextract: (skill: AnalysisSkill) => void
   onConfigureTask?: () => void
   onSelectCharacters?: () => void
@@ -466,6 +468,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
             onSelectCharacter={props.onSelectCharacter}
             onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul}
             onOpenSkillSelection={props.onOpenSkillSelection ?? (() => undefined)}
+            onDeleteCharacter={props.onDeleteCharacter ?? (() => undefined)}
           />
         )}
         {active === "story" && props.storyContent}
@@ -475,6 +478,7 @@ export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
             extracting={props.extractingStyle}
             onExtractStyle={() => props.onReextract("style")}
             onToggleStyle={props.onToggleStyle}
+            onDeleteStyle={props.onDeleteStyle ?? (() => undefined)}
           />
         )}
         {active === "evidence" && (

+ 9 - 2
src/components/novel/book-analysis-style-card.tsx

@@ -1,5 +1,5 @@
 import { useState } from "react"
-import { ChevronDown, ChevronUp, Feather, Loader2 } from "lucide-react"
+import { ChevronDown, ChevronUp, Feather, Loader2, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
 import { STYLE_DIMENSIONS } from "@/lib/novel/book-analysis/style-prompts"
@@ -9,9 +9,10 @@ interface BookAnalysisStyleCardProps {
   extracting: boolean
   onExtractStyle: () => void
   onToggleStyle: () => void
+  onDeleteStyle: () => void
 }
 
-export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onToggleStyle }: BookAnalysisStyleCardProps) {
+export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onToggleStyle, onDeleteStyle }: BookAnalysisStyleCardProps) {
   const profile = book.styleProfile
   const enabled = book.styleStatus === "enabled"
   const [expanded, setExpanded] = useState(false)
@@ -41,6 +42,12 @@ export function BookAnalysisStyleCard({ book, extracting, onExtractStyle, onTogg
             {extracting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {extracting ? "提取中..." : profile ? "重新提取文风" : "提取文风"}
           </Button>
+          {profile && (
+            <Button size="sm" variant="ghost" className="text-destructive hover:bg-destructive/10" onClick={onDeleteStyle}>
+              <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+              删除文风
+            </Button>
+          )}
         </div>
       </div>
       {profile && (

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

@@ -53,9 +53,10 @@ import {
   selectCharacterCandidates,
 } from "@/lib/novel/book-analysis/character-candidate-selection"
 import { analysisTabForSkills, type BookAnalysisModuleTab } from "./book-analysis-module-view"
-import { readFile } from "@/commands/fs"
+import { readFile, fileExists, deleteFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { saveRecognizedCharacters } from "@/lib/novel/book-analysis/recognized-character-store"
+import { deleteStoryMapHistory } from "@/lib/novel/book-analysis/story-map-history"
 
 interface StoryFrameworkSelectionData {
   book: BookAnalysisLibraryBook
@@ -206,6 +207,8 @@ export function BookAnalysisView() {
     handleLibraryToggleStyle,
     handleLibraryAddSkillsToSoul,
     handleLibraryDeleteBook,
+    handleLibraryDeleteStyle,
+    handleLibraryDeleteCharacter,
     handleLibraryReextractCharacters,
   } = useLibraryOperations({
     currentProjectPath: currentProject?.path ?? null,
@@ -214,6 +217,7 @@ export function BookAnalysisView() {
     setLibraryState,
     setSelectedBookId,
     setSelectedCharacterId,
+    selectedCharacterId,
     setChapterSelectionData,
     llmConfig,
     providerConfigs,
@@ -760,6 +764,27 @@ export function BookAnalysisView() {
     }
   }, [failPipelineTask, llmConfig, openPipelineCharacterPicker, providerConfigs, setRuntimeProgress, setTaskRecognizedCharacters])
 
+  const handleDeleteStoryMap = useCallback(async (id: string) => {
+    if (!currentProject?.path || !selectedLibraryBook) return
+    try {
+      if (id === "legacy") {
+        // 旧格式:仅根目录 story-map.{json,html},直接删除这两份引用文件
+        for (const name of ["story-map.json", "story-map.html"]) {
+          const target = joinPath(selectedLibraryBook.path, name)
+          if (await fileExists(target)) await deleteFile(target)
+        }
+      } else {
+        await deleteStoryMapHistory(selectedLibraryBook.path, id)
+      }
+      useBookAnalysisStore.getState().triggerSidebarRefresh()
+      toast.success("已删除该故事导图。")
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      console.error("[删除故事导图失败]", msg)
+      toast.error(`删除故事导图失败:${msg}`)
+    }
+  }, [currentProject?.path, selectedLibraryBook])
+
   const libraryLayout = (
     <>
     <BookAnalysisLibraryLayout
@@ -778,6 +803,9 @@ export function BookAnalysisView() {
       analysisActiveTab={moduleActiveTab}
       onAnalysisActiveTabChange={setModuleActiveTab}
       storyMapRefreshKey={storyMapRefreshKey}
+      onDeleteCharacter={handleLibraryDeleteCharacter}
+      onDeleteStyle={handleLibraryDeleteStyle}
+      onDeleteStoryMap={handleDeleteStoryMap}
       onParallelPauseTask={(taskId) => {
         void pausePipelineTask(taskId).catch((error) => {
           toast.error(`暂停失败:${error instanceof Error ? error.message : String(error)}`)

+ 56 - 1
src/components/novel/hooks/use-library-operations.ts

@@ -13,7 +13,8 @@ import {
 import { bindCharacterAura, listBindableNovelCharacters } from "@/lib/novel/character-aura"
 import { setEnabledWritingStyle, upsertWritingStylePreset } from "@/lib/novel/writing-style-store"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { readFile, listDirectory } from "@/commands/fs"
+import { fileExists, readFile, listDirectory } from "@/commands/fs"
+import { moveFileToTrash } from "@/lib/trash"
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
@@ -27,6 +28,8 @@ interface UseLibraryOperationsParams {
   setLibraryState: React.Dispatch<React.SetStateAction<BookAnalysisLibraryState>>
   setSelectedBookId: React.Dispatch<React.SetStateAction<string | null>>
   setSelectedCharacterId: React.Dispatch<React.SetStateAction<string | null>>
+  /** 当前选中的角色 id(用于删除选中角色后清空选中态) */
+  selectedCharacterId: string | null
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
   providerConfigs: ReturnType<typeof useWikiStore.getState>["providerConfigs"]
@@ -47,6 +50,7 @@ export function useLibraryOperations({
   setLibraryState,
   setSelectedBookId,
   setSelectedCharacterId,
+  selectedCharacterId,
   setChapterSelectionData,
   llmConfig,
   providerConfigs,
@@ -268,6 +272,55 @@ export function useLibraryOperations({
     }
   }, [currentProjectPath, libraryState.books, deletePublishedBook, setSelectedBookId, setSelectedCharacterId, reloadLibraryState])
 
+  const handleLibraryDeleteStyle = useCallback(async () => {
+    if (!currentProjectPath || !selectedLibraryBook?.styleProfile) return
+    const title = selectedLibraryBook.metadata.title
+    const enabledNow = libraryState.enabledStyle?.sourceBook === title
+    const confirmed = window.confirm(
+      `确认删除《${title}》的文风画像吗?\n\n删除后将移入回收站,可恢复。${enabledNow ? "\n该文风当前已启用,删除时会一并取消启用。" : ""}`,
+    )
+    if (!confirmed) return
+    try {
+      if (enabledNow) await setEnabledWritingStyle(currentProjectPath, null)
+      const profilePath = joinPath(selectedLibraryBook.path, "style-profile.json")
+      if (await fileExists(profilePath)) await moveFileToTrash(currentProjectPath, profilePath, "skill")
+      const styleMdPath = joinPath(selectedLibraryBook.path, "style.md")
+      if (await fileExists(styleMdPath)) await moveFileToTrash(currentProjectPath, styleMdPath, "skill")
+      await reloadLibraryState()
+      useBookAnalysisStore.getState().triggerSidebarRefresh()
+      toast.success(`已删除《${title}》的文风画像。`)
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      console.error("[删除文风失败]", msg)
+      toast.error(`删除文风失败:${msg}`)
+    }
+  }, [currentProjectPath, selectedLibraryBook, libraryState.enabledStyle, reloadLibraryState])
+
+  const handleLibraryDeleteCharacter = useCallback(async (characterId: string) => {
+    if (!currentProjectPath || !selectedLibraryBook) return
+    const character = selectedLibraryBook.characters.find((item) => item.id === characterId)
+    if (!character) return
+    const confirmed = window.confirm(
+      `确认删除角色「${character.name}」吗?\n\n将删除该角色的档案与已生成的 Skill,删除后将移入回收站,可恢复。`,
+    )
+    if (!confirmed) return
+    try {
+      const characterPath = joinPath(selectedLibraryBook.path, "characters", `${character.id}.json`)
+      if (await fileExists(characterPath)) await moveFileToTrash(currentProjectPath, characterPath, "skill")
+      const skill = selectedLibraryBook.skills.find(
+        (item) => item.characterId === character.id || item.characterName === character.name,
+      )
+      if (skill?.filePath && await fileExists(skill.filePath)) await moveFileToTrash(currentProjectPath, skill.filePath, "skill")
+      if (selectedCharacterId === characterId) setSelectedCharacterId(null)
+      await reloadLibraryState()
+      toast.success(`已删除角色「${character.name}」(含档案与 Skill)。`)
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      console.error("[删除角色失败]", msg)
+      toast.error(`删除角色失败:${msg}`)
+    }
+  }, [currentProjectPath, selectedLibraryBook, selectedCharacterId, reloadLibraryState])
+
   const handleLibraryReextractCharacters = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook) return
     if (!hasUsableLlm(llmConfig, providerConfigs)) {
@@ -343,6 +396,8 @@ export function useLibraryOperations({
     handleLibraryAddSkillsToSoul,
     handleLibraryBindCharacter,
     handleLibraryDeleteBook,
+    handleLibraryDeleteStyle,
+    handleLibraryDeleteCharacter,
     handleLibraryReextractCharacters,
   }
 }

+ 3 - 1
src/components/sources/outline-chat-panel.spec.tsx

@@ -188,7 +188,9 @@ describe("OutlineChatPanel controls", () => {
     const container = await renderOutlineChatPanel()
 
     expect(container.textContent).toContain("上下文中控")
-    expect(container.textContent).toContain("本轮数据源:命中 3,重载 2,无数据 0,fallback 0,失败 0")
+    expect(container.textContent).toContain("本次命中 3 项")
+    expect(container.textContent).toContain("命中率 60%")
+    expect(container.textContent).toContain("节省约 1,320 Token")
   })
 
   it.each([

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

@@ -1073,7 +1073,6 @@ function OutlineAssistantMessage({
       {currentContextHubSnapshot ? (
         <ContextHubDetails
           reference={currentContextHubSnapshot}
-          projectPath={projectPath}
         />
       ) : null}
       {/* File edit preview */}

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

@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest"
+import { parseNovelChapters } from "./analysis-engine"
+
+describe("parseNovelChapters 章节识别(锚定行首,防幻影章节)", () => {
+  it("正文里的“第X章”跨章引用不会被误拆成章节", () => {
+    const text = [
+      "第一章 山间小村",
+      "",
+      "他想起前文第三章提到过的那件事。",
+      "这一章里他又提到了第四章的内容。",
+      "第二章 拜师",
+      "",
+      "正说话间,第五章的伏笔开始显现。",
+      "第三章 下山",
+      "",
+      "(正文完)",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(3)
+    expect(chapters.map((chapter) => chapter.title)).toEqual([
+      "第一章 山间小村",
+      "第二章 拜师",
+      "第三章 下山",
+    ])
+    expect(chapters.map((chapter) => chapter.order)).toEqual([1, 2, 3])
+  })
+
+  it("兼容“第X卷卷名+第X章标题”同行格式,标题不含卷名", () => {
+    const text = [
+      "第五卷名震一方第六百四十八章 至木灵婴",
+      "",
+      "这一卷的正文内容。",
+      "第六百四十九章 预兆",
+      "",
+      "又一章正文。",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(2)
+    expect(chapters[0].title).toBe("第六百四十八章 至木灵婴")
+    expect(chapters[1].title).toBe("第六百四十九章 预兆")
+    // 章节正文应从章标题开始截取,行首的卷名前缀不会混入正文
+    expect(chapters[0].content).not.toContain("第五卷名震一方")
+    expect(chapters[0].content).toContain("这一卷的正文内容")
+    expect(chapters[0].order).toBe(1)
+    expect(chapters[1].order).toBe(2)
+  })
+
+  it("阿拉伯数字与中文数字章节号混合均可识别", () => {
+    const text = [
+      "第1章 起始",
+      "正文一。",
+      "第二章 推进",
+      "正文二。",
+      "第103章 大转折",
+      "正文三。",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(3)
+    expect(chapters.map((chapter) => chapter.title)).toEqual([
+      "第1章 起始",
+      "第二章 推进",
+      "第103章 大转折",
+    ])
+  })
+
+  it("没有章节标记时抛出明确错误", () => {
+    expect(() => parseNovelChapters("这是一篇没有章节标题的小说正文。")).toThrow(
+      "未能识别到章节标记",
+    )
+  })
+})

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

@@ -34,7 +34,11 @@ export interface ParsedNovelChapter {
 }
 
 export function parseNovelChapters(content: string): ParsedNovelChapter[] {
-  const chapterRegex = /第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*/gi
+  // 锚定行首(m 多行模式),只把独立成行的“第X章”标题当作章节;
+  // 兼容“第X卷卷名+第X章标题”同行的格式(如“第五卷名震一方第六百四十八章”),
+  // 卷前缀仅作为行首锚定条件,章标题用捕获组1单独提取(避免标题混入卷名)。
+  // 避免把正文里“第X章提到过”这类跨章引用误拆成幻影章节。
+  const chapterRegex = /^(?:第[零〇一二三四五六七八九十百千万两0-9]+卷[^。!?!?\n]{0,32}?)?(第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*)/gim
   const matches = Array.from(content.matchAll(chapterRegex))
 
   if (matches.length === 0) {
@@ -42,10 +46,13 @@ export function parseNovelChapters(content: string): ParsedNovelChapter[] {
   }
 
   return matches.map((match, index) => {
-    const startIdx = match.index!
-    const endIdx = matches[index + 1]?.index ?? content.length
+    // 章节正文起点取章标题(捕获组1)的位置,行首卷前缀不会混入正文
+    const startIdx = match.index! + match[0].indexOf(match[1])
+    const endIdx = matches[index + 1]
+      ? matches[index + 1].index! + matches[index + 1][0].indexOf(matches[index + 1][1])
+      : content.length
     return {
-      title: match[0].trim(),
+      title: match[1].trim(),
       content: content.slice(startIdx, endIdx).trim(),
       order: index + 1,
     }

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

@@ -169,4 +169,57 @@ describe("story analysis adapter", () => {
       signal: new AbortController().signal,
     })).rejects.toThrow("旧版四段格式")
   })
+
+  it("AI 输出漏掉区块最后一章时明确报错,不静默保存不完整导图", async () => {
+    const allChapters = Array.from({ length: 10 }, (_, i) => {
+      const order = 21 + i
+      const id = `ch-${String(order).padStart(4, "0")}`
+      return { id, title: `第${order}章`, order, content: `第${order}章正文。` }
+    })
+    // AI 只输出前 9 章,漏掉 ch-0030(第 30 章),模拟输出截断丢最后一章
+    const mapJson = JSON.stringify({
+      mainLineLabel: "主线",
+      mainSummary: "摘要",
+      chapters: allChapters.slice(0, 9).map((chapter) => ({
+        id: chapter.id,
+        order: chapter.order,
+        title: chapter.title,
+        summary: "s",
+        mainEvents: [{ label: "e", beats: [], characters: [] }],
+      })),
+    })
+    const adapter = createStoryAnalysisAdapter({
+      callModel: vi.fn(async () => mapJson),
+      loadMetadata: vi.fn(async () => metadata),
+      loadChapters: vi.fn(async () => allChapters),
+      recognizeCharacters: vi.fn(async () => []),
+      now: () => 10,
+    })
+    const inputTask = task()
+    await expect(adapter.runChunk({
+      task: inputTask,
+      skill: "story",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunk: {
+        version: 1,
+        id: "chunk-0021-0030",
+        taskId: inputTask.id,
+        skill: "story",
+        chapterIds: allChapters.map((chapter) => chapter.id),
+        startOrder: 21,
+        endOrder: 30,
+        wordCount: 1000,
+        status: "running",
+        attempts: 1,
+        resultPath: null,
+        error: null,
+        startedAt: 1,
+        completedAt: null,
+        updatedAt: 1,
+      },
+      signal: new AbortController().signal,
+    })).rejects.toThrow("缺少第 30 章")
+  })
 })

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

@@ -216,6 +216,14 @@ export function createStoryAnalysisAdapter(
       if (!map) {
         throw new Error("故事导图提取失败:AI 输出缺少主线事件或章节结构,请重试该区块")
       }
+      // 校验导图是否覆盖了本区块全部章节:AI 输出若被截断会从末尾漏掉最后一章,
+      // 这里比对归一化后的章节 ID,缺失时明确报错而非静默保存不完整导图
+      const normalizeChapterId = (id: string): string => id.replace(/^ch-?0*(\d+)$/i, "ch-$1")
+      const mappedChapterIds = new Set(map.chapters.map((chapter) => normalizeChapterId(chapter.id)))
+      const missingChapters = chapters.filter((chapter) => !mappedChapterIds.has(normalizeChapterId(chapter.id)))
+      if (missingChapters.length > 0) {
+        throw new Error(`故事导图缺少第 ${missingChapters.map((chapter) => chapter.order).join("、")} 章,请重试该区块`)
+      }
       return {
         result: { map, rangeChapterIds: chunk.chapterIds },
         evidence: storyEvidence(task.id, task.bookId, chunk.id, chapters, dependencies.now()),

+ 71 - 1
src/lib/novel/book-analysis/story-map-history.spec.ts

@@ -1,7 +1,7 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { realFs, createTempProject, fileExists, readFileRaw } from "@/test-helpers/fs-temp"
 import type { StoryMap } from "./story-map-types"
-import { listStoryMapHistory, writeStoryMapFiles } from "./story-map-history"
+import { listStoryMapHistory, writeStoryMapFiles, deleteStoryMapHistory } from "./story-map-history"
 
 vi.mock("@/commands/fs", () => realFs)
 
@@ -96,4 +96,74 @@ describe("story map history storage", () => {
     expect(history).toHaveLength(1)
     expect(history[0].map.createdAt).toBe(100)
   })
+
+  it("deleteStoryMapHistory 删除指定历史目录", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+    await writeStoryMapFiles(dir, makeMap(200, "主线B", [2]))
+
+    await deleteStoryMapHistory(dir, "story-map-100")
+
+    const remaining = await listStoryMapHistory(dir)
+    expect(remaining.map((entry) => entry.map.createdAt)).toEqual([200])
+    expect(await fileExists(`${dir}/story-maps/story-map-100`)).toBe(false)
+  })
+
+  it("删除最新导图后,根目录引用同步为剩余历史的最新一份", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+    await writeStoryMapFiles(dir, makeMap(200, "主线B", [2]))
+
+    await deleteStoryMapHistory(dir, "story-map-200")
+
+    const latest = JSON.parse(await readFileRaw(`${dir}/story-map.json`)) as StoryMap
+    expect(latest.createdAt).toBe(100)
+    expect(latest.mainLineLabel).toBe("主线A")
+    expect(await fileExists(`${dir}/story-map.html`)).toBe(true)
+  })
+
+  it("删除全部历史导图后,根目录引用一并移除", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+
+    await deleteStoryMapHistory(dir, "story-map-100")
+
+    expect(await listStoryMapHistory(dir)).toEqual([])
+    expect(await fileExists(`${dir}/story-map.json`)).toBe(false)
+    expect(await fileExists(`${dir}/story-map.html`)).toBe(false)
+  })
+
+  it("deleteStoryMapHistory 拒绝不合法的目录名", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+
+    await expect(deleteStoryMapHistory(dir, "../story-map-100")).rejects.toThrow("目录名不合法")
+    await expect(deleteStoryMapHistory(dir, "characters")).rejects.toThrow("目录名不合法")
+    // 未发生误删
+    expect(await listStoryMapHistory(dir)).toHaveLength(1)
+  })
+
+  it("deleteStoryMapHistory 对不存在的目录静默跳过", async () => {
+    await expect(deleteStoryMapHistory(dir, "story-map-999")).resolves.toBeUndefined()
+    expect(await listStoryMapHistory(dir)).toEqual([])
+  })
+
+  it("传入 projectPath 时删除的导图目录移入回收站(.trash)而非永久删除", async () => {
+    await writeStoryMapFiles(dir, makeMap(100, "主线A", [1]))
+
+    await deleteStoryMapHistory(dir, "story-map-100", dir)
+
+    // 原目录被移除,历史列表中不再出现
+    expect(await fileExists(`${dir}/story-maps/story-map-100`)).toBe(false)
+    expect(await listStoryMapHistory(dir)).toEqual([])
+
+    // 回收站记录包含该导图目录,且内容被复制到 .trash/files
+    const items = JSON.parse(await readFileRaw(`${dir}/.trash/items.json`)) as Array<{
+      name: string
+      kind: string
+      trashPath: string
+      originalPath: string
+    }>
+    const storyItem = items.find((item) => item.kind === "storymap")
+    expect(storyItem).toBeTruthy()
+    expect(storyItem!.name).toBe("story-map-100")
+    expect(await fileExists(`${storyItem!.trashPath}/story-map.json`)).toBe(true)
+    expect(await fileExists(`${storyItem!.trashPath}/story-map.html`)).toBe(true)
+  })
 })

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

@@ -12,12 +12,14 @@
 
 import {
   createDirectory,
+  deleteFile,
   fileExists,
   listDirectory,
   readFile,
   writeFile,
 } from "@/commands/fs"
 import { joinPath, normalizePath } from "@/lib/path-utils"
+import { moveFileToTrash } from "@/lib/trash"
 import { renderStoryMapHtml } from "./story-map-renderer"
 import type { StoryMap } from "./story-map-types"
 
@@ -100,3 +102,43 @@ export async function listStoryMapHistory(bookPath: string): Promise<StoryMapHis
   result.sort((left, right) => left.map.createdAt - right.map.createdAt)
   return result
 }
+
+/** 历史导图目录名校验:仅允许本仓库生成的 story-map-<createdAt>[-<index>] 目录 */
+const STORY_MAP_DIR_PATTERN = /^story-map-\d+(-\d+)?$/
+
+/**
+ * 删除指定历史导图目录,并刷新根目录最新引用。
+ *
+ * @param bookPath 拆书库作品目录
+ * @param dirName 历史目录名(story-map-<createdAt>,会被校验)
+ * @param projectPath 可选的项目根目录;传入时删除的导图目录会移入应用内回收站(.trash)而非永久删除
+ */
+export async function deleteStoryMapHistory(bookPath: string, dirName: string, projectPath?: string): Promise<void> {
+  if (!STORY_MAP_DIR_PATTERN.test(dirName)) {
+    throw new Error("故事导图目录名不合法,已取消删除")
+  }
+  const dirPath = normalizePath(joinPath(historyRoot(bookPath), dirName))
+  if (!(await fileExists(dirPath))) return
+
+  if (projectPath) {
+    // 移入应用内回收站,可恢复
+    await moveFileToTrash(projectPath, dirPath, "storymap")
+  } else {
+    await deleteFile(dirPath)
+  }
+
+  // 刷新根目录最新引用
+  const remaining = await listStoryMapHistory(bookPath)
+  const latest = remaining[remaining.length - 1] // listStoryMapHistory 按生成时间升序
+  const rootJsonPath = normalizePath(joinPath(bookPath, "story-map.json"))
+  const rootHtmlPath = normalizePath(joinPath(bookPath, "story-map.html"))
+  if (latest) {
+    const json = await readFile(latest.jsonPath)
+    const html = await readFile(latest.htmlPath)
+    await writeFile(rootJsonPath, json)
+    await writeFile(rootHtmlPath, html)
+  } else {
+    if (await fileExists(rootJsonPath)) await deleteFile(rootJsonPath)
+    if (await fileExists(rootHtmlPath)) await deleteFile(rootHtmlPath)
+  }
+}

+ 53 - 16
src/lib/trash.ts

@@ -1,8 +1,16 @@
-import { createDirectory, deleteFile, fileExists, readFile, writeFile } from "@/commands/fs"
+import {
+  copyDirectory,
+  createDirectory,
+  deleteFile,
+  fileExists,
+  listDirectory,
+  readFile,
+  writeFile,
+} from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { makeChapterFileStem, makeSafeFileSlug } from "@/lib/wiki-filename"
 
-export type TrashItemKind = "chapter" | "outline" | "page" | "file" | "history"
+export type TrashItemKind = "chapter" | "outline" | "page" | "file" | "history" | "skill" | "storymap"
 
 export interface TrashItem {
   id: string
@@ -74,6 +82,16 @@ function makeTrashId(now: number): string {
   return `${formatDateTime(now)}-${Math.random().toString(36).slice(2, 8)}`
 }
 
+/** 判断路径是否为目录:list_directory 对目录成功(含空目录),对文件/不存在路径会失败 */
+async function isDirectory(path: string): Promise<boolean> {
+  try {
+    await listDirectory(path)
+    return true
+  } catch {
+    return false
+  }
+}
+
 function extractFrontmatterString(content: string, field: string): string | null {
   const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
   const match = content.match(new RegExp(`^${escapedField}:\\s*["']?(.+?)["']?\\s*$`, "m"))
@@ -156,19 +174,31 @@ export async function moveFileToTrash(
   const normalizedPath = normalizePath(filePath)
   const name = getBaseName(normalizedPath)
   const id = makeTrashId(now)
-  const trashPath = `${trashFilesDir(pp)}/${id}${getExtension(name)}`
-
-  // 尝试读取文件内容;如果文件不存在(损坏/幽灵条目),用空内容占位,
-  // 确保删除操作不会因为读不到文件而失败
-  let content: string
-  try {
-    content = await readFile(normalizedPath)
-  } catch {
-    content = ""
-  }
+  const isDir = await isDirectory(normalizedPath)
+  // 目录条目不带扩展名;文件条目保留原扩展名(便于恢复/预览)
+  const trashPath = isDir
+    ? `${trashFilesDir(pp)}/${id}`
+    : `${trashFilesDir(pp)}/${id}${getExtension(name)}`
 
   await ensureTrashDirs(pp)
-  await writeFile(trashPath, content)
+  if (isDir) {
+    // 整目录移入回收站(copy_directory 复制目录内容到目标目录)
+    try {
+      await copyDirectory(normalizedPath, trashPath)
+    } catch {
+      // 目录可能已不存在(幽灵条目),静默继续
+    }
+  } else {
+    // 尝试读取文件内容;如果文件不存在(损坏/幽灵条目),用空内容占位,
+    // 确保删除操作不会因为读不到文件而失败
+    let content: string
+    try {
+      content = await readFile(normalizedPath)
+    } catch {
+      content = ""
+    }
+    await writeFile(trashPath, content)
+  }
   const item: TrashItem = {
     id,
     name,
@@ -181,7 +211,7 @@ export async function moveFileToTrash(
   const items = await readTrashItems(pp)
   await writeTrashItems(pp, [item, ...items])
 
-  // 删除原文件,忽略不存在的情况(幽灵条目)
+  // 删除原路径(文件或目录),忽略不存在的情况(幽灵条目)
   try {
     await deleteFile(normalizedPath)
   } catch {
@@ -222,12 +252,18 @@ export async function restoreTrashItem(
   const items = await readTrashItems(pp)
   const item = items.find((candidate) => candidate.id === itemId)
   if (!item) throw new Error("回收站项目不存在")
-  const content = await readFile(item.trashPath)
+  const isDir = await isDirectory(item.trashPath)
+  const content = isDir ? "" : await readFile(item.trashPath)
   void now
   const restoreTarget = await resolveRestorePath(item, content)
   const dir = getDirName(restoreTarget.path)
   if (dir) await createDirectory(dir)
-  await writeFile(restoreTarget.path, content)
+  if (isDir) {
+    // 目录:整目录复制回原位置(copy_directory 复制内容到目标目录)
+    await copyDirectory(item.trashPath, restoreTarget.path)
+  } else {
+    await writeFile(restoreTarget.path, content)
+  }
   await deleteFile(item.trashPath)
   await writeTrashItems(pp, items.filter((candidate) => candidate.id !== itemId))
   return {
@@ -266,6 +302,7 @@ export function getTrashDaysRemaining(item: TrashItem, now = Date.now()): number
 }
 
 export async function readTrashItemContent(item: TrashItem): Promise<string> {
+  if (await isDirectory(item.trashPath)) return ""
   return await readFile(item.trashPath)
 }
 

+ 23 - 1
src/test-helpers/fs-temp.ts

@@ -69,11 +69,33 @@ export const realFs = {
     await fs.mkdir(path.dirname(destination), { recursive: true })
     await fs.copyFile(source, destination)
   },
+  copyDirectory: async (source: string, destination: string): Promise<string[]> => {
+    // 对齐真实 copy_directory 语义:把 source 目录内容复制到 destination(跳过点文件/点目录)
+    const copied: string[] = []
+    async function copyRecursive(src: string, dest: string): Promise<void> {
+      await fs.mkdir(dest, { recursive: true })
+      const entries = await fs.readdir(src, { withFileTypes: true })
+      for (const entry of entries) {
+        if (entry.name.startsWith(".")) continue
+        const srcPath = path.join(src, entry.name)
+        const destPath = path.join(dest, entry.name)
+        if (entry.isDirectory()) {
+          await copyRecursive(srcPath, destPath)
+        } else {
+          await fs.copyFile(srcPath, destPath)
+          copied.push(destPath.replace(/\\/g, "/"))
+        }
+      }
+    }
+    await copyRecursive(source, destination)
+    return copied
+  },
   preprocessFile: async (p: string): Promise<string> => {
     return fs.readFile(p, "utf-8")
   },
   deleteFile: async (p: string): Promise<void> => {
-    await fs.unlink(p).catch(() => {})
+    // 真实 delete_file 命令对文件与目录均支持递归删除;fs.rm 可同时覆盖两者
+    await fs.rm(p, { recursive: true, force: true }).catch(() => {})
   },
   findRelatedWikiPages: async (): Promise<string[]> => {
     return []

+ 43 - 0
zhangjietiquxiufu-分支说明.md

@@ -0,0 +1,43 @@
+# zhangjietiquxiufu 分支说明
+
+## 分支用途
+
+本分支为「章节提取修复」功能分支,修复拆书库/书库分析模块的章节识别与故事导图提取问题。
+
+## 修复背景
+
+用户反馈:拆书库提取章节范围 21~30 章时,结果只覆盖到 21~29 章;31~40 章只覆盖到 31~39 章,总是遗漏最后一章(1~10 章正常)。排查后确认两个根因:
+
+1. **章节识别正则未锚定行首**,会把正文里的「第X章」跨章引用误当成章节标题,产生幻影章节,导致章节序号与真实章节错位,范围末尾章节被切碎/合并,内容缺失。
+2. **故事导图解析不做章节数量校验**,AI 输出被截断漏掉最后一章时,不完整的导图会被静默保存,覆盖范围停留在 21~29 / 31~39。
+
+## 使用要求
+
+1. 本分支只修复「章节提取」相关问题,不混入其他功能改动。
+2. 章节识别(parseNovelChapters)只把独立成行的「第X章」标题当作章节;兼容「第X卷卷名+第X章标题」同行格式,卷名不混入章标题与正文。
+3. 故事导图提取后校验是否覆盖所选全部章节,缺失章节时明确报错提示重试,不再静默保存不完整导图。
+4. 本分支必须保持稳定、可打包。
+5. 修改完成后先跑源码相关测试,再跑旧功能回归测试,确认无回退后再考虑打包。
+
+## 更新记录
+
+- 2026-08-26:创建分支(从 main 派生),开始修复章节提取问题。
+- 2026-08-26:修复章节识别正则(src/lib/novel/book-analysis/analysis-engine.ts):
+  - 正则从 `第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*` 改为锚定行首 + 可选「第X卷」前缀:`^(?:第[零〇一二三四五六七八九十百千万两0-9]+卷[^。!?!?\n]{0,32}?)?(第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*)`(gim)。
+  - 章标题用捕获组单独提取,正文起点取章标题位置,行首卷名不混入标题与正文。
+  - 真实数据验证:《武炼巅峰》源文件幻影章节从 5962 → 5917(消除 45 个);《凡人修仙传》从 2621 → 2574(消除 47 个),21~40 范围章节完全正确。
+- 2026-08-26:故事导图增加章节覆盖校验(src/lib/novel/book-analysis/story-analysis-adapter.ts):
+  - parseStoryMapResult 返回后,比对导图章节 ID 与所选全部章节,缺失时抛出「故事导图缺少第 N 章,请重试该区块」,不再静默保存不完整导图。
+- 2026-08-26:新增回归测试:
+  - analysis-engine.spec.ts:正文跨章引用不误拆、卷章同行标题提取(不含卷名)、中英文数字章节混用、无章节标记报错,共 4 条。
+  - story-analysis-adapter.spec.ts:新增「AI 输出漏掉区块最后一章时明确报错」1 条。
+  - 清理指向用户本地文件的临时复现测试(repro-*.spec.ts,不提交)。
+- 2026-08-26:验证:
+  - typecheck 通过;
+  - book-analysis 目录 44 个测试文件 346 条用例全部通过;
+  - batch-import / dismantling / plot-framework 相关 82 条用例通过;
+  - 全量回归 533 文件通过,15 个失败文件均为既有历史基线问题(fast-check 缺失、window 未定义等),与本次改动无关,无新增回退。
+
+## 提交状态
+
+- 尚未提交(开发中,待用户确认后提交)。