Sfoglia il codice sorgente

优化技能库与章节批量移动交互

Mochocyang 3 mesi fa
parent
commit
8ba458a583

+ 43 - 0
jinengku-youhua-分支说明.md

@@ -0,0 +1,43 @@
+# 技能库优化分支说明
+
+## 使用要求
+
+- 本分支只处理技能库稳定性和第一版高风险 BUG。
+- 不改动 AI 模型配置、章节生成、文件同步、打包脚本等无关逻辑。
+- 技能库优化优先保证现有项目 Skill 不丢失、不被损坏配置覆盖。
+- 完成后需要运行技能库聚焦测试、全量 mock 测试、类型检查、构建和便携版打包。
+
+## 更新记录
+
+- 20260630:创建 `jinengku-youhua` 分支,开始修复技能库未保存保护、损坏配置、无项目保存、调用入口刷新和默认技能禁用关系。
+- 20260630:新增技能库边界回归测试,覆盖未保存保护、无项目保存、损坏配置、调用入口刷新和默认技能禁用关系。
+- 20260630:完成技能库稳定性修复,并通过聚焦测试、全量 mock 测试、类型检查、构建、源码运行冒烟验证和便携版打包。
+- 20260630:继续补强技能库配置恢复能力,保存前自动备份有效配置,损坏配置时支持从备份恢复或重新创建默认配置。
+- 20260630:优化技能库左右面板配置加载方式,左侧列表和右侧详情页共享同一次加载结果,减少重复读取项目配置。
+- 20260630:新增第二阶段回归测试,覆盖保存前备份、备份恢复、重新创建默认配置、共享加载和损坏配置恢复界面;已通过聚焦测试、全量 mock 测试、类型检查、构建、源码运行冒烟验证和便携版打包。
+- 20260630:继续优化调用入口一致性,聊天输入框和章节预览的去AI味 Skill 选择入口复用同一组选项列表、错误态和“已修改”标记。
+- 20260630:新增调用入口回归测试,覆盖配置损坏提示和共享列表展示;已通过技能库聚焦测试、全量 mock 测试、类型检查和构建;按用户要求本轮未打包。
+- 20260630:继续优化配置写入安全性,主配置保存、备份恢复和重新创建配置全部改用 `writeFileAtomic`,同一项目路径保存流程串行执行,避免并发写入交叉。
+- 20260630:新增配置写入回归测试,覆盖主配置原子写和同项目并发保存顺序;已通过技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包。
+- 20260630:继续优化 5 个内置去AI味 Skill 的实际内容,综合、减少解释腔、对话口语化、打破工整句式、保留文艺感均改为独立专用规则,不再共用同一份完整底稿。
+- 20260630:新增内置 Skill 内容差异回归测试,要求每个内置 Skill 都有专用规则章节,并限制内容前缀高度重复;已通过技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包。
+- 20260630:继续优化去AI味 Skill 调用入口,章节工具栏显示当前实际使用的 Skill,章节弹层和 AI 会话底部选择器标记“当前 / 默认 / 已修改”;AI 会话发送时如果去AI味配置损坏,会跳过本次 Skill 注入并显示中文警告,不再中断发送。
+- 20260630:新增章节入口和聊天发送安全加载回归测试;已通过章节/聊天聚焦测试、技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
+- 20260630:继续优化章节去AI味 Skill 调用体验,项目配置新增 `lastChapterDeAiSkillId`,章节入口会记住上次选择的 Skill;旧配置和旧备份恢复时会自动补齐新字段,保持向后兼容。
+- 20260630:AI 会话去AI味配置损坏提示改为独立显示在聊天输入区上方,不再复用章节保存状态;已通过章节/聊天/技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
+- 20260630:继续优化去AI味 Skill 选择体验,选择器支持外点关闭、Esc 关闭、上下键切换、Enter 选择,并为当前项增加视觉高亮和 `aria-current`。
+- 20260630:抽取 `useDeAiSkillOptions` 共享 Hook,统一章节入口和 AI 会话选择器的配置加载、当前项、默认项、已修改标记、错误提示和章节上次选择回退;章节记忆 Skill 写入失败改为弱提示,不再误导为正文处理失败。
+- 20260630:新增共享 Hook、选择器交互和章节弱提示回归测试;已通过聚焦测试、相关模块测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
+- 20260630:继续优化章节去AI味 Skill 细节,切换项目时重置章节本地 Skill 选择,记忆 Skill 失败弱提示会在后续处理状态中保留,选择器打开后自动聚焦列表;已通过聚焦测试、相关模块测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
+- 20260630:继续优化章节去AI味 Skill 弹层,支持点击外部关闭并清空待处理选中文本上下文;已通过章节面板聚焦测试、技能库/章节/聊天相关模块测试、类型检查、构建和源码运行冒烟验证;全量 mock 当前仍受 `src/lib/ingest-queue.integration.test.ts` 并行等待超时影响,单跑该文件通过;按用户要求本轮未打包、未提交。
+- 20260630:按用户要求先完成 Windows 本地便携版打包供测试使用,产物为 `release-portable/QMaiWrite.exe`,版本 2.2.30,大小 149,215,744 字节;本轮未提交,未上传 GitHub。
+- 20260630:按 B 方案升级 5 个内置去AI味 Skill 为专业编辑工作流,并修复章节顶部 Skill 弹层固定右上角、AI 会话底部 Skill 选择器受滚动工具栏裁切导致无法选择的问题;已通过相关回归测试、类型检查、构建和便携版打包,产物版本 2.2.30;本轮未提交,未上传 GitHub。
+- 20260630:将 AI 会话底部去AI味 Skill 入口改为仅显示图标,悬停显示当前 Skill 说明;章节顶部去AI味按钮不变;已通过相关回归测试、类型检查、构建和便携版打包,产物版本 2.2.30;本轮未提交,未上传 GitHub。
+- 20260630:将 AI 会话底部模型选择框改为固定窄宽度,长模型名在按钮内截断显示;下拉列表宽度保持可读,不影响模型选择。已通过模型选择框/聊天面板相关回归测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
+
+- 20260630:章节库新增多选移动到卷能力,支持勾选多个章节、自定义选择、全选章节后批量移动到指定卷;右键移动保留单章能力,并在右键章节属于勾选集合时批量移动勾选章节。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
+- 20260630:优化章节库批量移动交互,默认不再常驻显示复选框,改为点击“批量”后进入批量模式;批量模式下显示复选框、全选、已选数量、移动选中到卷和取消,移动完成后自动退出并清空选择。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
+- 20260630:将章节库“批量”入口从章节列表顶部移入右键菜单的“移动到卷”子菜单;默认状态不显示复选框,展开“移动到卷”后点击“批量”才进入批量模式。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
+## 提交状态
+
+- 未提交。

+ 14 - 0
src/components/chat/chat-model-selector.spec.tsx

@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "chat-model-selector.tsx"), "utf8")
+
+describe("chat model selector sizing", () => {
+  it("uses a narrow fixed trigger width with truncated model text", () => {
+    expect(source).toContain('className="h-8 w-32 justify-between gap-2 px-3 text-xs"')
+    expect(source).toContain('className="min-w-0 flex-1 truncate text-left"')
+    expect(source).not.toContain("min-w-[160px]")
+    expect(source).not.toContain("max-w-[200px]")
+  })
+})

+ 2 - 2
src/components/chat/chat-model-selector.tsx

@@ -136,9 +136,9 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
         variant="outline"
         onClick={() => !disabled && setOpen(!open)}
         disabled={disabled}
-        className="h-8 min-w-[160px] justify-between gap-2 px-3 text-xs"
+        className="h-8 w-32 justify-between gap-2 px-3 text-xs"
       >
-        <span className="max-w-[200px] truncate">
+        <span className="min-w-0 flex-1 truncate text-left">
           {selectedModel?.name ?? (value && value.trim() ? value : t("chat.selectModel"))}
         </span>
         <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />

+ 20 - 0
src/components/chat/chat-panel.spec.tsx

@@ -0,0 +1,20 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "chat-panel.tsx"), "utf8")
+
+describe("chat-panel de-AI skill handling", () => {
+  it("loads the chat de-AI skill safely and surfaces a warning without aborting send", () => {
+    expect(source).toContain("loadEffectiveDeAiSkillSafely")
+    expect(source).toContain("deAiSkillWarning")
+    expect(source).toContain("deAiSkillWarningMessage")
+    expect(source).toContain("setDeAiSkillWarningMessage(deAiSkillWarning)")
+    expect(source).not.toContain("setChapterSaveStatus(deAiSkillWarning)")
+  })
+
+  it("uses an icon-only de-AI skill trigger in the chat input toolbar", () => {
+    expect(source).toContain("<DeAiSkillPicker")
+    expect(source).toContain("iconOnly")
+  })
+})

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

@@ -20,7 +20,7 @@ import { readFile, writeFile, createDirectory, deleteFile } from "@/commands/fs"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
 import { detectLastGeneratedChapterNumber, findChapterFileByNumber, getNextChapterNumber, readSelectedChapterNumberForFile, resolveTargetChapterNumberForChat } from "@/lib/novel/chapter-utils"
 import { buildDeAiSkillSystemPrompt, buildQmQuaiSystemPrompt, injectDeAiDirective } from "@/lib/novel/de-ai-adapter"
-import { loadDeAiSkillConfig, resolveEffectiveDeAiSkill } from "@/lib/novel/de-ai-skill-library"
+import { loadEffectiveDeAiSkillSafely } from "@/lib/novel/de-ai-skill-library"
 import { cleanGeneratedChapterContentWithTitle } from "@/lib/novel/chapter-content-cleanup"
 import { normalizePath, getFileName, getRelativePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
@@ -222,6 +222,7 @@ export function ChatPanel() {
   const lastScrollTopRef = useRef(0)
 
   const [chapterSaveStatus, setChapterSaveStatus] = useState<string>("")
+  const [deAiSkillWarningMessage, setDeAiSkillWarningMessage] = useState<string>("")
   const [isSavingChapter, setIsSavingChapter] = useState(false)
   const [pendingSoulDialog, setPendingSoulDialog] = useState({ open: false, summary: "" })
   const deepChapterEnabled = useWikiStore((s) => s.deepChapterEnabled)
@@ -385,6 +386,7 @@ export function ChatPanel() {
   const handleSend = useCallback(
     async (text: string) => {
       // Auto-create a conversation if none is active
+      setDeAiSkillWarningMessage("")
       let convId = useChatStore.getState().activeConversationId
       if (!convId) {
         convId = createConversation()
@@ -1017,8 +1019,13 @@ export function ChatPanel() {
       const conversations = useChatStore.getState().conversations
       // 使用 capturedConvId 而非闭包中的 activeConversationId,防止切换会话后取错会话
       const activeConv = conversations.find(c => c.id === capturedConvId)
-      const skillConfig = await loadDeAiSkillConfig(project?.path ?? null)
-      const effectiveDeAiSkill = resolveEffectiveDeAiSkill(skillConfig, activeConv?.selectedDeAiSkillId)
+      const {
+        skill: effectiveDeAiSkill,
+        warning: deAiSkillWarning,
+      } = await loadEffectiveDeAiSkillSafely(project?.path ?? null, activeConv?.selectedDeAiSkillId)
+      if (deAiSkillWarning) {
+        setDeAiSkillWarningMessage(deAiSkillWarning)
+      }
       if (effectiveDeAiSkill) {
         systemMessages.push({
           role: "system",
@@ -1518,6 +1525,11 @@ export function ChatPanel() {
         )}
 
         <div className="shrink-0 bg-background">
+          {deAiSkillWarningMessage ? (
+            <div className="border-t border-amber-500/20 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
+              {deAiSkillWarningMessage}
+            </div>
+          ) : null}
           <ChatInput
             onSend={handleSend}
             onStop={handleStop}
@@ -1528,6 +1540,7 @@ export function ChatPanel() {
                   <ChatDockControls />
                   <DeAiSkillPicker
                     value={activeConversation?.selectedDeAiSkillId}
+                    iconOnly
                     onChange={(skillId) => {
                       const convId = useChatStore.getState().activeConversationId
                       if (convId) setConversationDeAiSkillId(convId, skillId)

+ 170 - 17
src/components/layout/knowledge-tree.tsx

@@ -280,6 +280,8 @@ export function KnowledgeTree({
   const [renameValue, setRenameValue] = useState("")
   const [renamingBusy, setRenamingBusy] = useState(false)
   const [moveMenuTarget, setMoveMenuTarget] = useState<string | null>(null)
+  const [chapterBatchMode, setChapterBatchMode] = useState(false)
+  const [selectedChapterPaths, setSelectedChapterPaths] = useState<Set<string>>(() => new Set())
   const [dragSource, setDragSource] = useState<string | null>(null)
   const [dragInsertIndex, setDragInsertIndex] = useState<number | null>(null)
   const [isDragging, setIsDragging] = useState(false)
@@ -383,6 +385,28 @@ export function KnowledgeTree({
       })
   }, [effectivePages])
 
+  const selectableChapterPaths = useMemo(() => sortedChapterPages.map((page) => page.path), [sortedChapterPages])
+  const selectedChapterCount = selectedChapterPaths.size
+  const allChaptersSelected = selectableChapterPaths.length > 0
+    && selectableChapterPaths.every((path) => selectedChapterPaths.has(path))
+
+  useEffect(() => {
+    if (filterType !== "chapter") {
+      setChapterBatchMode(false)
+      setSelectedChapterPaths((previous) => previous.size > 0 ? new Set() : previous)
+      return
+    }
+
+    const validPaths = new Set(selectableChapterPaths)
+    setSelectedChapterPaths((previous) => {
+      const next = new Set<string>()
+      for (const path of previous) {
+        if (validPaths.has(path)) next.add(path)
+      }
+      return next.size === previous.size ? previous : next
+    })
+  }, [filterType, selectableChapterPaths])
+
   const sectionRootPath = useMemo(() => {
     if (!project) return null
     return `${normalizePath(project.path)}/wiki/${filterType === "chapter" ? "chapters" : "outlines"}`
@@ -401,30 +425,51 @@ export function KnowledgeTree({
     }))
   }, [sectionNodes])
 
-  const handleMoveToVolume = useCallback(async (sourcePath: string, targetVolumePath: string) => {
+  const handleMoveChaptersToVolume = useCallback(async (sourcePaths: string[], targetVolumePath: string) => {
     if (!project) return
-    const normalizedSource = normalizePath(sourcePath)
-    const fileName = normalizedSource.split("/").pop()
-    if (!fileName) return
+    const normalizedTargetVolumePath = normalizePath(targetVolumePath)
+    const normalizedSources = Array.from(new Set(sourcePaths.map((sourcePath) => normalizePath(sourcePath))))
+    const plans: Array<{ source: string; dest: string }> = []
+    const plannedDestinations = new Set<string>()
+
+    for (const normalizedSource of normalizedSources) {
+      if (getDirName(normalizedSource) === normalizedTargetVolumePath) continue
+      const fileName = normalizedSource.split("/").pop()
+      if (!fileName) continue
+
+      const destPath = `${normalizedTargetVolumePath}/${fileName}`
+      if (normalizedSource === destPath) continue
+
+      if (plannedDestinations.has(destPath) || await fileExists(destPath)) {
+        window.alert(t("knowledgeTree.moveTargetExists"))
+        return
+      }
 
-    const destPath = `${targetVolumePath}/${fileName}`
-    if (normalizedSource === destPath) return
+      plannedDestinations.add(destPath)
+      plans.push({ source: normalizedSource, dest: destPath })
+    }
 
-    if (await fileExists(destPath)) {
-      window.alert(t("knowledgeTree.moveTargetExists"))
+    if (plans.length === 0) {
+      setMoveMenuTarget(null)
+      setPageMenu(null)
       return
     }
 
     try {
-      await copyFile(normalizedSource, destPath)
-      await deleteFile(normalizedSource)
+      for (const plan of plans) {
+        await copyFile(plan.source, plan.dest)
+        await deleteFile(plan.source)
+      }
 
       await loadPages()
       const tree = await listDirectory(normalizePath(project.path))
       setFileTree(tree)
       bumpDataVersion()
-      if (selectedFile === normalizedSource) {
-        setSelectedFile(destPath)
+      setSelectedChapterPaths(new Set())
+      setChapterBatchMode(false)
+      const selectedMove = selectedFile ? plans.find((plan) => plan.source === normalizePath(selectedFile)) : undefined
+      if (selectedMove) {
+        setSelectedFile(selectedMove.dest)
       }
     } catch (error) {
       console.error("[KnowledgeTree] move to volume failed:", error)
@@ -434,6 +479,30 @@ export function KnowledgeTree({
     }
   }, [project, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
 
+  const toggleChapterSelection = useCallback((pagePath: string) => {
+    const normalizedPath = normalizePath(pagePath)
+    setSelectedChapterPaths((previous) => {
+      const next = new Set(previous)
+      if (next.has(normalizedPath)) next.delete(normalizedPath)
+      else next.add(normalizedPath)
+      return next
+    })
+  }, [])
+
+  const handleToggleAllChapters = useCallback(() => {
+    setSelectedChapterPaths((previous) => {
+      if (selectableChapterPaths.length > 0 && selectableChapterPaths.every((path) => previous.has(path))) {
+        return new Set()
+      }
+      return new Set(selectableChapterPaths)
+    })
+  }, [selectableChapterPaths])
+
+  const exitChapterBatchMode = useCallback(() => {
+    setChapterBatchMode(false)
+    setSelectedChapterPaths(new Set())
+  }, [])
+
   const handleDeleteClick = useCallback(async (pagePath: string) => {
     if (!project) return
     if (armedPath !== pagePath) {
@@ -960,6 +1029,7 @@ export function KnowledgeTree({
         tags: [],
       }
       const isSelected = selectedFile === normalizedPath
+      const isChapterChecked = filterType === "chapter" && selectedChapterPaths.has(normalizedPath)
       const isArmed = armedPath === normalizedPath
       const isDeleting = deletingPath === normalizedPath
       const isDragSource = dragSource === normalizedPath
@@ -970,7 +1040,7 @@ export function KnowledgeTree({
           key={normalizedPath}
           data-knowledge-interactive="true"
           data-page-path={normalizedPath}
-          className={`group flex items-center gap-1 rounded-md ${isSelected ? "qm-selected" : "qm-hover"} ${isDragSource ? "ring-2 ring-primary/50" : ""} ${isInsertTarget ? "border-t-[3px] border-primary/70" : ""}`}
+          className={`group flex items-center gap-1 rounded-md ${isSelected ? "qm-selected" : "qm-hover"} ${isChapterChecked ? "ring-1 ring-primary/35" : ""} ${isDragSource ? "ring-2 ring-primary/50" : ""} ${isInsertTarget ? "border-t-[3px] border-primary/70" : ""}`}
           onContextMenu={(event) => handlePageContextMenu(event, normalizedPath)}
           onPointerDown={(event) => handleItemPointerDown(event, normalizedPath)}
           style={{
@@ -980,6 +1050,20 @@ export function KnowledgeTree({
             WebkitTouchCallout: filterType === "chapter" ? "none" : undefined,
           }}
         >
+          {filterType === "chapter" && chapterBatchMode && (
+            <input
+              type="checkbox"
+              checked={isChapterChecked}
+              aria-label={t("knowledgeTree.selectChapter", { title: page.title })}
+              className="ml-1 h-3.5 w-3.5 shrink-0 accent-primary"
+              onPointerDown={(event) => event.stopPropagation()}
+              onClick={(event) => event.stopPropagation()}
+              onChange={(event) => {
+                event.stopPropagation()
+                toggleChapterSelection(normalizedPath)
+              }}
+            />
+          )}
           <button
             type="button"
             onClick={() => handlePageClick(normalizedPath)}
@@ -1052,10 +1136,14 @@ export function KnowledgeTree({
     handleItemPointerDown,
     handlePageContextMenu,
     handlePageClick,
+    chapterBatchMode,
+    selectedChapterPaths,
+    toggleChapterSelection,
     dragSource,
     dragInsertIndex,
     isDragging,
     sortedChapterPages,
+    t,
   ])
 
   if (!project) {
@@ -1083,7 +1171,57 @@ export function KnowledgeTree({
         onContextMenu={handleBlankContextMenu}
         onPointerMove={handleContainerPointerMove}
       >
-        <div className="mb-2 px-2 text-xs font-semibold uppercase text-muted-foreground">{rootLabel}</div>
+        <div className="mb-2 flex items-center justify-between gap-2 px-2 text-xs font-semibold uppercase text-muted-foreground">
+          <span>{rootLabel}</span>
+        </div>
+        {filterType === "chapter" && chapterBatchMode && selectableChapterPaths.length > 0 && (
+          <div className="mb-2 flex items-center gap-2 px-2 text-xs">
+            <button
+              type="button"
+              className="rounded border px-2 py-1 hover:bg-accent"
+              onClick={(event) => {
+                event.stopPropagation()
+                handleToggleAllChapters()
+              }}
+            >
+              {allChaptersSelected ? t("knowledgeTree.clearChapterSelection") : t("knowledgeTree.selectAllChapters")}
+            </button>
+            <span className="min-w-0 flex-1 truncate text-muted-foreground">
+              {selectedChapterCount > 0
+                ? t("knowledgeTree.selectedChapters", { count: selectedChapterCount })
+                : t("knowledgeTree.selectChaptersHint")}
+            </span>
+            {selectedChapterCount > 0 && volumeFolders.length > 0 && (
+              <select
+                aria-label={t("knowledgeTree.moveSelectedToVolume")}
+                value=""
+                className="h-7 max-w-[120px] rounded border bg-background px-2 text-xs"
+                onClick={(event) => event.stopPropagation()}
+                onChange={(event) => {
+                  const targetVolumePath = event.target.value
+                  if (targetVolumePath) {
+                    void handleMoveChaptersToVolume(Array.from(selectedChapterPaths), targetVolumePath)
+                  }
+                }}
+              >
+                <option value="">{t("knowledgeTree.moveSelectedToVolume")}</option>
+                {volumeFolders.map((vol) => (
+                  <option key={vol.path} value={vol.path}>{vol.name}</option>
+                ))}
+              </select>
+            )}
+            <button
+              type="button"
+              className="rounded border px-2 py-1 hover:bg-accent"
+              onClick={(event) => {
+                event.stopPropagation()
+                exitChapterBatchMode()
+              }}
+            >
+              {t("knowledgeTree.exitBatchMode")}
+            </button>
+          </div>
+        )}
         {sectionNodes.length === 0 && pendingPages.length === 0 ? (
           <div className="px-2 py-4 text-center text-xs text-muted-foreground">{emptyLabel}</div>
         ) : (
@@ -1196,9 +1334,24 @@ export function KnowledgeTree({
                 </button>
                 {moveMenuTarget === pageMenu.path && (
                   <div className="max-h-48 overflow-y-auto border-t bg-background py-1 text-xs">
+                    <button
+                      type="button"
+                      className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                      onClick={() => {
+                        setChapterBatchMode(true)
+                        setSelectedChapterPaths(new Set())
+                        setMoveMenuTarget(null)
+                        setPageMenu(null)
+                      }}
+                    >
+                      <Check className="h-3 w-3 shrink-0 text-muted-foreground" />
+                      <span>{t("knowledgeTree.batchChapters")}</span>
+                    </button>
                     {volumeFolders.map((vol) => {
-                      const sourceDir = getDirName(pageMenu.path)
-                      const isCurrentVolume = sourceDir === vol.path
+                      const sourcePaths = selectedChapterPaths.has(pageMenu.path)
+                        ? Array.from(selectedChapterPaths)
+                        : [pageMenu.path]
+                      const isCurrentVolume = sourcePaths.every((sourcePath) => getDirName(sourcePath) === vol.path)
                       return (
                         <button
                           key={vol.path}
@@ -1207,7 +1360,7 @@ export function KnowledgeTree({
                           disabled={isCurrentVolume}
                           onClick={() => {
                             if (!isCurrentVolume) {
-                              void handleMoveToVolume(pageMenu.path, vol.path)
+                              void handleMoveChaptersToVolume(sourcePaths, vol.path)
                             }
                           }}
                         >

+ 124 - 70
src/components/layout/preview-panel.tsx

@@ -1,4 +1,4 @@
-import { Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
+import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -21,13 +21,14 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { getNextChatExpanded } from "./chat-layout"
 import { DeAiPreviewDialog } from "@/components/novel/de-ai-preview-dialog"
 import { TextTransformPreviewDialog } from "@/components/novel/text-transform-preview-dialog"
+import { DeAiSkillOptionsPanel } from "@/components/skill-library/de-ai-skill-picker"
+import { useDeAiSkillOptions } from "@/components/skill-library/use-de-ai-skill-options"
 import { buildDeAiRewriteMessages } from "@/lib/novel/de-ai-adapter"
 import {
-  isDeAiSkillModified,
   loadDeAiSkillConfig,
-  resolveAvailableDeAiSkills,
   resolveEffectiveDeAiSkill,
-  type DeAiSkill,
+  saveDeAiSkillConfig,
+  setLastChapterDeAiSkill,
 } from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { streamChat } from "@/lib/llm-client"
@@ -158,6 +159,22 @@ function getChapterTitleFromPath(path: string): string {
 const CHAPTER_TITLE_MIN_WIDTH_PX = 48
 const CHAPTER_TITLE_RESTING_EXTRA_WIDTH_PX = 2
 const CHAPTER_TITLE_EDITING_EXTRA_WIDTH_PX = 16
+const DE_AI_SKILL_PICKER_WIDTH_PX = 288
+
+function getDeAiSkillPickerPosition(anchor?: HTMLElement | null): CSSProperties {
+  if (!anchor) return { right: 24, top: 80 }
+  const rect = anchor.getBoundingClientRect()
+  const gap = 8
+  const viewportWidth = window.innerWidth || DE_AI_SKILL_PICKER_WIDTH_PX
+  const left = Math.min(
+    Math.max(rect.left, gap),
+    Math.max(gap, viewportWidth - DE_AI_SKILL_PICKER_WIDTH_PX - gap),
+  )
+  return {
+    left,
+    top: rect.bottom + gap,
+  }
+}
 
 export function PreviewPanel() {
   const { t } = useTranslation()
@@ -191,6 +208,7 @@ export function PreviewPanel() {
   const [deAiSourceContent, setDeAiSourceContent] = useState("")
   const [deAiCandidateContent, setDeAiCandidateContent] = useState("")
   const [deAiSkillName, setDeAiSkillName] = useState("")
+  const [deAiSkillMemoryWarning, setDeAiSkillMemoryWarning] = useState("")
   const [selectionTransformOpen, setSelectionTransformOpen] = useState(false)
   const [selectionTransformAction, setSelectionTransformAction] = useState<ChapterSelectionAction | null>(null)
   const [selectionTransformSelection, setSelectionTransformSelection] = useState<ChapterBodySelection | null>(null)
@@ -198,9 +216,8 @@ export function PreviewPanel() {
   const [selectionTransformCandidateContent, setSelectionTransformCandidateContent] = useState("")
   const [selectionTransformSkillName, setSelectionTransformSkillName] = useState("")
   const [deAiSkillPickerOpen, setDeAiSkillPickerOpen] = useState(false)
-  const [deAiSkillPickerLoading, setDeAiSkillPickerLoading] = useState(false)
-  const [deAiSkillPickerSkills, setDeAiSkillPickerSkills] = useState<DeAiSkill[]>([])
-  const [deAiSkillPickerModifiedIds, setDeAiSkillPickerModifiedIds] = useState<string[]>([])
+  const [deAiSkillPickerPosition, setDeAiSkillPickerPosition] = useState<CSSProperties>(() => getDeAiSkillPickerPosition())
+  const [chapterDeAiSkillId, setChapterDeAiSkillId] = useState<string | null | undefined>(undefined)
   const [pendingSelectionForDeAi, setPendingSelectionForDeAi] = useState<ChapterBodySelection | null>(null)
   const [chapterTitleDraft, setChapterTitleDraft] = useState("")
   const [chapterTitleEditing, setChapterTitleEditing] = useState(false)
@@ -216,13 +233,45 @@ export function PreviewPanel() {
   const lastLoadedRef = useRef<string>("")
   const fileContentRef = useRef(fileContent)
   const selectedFileRef = useRef<string | null>(selectedFile)
+  const deAiSkillMemoryWarningRef = useRef("")
+  const deAiSkillPickerRef = useRef<HTMLDivElement | null>(null)
   const chapterToolbarRef = useRef<HTMLDivElement | null>(null)
   const titleMeasureRef = useRef<HTMLSpanElement | null>(null)
+  const chapterDeAiOptions = useDeAiSkillOptions({
+    projectPath: project?.path,
+    selectedSkillId: chapterDeAiSkillId,
+    useLastChapterSkill: true,
+  })
 
   useEffect(() => {
     fileContentRef.current = fileContent
   }, [fileContent])
 
+  useEffect(() => {
+    setChapterDeAiSkillId(undefined)
+    deAiSkillMemoryWarningRef.current = ""
+    setDeAiSkillMemoryWarning("")
+  }, [project?.path])
+
+  const formatDeAiStatus = useCallback((status: string) => {
+    const warning = deAiSkillMemoryWarningRef.current || deAiSkillMemoryWarning
+    if (!status) return warning
+    return warning ? `${warning};${status}` : status
+  }, [deAiSkillMemoryWarning])
+
+  useEffect(() => {
+    if (!deAiSkillPickerOpen) return
+    const handleDeAiSkillPickerMouseDown = (event: MouseEvent) => {
+      if (deAiSkillPickerRef.current?.contains(event.target as Node)) return
+      setDeAiSkillPickerOpen(false)
+      setPendingSelectionForDeAi(null)
+    }
+    document.addEventListener("mousedown", handleDeAiSkillPickerMouseDown)
+    return () => {
+      document.removeEventListener("mousedown", handleDeAiSkillPickerMouseDown)
+    }
+  }, [deAiSkillPickerOpen])
+
   const syncChapterToCanonicalPath = useCallback(async (path: string, markdown: string) => {
     const normalized = normalizeChapterWriting(markdown)
     const chapterNumber = extractChapterNumberFromMarkdown(normalized)
@@ -492,6 +541,9 @@ export function PreviewPanel() {
       {chapterWordCountMeta}
     </div>
   ) : null
+  const chapterDeAiSkillName = chapterHeader ? chapterDeAiOptions.effectiveName : "未启用"
+  const chapterDeAiButtonLabel = deAiProcessing ? "处理中" : `去AI味:${chapterDeAiSkillName}`
+  const chapterDeAiButtonTitle = `当前去AI味 Skill:${chapterDeAiSkillName}`
 
   useEffect(() => {
     if (!chapterHeader) {
@@ -777,7 +829,7 @@ export function PreviewPanel() {
       setSaveStatus("未配置可用的 AI 模型,无法去AI味")
       return
     }
-    setSaveStatus(`去AI味处理中,使用 Skill:${skillName}...`)
+    setSaveStatus(formatDeAiStatus(`去AI味处理中,使用 Skill:${skillName}...`))
     const source = fileContent
     let result = ""
     try {
@@ -793,7 +845,7 @@ export function PreviewPanel() {
             setDeAiCandidateContent(result)
             setDeAiPreviewOpen(true)
             setDeAiProcessing(false)
-            setSaveStatus(`本次使用 Skill:${skillName}`)
+            setSaveStatus(formatDeAiStatus(`本次使用 Skill:${skillName}`))
           },
           onError: (error) => {
             console.error("去AI味处理失败:", error)
@@ -805,7 +857,7 @@ export function PreviewPanel() {
       console.error("去AI味处理失败:", err)
       setDeAiProcessing(false)
     }
-  }, [fileContent])
+  }, [fileContent, formatDeAiStatus])
 
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
@@ -853,9 +905,10 @@ export function PreviewPanel() {
     const actionFile = selectedFileRef.current
     const actionLabel = action === "polish" ? "AI润色" : "去AI味"
     setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
-    setSaveStatus(action === "de-ai" && skillName
+    const transformStatus = action === "de-ai" && skillName
       ? `${actionLabel}处理中,使用 Skill:${skillName}...`
-      : `${actionLabel}处理中...`)
+      : `${actionLabel}处理中...`
+    setSaveStatus(action === "de-ai" ? formatDeAiStatus(transformStatus) : transformStatus)
 
     let result = ""
     try {
@@ -876,7 +929,7 @@ export function PreviewPanel() {
             setSelectionTransformCandidateContent(result)
             setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
             setSelectionTransformOpen(true)
-            setSaveStatus("")
+            setSaveStatus(formatDeAiStatus(""))
           },
           onError: (error) => {
             if (selectedFileRef.current !== actionFile) return
@@ -891,38 +944,32 @@ export function PreviewPanel() {
       console.error(`${actionLabel}失败:`, err)
       setSaveStatus(`${actionLabel}失败:${message}`)
     }
-  }, [])
+  }, [formatDeAiStatus])
 
-  const openDeAiSkillPicker = useCallback(async (selection: ChapterBodySelection | null) => {
+  const openDeAiSkillPicker = useCallback((selection: ChapterBodySelection | null, anchor?: HTMLElement | null) => {
     if (deAiProcessing) return
     setPendingSelectionForDeAi(selection)
+    setDeAiSkillPickerPosition(getDeAiSkillPickerPosition(anchor))
     setDeAiSkillPickerOpen(true)
-    setDeAiSkillPickerLoading(true)
-    setSaveStatus("")
-    try {
-      const config = await loadDeAiSkillConfig(project?.path ?? null)
-      const skills = resolveAvailableDeAiSkills(config)
-      setDeAiSkillPickerSkills(skills)
-      setDeAiSkillPickerModifiedIds(skills.filter((skill) => isDeAiSkillModified(config, skill.id)).map((skill) => skill.id))
-      if (skills.length === 0) {
-        setSaveStatus("暂无可用去AI味技能")
-      }
-    } catch (err) {
-      console.error("读取去AI味技能失败:", err)
-      setDeAiSkillPickerSkills([])
-      setDeAiSkillPickerModifiedIds([])
-      setSaveStatus("读取去AI味技能失败")
-    } finally {
-      setDeAiSkillPickerLoading(false)
+    deAiSkillMemoryWarningRef.current = ""
+    setDeAiSkillMemoryWarning("")
+    if (chapterDeAiOptions.loadError) {
+      setSaveStatus(chapterDeAiOptions.loadError)
+      return
     }
-  }, [deAiProcessing, project?.path])
+    if (!chapterDeAiOptions.loading && chapterDeAiOptions.skills.length === 0) {
+      setSaveStatus("暂无可用去AI味技能")
+      return
+    }
+    setSaveStatus("")
+  }, [chapterDeAiOptions.loadError, chapterDeAiOptions.loading, chapterDeAiOptions.skills.length, deAiProcessing])
 
   const handlePickedDeAiSkill = useCallback(async (skillId: string) => {
     const selection = pendingSelectionForDeAi
     setDeAiSkillPickerOpen(false)
     setPendingSelectionForDeAi(null)
 
-    let skill = deAiSkillPickerSkills.find((item) => item.id === skillId) ?? null
+    let skill = chapterDeAiOptions.skills.find((item) => item.id === skillId) ?? null
     if (!skill) {
       const config = await loadDeAiSkillConfig(project?.path ?? null)
       skill = resolveEffectiveDeAiSkill(config, skillId)
@@ -931,13 +978,28 @@ export function PreviewPanel() {
       setSaveStatus("暂无可用去AI味技能")
       return
     }
+    setChapterDeAiSkillId(skill.id)
+    if (project) {
+      try {
+        const config = await loadDeAiSkillConfig(project.path)
+        await saveDeAiSkillConfig(project.path, setLastChapterDeAiSkill(config, skill.id))
+        deAiSkillMemoryWarningRef.current = ""
+        setDeAiSkillMemoryWarning("")
+        bumpDataVersion()
+      } catch (err) {
+        console.error("保存章节去AI味 Skill 选择失败:", err)
+        deAiSkillMemoryWarningRef.current = "未能记住本次去AI味 Skill 选择,本次处理仍会继续"
+        setDeAiSkillMemoryWarning("未能记住本次去AI味 Skill 选择,本次处理仍会继续")
+        setSaveStatus("未能记住本次去AI味 Skill 选择,本次处理仍会继续")
+      }
+    }
 
     if (selection) {
       await runSelectionTransform("de-ai", selection, skill.content, skill.name)
       return
     }
     await runWholeChapterDeAi(skill.content, skill.name)
-  }, [deAiSkillPickerSkills, pendingSelectionForDeAi, project?.path, runSelectionTransform, runWholeChapterDeAi])
+  }, [bumpDataVersion, chapterDeAiOptions.skills, pendingSelectionForDeAi, project, runSelectionTransform, runWholeChapterDeAi])
 
   const handleSelectionAction = useCallback((action: ChapterSelectionAction, selection: ChapterBodySelection) => {
     if (action === "de-ai") {
@@ -1141,14 +1203,15 @@ export function PreviewPanel() {
                   {chapterHeader ? (
                     <button
                       type="button"
-                      onClick={() => {
+                      onClick={(e) => {
                         setChapterToolbarMoreOpen(false)
-                        void openDeAiSkillPicker(null)
+                        void openDeAiSkillPicker(null, e.currentTarget)
                       }}
                       disabled={deAiProcessing}
                       className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+                      title={chapterDeAiButtonTitle}
                     >
-                      {deAiProcessing ? "处理中" : "去AI味"}
+                      <span className="block truncate">{chapterDeAiButtonLabel}</span>
                     </button>
                   ) : null}
                   {canIngestOutline ? (
@@ -1258,11 +1321,12 @@ export function PreviewPanel() {
             <div className="relative shrink-0">
               <button
                 type="button"
-                onClick={() => void openDeAiSkillPicker(null)}
+                onClick={(e) => void openDeAiSkillPicker(null, e.currentTarget)}
                 disabled={deAiProcessing}
-                className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+                className="max-w-[11rem] shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+                title={chapterDeAiButtonTitle}
               >
-                {deAiProcessing ? "处理中" : "去AI味"}
+                <span className="block truncate">{chapterDeAiButtonLabel}</span>
               </button>
             </div>
           ) : null}
@@ -1408,7 +1472,11 @@ export function PreviewPanel() {
         </div>
       ) : null}
       {deAiSkillPickerOpen ? (
-        <div className="fixed right-6 top-20 z-50 w-72 rounded-md border bg-popover p-2 text-sm text-popover-foreground shadow-lg">
+        <div
+          ref={deAiSkillPickerRef}
+          className="fixed z-50 w-72 rounded-md border bg-popover p-2 text-sm text-popover-foreground shadow-lg"
+          style={deAiSkillPickerPosition}
+        >
           <div className="mb-1 flex items-center justify-between gap-2 px-1">
             <div className="truncate text-sm font-medium">
               {pendingSelectionForDeAi ? "选择选中文本去AI味技能" : "选择去AI味技能"}
@@ -1426,34 +1494,20 @@ export function PreviewPanel() {
               <X className="h-3.5 w-3.5" />
             </button>
           </div>
-          {deAiSkillPickerLoading ? (
-            <div className="px-2 py-3 text-xs text-muted-foreground">正在读取技能...</div>
-          ) : deAiSkillPickerSkills.length === 0 ? (
-            <div className="px-2 py-3 text-xs text-muted-foreground">暂无可用去AI味技能</div>
-          ) : (
-            <div className="max-h-72 overflow-y-auto">
-              {deAiSkillPickerSkills.map((skill) => (
-                <button
-                  key={skill.id}
-                  type="button"
-                  className="block w-full rounded px-2 py-2 text-left hover:bg-accent"
-                  onClick={() => void handlePickedDeAiSkill(skill.id)}
-                >
-                  <div className="flex items-center gap-2">
-                    <span className="min-w-0 flex-1 truncate text-sm">{skill.name}</span>
-                    {deAiSkillPickerModifiedIds.includes(skill.id) ? (
-                      <span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800">
-                        已修改
-                      </span>
-                    ) : null}
-                  </div>
-                  {skill.description ? (
-                    <div className="mt-0.5 truncate text-xs text-muted-foreground">{skill.description}</div>
-                  ) : null}
-                </button>
-              ))}
-            </div>
-          )}
+          <DeAiSkillOptionsPanel
+            loading={chapterDeAiOptions.loading}
+            errorMessage={chapterDeAiOptions.loadError}
+            emptyMessage="暂无可用去AI味技能"
+            skills={chapterDeAiOptions.skills}
+            currentSkillId={chapterDeAiOptions.currentSkillId}
+            defaultSkillId={chapterDeAiOptions.defaultSkillId}
+            modifiedSkillIds={chapterDeAiOptions.modifiedSkillIds}
+            onClose={() => {
+              setDeAiSkillPickerOpen(false)
+              setPendingSelectionForDeAi(null)
+            }}
+            onPick={(skillId) => void handlePickedDeAiSkill(skillId)}
+          />
         </div>
       ) : null}
       <DeAiPreviewDialog

+ 10 - 0
src/components/skill-library/de-ai-skill-errors.ts

@@ -0,0 +1,10 @@
+import { isDeAiSkillConfigCorruptError } from "@/lib/novel/de-ai-skill-library"
+
+export const DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE = "技能库配置文件损坏,请到技能库恢复配置"
+export const DE_AI_SKILL_LOAD_FAILED_MESSAGE = "读取去AI味技能失败"
+
+export function getDeAiSkillLoadErrorMessage(error: unknown): string {
+  return isDeAiSkillConfigCorruptError(error)
+    ? DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE
+    : DE_AI_SKILL_LOAD_FAILED_MESSAGE
+}

+ 256 - 5
src/components/skill-library/de-ai-skill-picker.spec.tsx

@@ -1,10 +1,11 @@
 // @vitest-environment jsdom
 
-import { act } from "react"
+import { act, type ComponentProps } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { beforeEach, describe, expect, it, vi } from "vitest"
 import { useWikiStore } from "@/stores/wiki-store"
-import { DeAiSkillPicker } from "./de-ai-skill-picker"
+import { DeAiSkillOptionsPanel, DeAiSkillPicker } from "./de-ai-skill-picker"
+import { useDeAiSkillOptions } from "./use-de-ai-skill-options"
 
 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
 
@@ -20,14 +21,37 @@ vi.mock("@tauri-apps/api/path", () => ({
   join: joinMock,
 }))
 
-async function renderPicker() {
+async function renderPicker(onChange = vi.fn(), props: Partial<ComponentProps<typeof DeAiSkillPicker>> = {}) {
   const container = document.createElement("div")
   document.body.appendChild(container)
   const root = createRoot(container)
   await act(async () => {
-    root.render(<DeAiSkillPicker value="built-in:comprehensive" onChange={vi.fn()} />)
+    root.render(<DeAiSkillPicker value="built-in:comprehensive" onChange={onChange} {...props} />)
+  })
+  return { container, root, onChange }
+}
+
+function SkillOptionsProbe() {
+  const options = useDeAiSkillOptions({
+    projectPath: "C:/project",
+    selectedSkillId: undefined,
+    useLastChapterSkill: true,
+  })
+  return (
+    <div>
+      <span data-testid="effective-name">{options.effectiveName}</span>
+      <span data-testid="current-id">{options.currentSkillId}</span>
+      <span data-testid="default-id">{options.defaultSkillId}</span>
+      <span data-testid="modified-count">{options.modifiedSkillIds.length}</span>
+    </div>
+  )
+}
+
+async function flushEffects() {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
   })
-  return { container, root }
 }
 
 function cleanup(root: Root, container: HTMLElement) {
@@ -76,4 +100,231 @@ describe("DeAiSkillPicker", () => {
 
     cleanup(root, container)
   })
+
+  it("reloads skills after the skill library data version changes", async () => {
+    const { container, root } = await renderPicker()
+    await flushEffects()
+    expect(container.querySelector<HTMLButtonElement>("button")?.textContent).toContain("综合去AI味-项目版")
+
+    readFileMock.mockResolvedValue(JSON.stringify({
+      version: 1,
+      defaultSkillId: "built-in:comprehensive",
+      disabledSkillIds: [],
+      projectSkills: [],
+      builtInSkillOverrides: [{
+        id: "built-in:comprehensive",
+        name: "综合去AI味-新版",
+        description: "当前项目新规则",
+        templateId: "comprehensive",
+        content: "新的项目覆盖规则",
+        source: "built-in",
+        createdAt: 1000,
+        updatedAt: 3000,
+      }],
+    }))
+
+    await act(async () => {
+      useWikiStore.getState().bumpDataVersion()
+    })
+    await flushEffects()
+
+    expect(container.querySelector<HTMLButtonElement>("button")?.textContent).toContain("综合去AI味-新版")
+
+    cleanup(root, container)
+  })
+
+  it("shows a recovery hint when the skill config is corrupt", async () => {
+    readFileMock.mockResolvedValue("{bad json")
+    const { container, root } = await renderPicker()
+    await flushEffects()
+
+    const button = container.querySelector<HTMLButtonElement>("button")
+    expect(button?.textContent).toContain("去AI味:配置损坏")
+    expect(button?.title).toBe("当前去AI味 Skill:配置损坏")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    expect(container.textContent).toContain("技能库配置文件损坏,请到技能库恢复配置")
+
+    cleanup(root, container)
+  })
+
+  it("renders shared option list states for chapter and chat entries", async () => {
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+
+    await act(async () => {
+      root.render(
+        <DeAiSkillOptionsPanel
+          loading={false}
+          errorMessage=""
+          emptyMessage="暂无可用去AI味技能"
+          skills={[{
+            id: "built-in:comprehensive",
+            name: "综合去AI味-项目版",
+            description: "当前项目规则",
+            templateId: "comprehensive",
+            content: "当前项目覆盖后的内置规则",
+            source: "built-in",
+          }]}
+          currentSkillId="built-in:comprehensive"
+          defaultSkillId="built-in:comprehensive"
+          modifiedSkillIds={["built-in:comprehensive"]}
+          onPick={vi.fn()}
+        />,
+      )
+    })
+
+    expect(container.textContent).toContain("综合去AI味-项目版")
+    expect(container.textContent).toContain("当前")
+    expect(container.textContent).toContain("默认")
+    expect(container.textContent).toContain("已修改")
+
+    await act(async () => {
+      root.render(
+        <DeAiSkillOptionsPanel
+          loading={false}
+          errorMessage="技能库配置文件损坏,请到技能库恢复配置"
+          emptyMessage="暂无可用去AI味技能"
+          skills={[]}
+          currentSkillId={null}
+          defaultSkillId={null}
+          modifiedSkillIds={[]}
+          onPick={vi.fn()}
+        />,
+      )
+    })
+
+    expect(container.textContent).toContain("技能库配置文件损坏,请到技能库恢复配置")
+
+    cleanup(root, container)
+  })
+
+  it("loads shared de-AI skill option state with last chapter skill fallback", async () => {
+    readFileMock.mockResolvedValue(JSON.stringify({
+      version: 1,
+      defaultSkillId: "built-in:comprehensive",
+      disabledSkillIds: [],
+      projectSkills: [],
+      builtInSkillOverrides: [],
+      lastChapterDeAiSkillId: "built-in:dialogue-natural",
+    }))
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+
+    await act(async () => {
+      root.render(<SkillOptionsProbe />)
+    })
+    await flushEffects()
+
+    expect(container.querySelector('[data-testid="effective-name"]')?.textContent).toContain("对话口语化")
+    expect(container.querySelector('[data-testid="current-id"]')?.textContent).toBe("built-in:dialogue-natural")
+    expect(container.querySelector('[data-testid="default-id"]')?.textContent).toBe("built-in:comprehensive")
+
+    cleanup(root, container)
+  })
+
+  it("closes the picker with Escape and outside click", async () => {
+    const { container, root } = await renderPicker()
+    await flushEffects()
+    const button = container.querySelector<HTMLButtonElement>("button")
+
+    await act(async () => {
+      button?.click()
+    })
+    expect(container.textContent).toContain("当前项目规则")
+
+    await act(async () => {
+      document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
+    })
+    expect(container.textContent).not.toContain("当前项目规则")
+
+    await act(async () => {
+      button?.click()
+    })
+    expect(container.textContent).toContain("当前项目规则")
+
+    await act(async () => {
+      document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
+    })
+    expect(container.textContent).not.toContain("当前项目规则")
+
+    cleanup(root, container)
+  })
+
+  it("highlights the current option and supports keyboard pick", async () => {
+    const onChange = vi.fn()
+    const { container, root } = await renderPicker(onChange)
+    await flushEffects()
+    const button = container.querySelector<HTMLButtonElement>("button")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    expect(container.querySelector('[aria-current="true"]')?.textContent).toContain("综合去AI味-项目版")
+
+    await act(async () => {
+      container
+        .querySelector('[role="listbox"]')
+        ?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))
+    })
+    await act(async () => {
+      container
+        .querySelector('[role="listbox"]')
+        ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+    })
+
+    expect(onChange).toHaveBeenCalledWith("built-in:reduce-explanation")
+
+    cleanup(root, container)
+  })
+
+  it("focuses the option list after opening the picker", async () => {
+    const { container, root } = await renderPicker()
+    await flushEffects()
+    const button = container.querySelector<HTMLButtonElement>("button")
+
+    await act(async () => {
+      button?.click()
+    })
+    await flushEffects()
+
+    expect(document.activeElement).toBe(container.querySelector('[role="listbox"]'))
+
+    cleanup(root, container)
+  })
+
+  it("anchors the option panel with fixed positioning so overflow toolbars do not clip it", async () => {
+    const { container, root } = await renderPicker()
+    await flushEffects()
+    const button = container.querySelector<HTMLButtonElement>("button")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    const popover = container.querySelector<HTMLElement>('[data-testid="de-ai-skill-picker-popover"]')
+    expect(popover?.className).toContain("fixed")
+    expect(popover?.style.left).not.toBe("")
+
+    cleanup(root, container)
+  })
+
+  it("can render as an icon-only trigger while keeping hover and accessible descriptions", async () => {
+    const { container, root } = await renderPicker(vi.fn(), { iconOnly: true })
+    await flushEffects()
+    const button = container.querySelector<HTMLButtonElement>("button")
+
+    expect(button?.textContent).not.toContain("去AI味")
+    expect(button?.textContent).not.toContain("综合去AI味")
+    expect(button?.title).toBe("当前去AI味 Skill:综合去AI味-项目版。点击选择去AI味 Skill")
+    expect(button?.getAttribute("aria-label")).toBe("当前去AI味 Skill:综合去AI味-项目版。点击选择去AI味 Skill")
+
+    cleanup(root, container)
+  })
 })

+ 199 - 58
src/components/skill-library/de-ai-skill-picker.tsx

@@ -1,19 +1,150 @@
-import { useEffect, useState } from "react"
+import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"
 import { WandSparkles, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
-import {
-  isDeAiSkillModified,
-  loadDeAiSkillConfig,
-  resolveAvailableDeAiSkills,
-  resolveEffectiveDeAiSkill,
-  type DeAiSkill,
-} from "@/lib/novel/de-ai-skill-library"
+import { type DeAiSkill } from "@/lib/novel/de-ai-skill-library"
+import { useDeAiSkillOptions } from "./use-de-ai-skill-options"
 
 interface DeAiSkillPickerProps {
   value?: string | null
   onChange: (skillId: string | null | undefined) => void
   includeDisableOption?: boolean
   buttonLabel?: string
+  iconOnly?: boolean
+}
+
+export { getDeAiSkillLoadErrorMessage } from "./de-ai-skill-errors"
+
+interface DeAiSkillOptionsPanelProps {
+  loading: boolean
+  errorMessage: string
+  emptyMessage: string
+  skills: DeAiSkill[]
+  currentSkillId?: string | null
+  defaultSkillId?: string | null
+  modifiedSkillIds: string[]
+  onPick: (skillId: string) => void
+  onClose?: () => void
+}
+
+function getPickerPanelStyle(anchor: HTMLElement): CSSProperties {
+  const rect = anchor.getBoundingClientRect()
+  const panelWidth = 256
+  const gap = 8
+  const viewportWidth = window.innerWidth || panelWidth
+  const left = Math.min(Math.max(rect.left, gap), Math.max(gap, viewportWidth - panelWidth - gap))
+  if (rect.top > 320) {
+    return {
+      left,
+      bottom: Math.max(gap, (window.innerHeight || rect.top) - rect.top + gap),
+    }
+  }
+  return {
+    left,
+    top: rect.bottom + gap,
+  }
+}
+
+export function DeAiSkillOptionsPanel({
+  loading,
+  errorMessage,
+  emptyMessage,
+  skills,
+  currentSkillId,
+  defaultSkillId,
+  modifiedSkillIds,
+  onPick,
+  onClose,
+}: DeAiSkillOptionsPanelProps) {
+  const skillIds = useMemo(() => skills.map((skill) => skill.id), [skills])
+  const [focusedSkillId, setFocusedSkillId] = useState<string | null>(null)
+  const listboxRef = useRef<HTMLDivElement | null>(null)
+
+  useEffect(() => {
+    if (skills.length === 0) {
+      setFocusedSkillId(null)
+      return
+    }
+    setFocusedSkillId(currentSkillId && skillIds.includes(currentSkillId) ? currentSkillId : skills[0].id)
+  }, [currentSkillId, skillIds, skills])
+
+  useEffect(() => {
+    if (!loading && !errorMessage && skills.length > 0) {
+      listboxRef.current?.focus()
+    }
+  }, [errorMessage, loading, skills.length])
+
+  if (loading) {
+    return <div className="px-2 py-3 text-xs text-muted-foreground">正在读取技能...</div>
+  }
+  if (errorMessage) {
+    return <div className="px-2 py-3 text-xs text-destructive">{errorMessage}</div>
+  }
+  if (skills.length === 0) {
+    return <div className="px-2 py-3 text-xs text-muted-foreground">{emptyMessage}</div>
+  }
+  return (
+    <div
+      ref={listboxRef}
+      role="listbox"
+      tabIndex={0}
+      className="max-h-72 overflow-y-auto outline-none"
+      onKeyDown={(event) => {
+        if (event.key === "Escape") {
+          event.preventDefault()
+          onClose?.()
+          return
+        }
+        if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return
+        event.preventDefault()
+        const currentIndex = Math.max(0, skillIds.indexOf(focusedSkillId ?? ""))
+        if (event.key === "Enter") {
+          const pickedSkillId = focusedSkillId ?? skills[currentIndex]?.id
+          if (pickedSkillId) onPick(pickedSkillId)
+          return
+        }
+        const offset = event.key === "ArrowDown" ? 1 : -1
+        const nextIndex = (currentIndex + offset + skills.length) % skills.length
+        setFocusedSkillId(skills[nextIndex].id)
+      }}
+    >
+      {skills.map((skill) => (
+        <button
+          key={skill.id}
+          type="button"
+          role="option"
+          aria-selected={skill.id === focusedSkillId}
+          aria-current={skill.id === currentSkillId ? "true" : undefined}
+          className={`block w-full rounded px-2 py-2 text-left hover:bg-accent ${
+            skill.id === currentSkillId ? "bg-accent text-foreground" : ""
+          } ${skill.id === focusedSkillId ? "ring-1 ring-primary/40" : ""}`}
+          onMouseEnter={() => setFocusedSkillId(skill.id)}
+          onClick={() => onPick(skill.id)}
+        >
+          <div className="flex items-center gap-2">
+            <span className="min-w-0 flex-1 truncate text-sm">{skill.name}</span>
+            {skill.id === currentSkillId ? (
+              <span className="shrink-0 rounded bg-primary px-1.5 py-0.5 text-[10px] text-primary-foreground">
+                当前
+              </span>
+            ) : null}
+            {skill.id === defaultSkillId ? (
+              <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                默认
+              </span>
+            ) : null}
+            {modifiedSkillIds.includes(skill.id) ? (
+              <span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800">
+                已修改
+              </span>
+            ) : null}
+          </div>
+          {skill.description ? (
+            <div className="mt-0.5 truncate text-xs text-muted-foreground">{skill.description}</div>
+          ) : null}
+        </button>
+      ))}
+    </div>
+  )
 }
 
 export function DeAiSkillPicker({
@@ -21,71 +152,81 @@ export function DeAiSkillPicker({
   onChange,
   includeDisableOption = true,
   buttonLabel,
+  iconOnly = false,
 }: DeAiSkillPickerProps) {
   const project = useWikiStore((s) => s.project)
   const [open, setOpen] = useState(false)
-  const [skills, setSkills] = useState<DeAiSkill[]>([])
-  const [effectiveName, setEffectiveName] = useState("技能")
-  const [modifiedSkillIds, setModifiedSkillIds] = useState<string[]>([])
+  const [panelStyle, setPanelStyle] = useState<CSSProperties>({ left: 8, top: 8 })
+  const containerRef = useRef<HTMLDivElement | null>(null)
+  const {
+    loading,
+    skills,
+    effectiveName,
+    currentSkillId,
+    defaultSkillId,
+    modifiedSkillIds,
+    loadError,
+  } = useDeAiSkillOptions({ projectPath: project?.path, selectedSkillId: value })
+  const triggerTitle = `当前去AI味 Skill:${effectiveName}`
+  const iconTriggerDescription = `${triggerTitle}。点击选择去AI味 Skill`
 
   useEffect(() => {
-    let cancelled = false
-    loadDeAiSkillConfig(project?.path)
-      .then((config) => {
-        if (cancelled) return
-        const available = resolveAvailableDeAiSkills(config)
-        setSkills(available)
-        setModifiedSkillIds(available.filter((skill) => isDeAiSkillModified(config, skill.id)).map((skill) => skill.id))
-        setEffectiveName(resolveEffectiveDeAiSkill(config, value)?.name ?? "未启用")
-      })
-      .catch(() => {
-        if (!cancelled) {
-          setSkills([])
-          setModifiedSkillIds([])
-          setEffectiveName("未启用")
-        }
-      })
+    if (!open) return
+    const handleMouseDown = (event: MouseEvent) => {
+      if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
+    }
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") setOpen(false)
+    }
+    document.addEventListener("mousedown", handleMouseDown)
+    document.addEventListener("keydown", handleKeyDown)
     return () => {
-      cancelled = true
+      document.removeEventListener("mousedown", handleMouseDown)
+      document.removeEventListener("keydown", handleKeyDown)
     }
-  }, [project?.path, value])
+  }, [open])
 
   return (
-    <div className="relative">
+    <div ref={containerRef} className="relative">
       <button
         type="button"
-        onClick={() => setOpen((next) => !next)}
-        className="flex h-8 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
-        title={`当前去AI味 Skill:${effectiveName}`}
+        onClick={(event) => {
+          if (!open) {
+            setPanelStyle(getPickerPanelStyle(event.currentTarget))
+          }
+          setOpen((next) => !next)
+        }}
+        className={iconOnly
+          ? "flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
+          : "flex h-8 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"}
+        title={iconOnly ? iconTriggerDescription : triggerTitle}
+        aria-label={iconOnly ? iconTriggerDescription : triggerTitle}
       >
         <WandSparkles className="h-4 w-4" />
-        <span className="max-w-[10rem] truncate">{buttonLabel ?? `去AI味:${effectiveName}`}</span>
+        {iconOnly ? null : (
+          <span className="max-w-[10rem] truncate">{buttonLabel ?? `去AI味:${effectiveName}`}</span>
+        )}
       </button>
       {open ? (
-        <div className="absolute bottom-9 left-0 z-50 w-64 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-lg">
-          {skills.length === 0 ? (
-            <div className="px-2 py-2 text-xs text-muted-foreground">暂无可用去AI味技能</div>
-          ) : skills.map((skill) => (
-            <button
-              key={skill.id}
-              type="button"
-              className="block w-full rounded px-2 py-2 text-left hover:bg-accent"
-              onClick={() => {
-                onChange(skill.id)
-                setOpen(false)
-              }}
-            >
-              <div className="flex items-center gap-2">
-                <span className="min-w-0 flex-1 truncate text-sm">{skill.name}</span>
-                {modifiedSkillIds.includes(skill.id) ? (
-                  <span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800">
-                    已修改
-                  </span>
-                ) : null}
-              </div>
-              <div className="truncate text-xs text-muted-foreground">{skill.description}</div>
-            </button>
-          ))}
+        <div
+          data-testid="de-ai-skill-picker-popover"
+          className="fixed z-50 w-64 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-lg"
+          style={panelStyle}
+        >
+          <DeAiSkillOptionsPanel
+            loading={loading}
+            errorMessage={loadError}
+            emptyMessage="暂无可用去AI味技能"
+            skills={skills}
+            currentSkillId={currentSkillId}
+            defaultSkillId={defaultSkillId}
+            modifiedSkillIds={modifiedSkillIds}
+            onClose={() => setOpen(false)}
+            onPick={(skillId) => {
+              onChange(skillId)
+              setOpen(false)
+            }}
+          />
           {includeDisableOption ? (
             <button
               type="button"

+ 125 - 0
src/components/skill-library/skill-library-view.spec.tsx

@@ -10,12 +10,14 @@ import { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view
 
 const readFileMock = vi.hoisted(() => vi.fn())
 const writeFileMock = vi.hoisted(() => vi.fn())
+const writeFileAtomicMock = vi.hoisted(() => vi.fn())
 const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
 let savedConfigContent = ""
 
 vi.mock("@/commands/fs", () => ({
   readFile: readFileMock,
   writeFile: writeFileMock,
+  writeFileAtomic: writeFileAtomicMock,
 }))
 
 vi.mock("@tauri-apps/api/path", () => ({
@@ -94,6 +96,9 @@ describe("SkillLibraryView", () => {
     writeFileMock.mockImplementation(async (_path: string, content: string) => {
       savedConfigContent = content
     })
+    writeFileAtomicMock.mockImplementation(async (_path: string, content: string) => {
+      savedConfigContent = content
+    })
     useWikiStore.getState().setProject({
       id: "p1",
       name: "测试项目",
@@ -298,4 +303,124 @@ describe("SkillLibraryView", () => {
 
     cleanup(root, container)
   })
+
+  it("does not toggle a skill from the sidebar when the current draft is unsaved and discard is cancelled", async () => {
+    const { container, root } = await renderLibrary()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    expect(nameInput).not.toBeNull()
+
+    await setInputValue(nameInput!, "未保存的技能草稿")
+    expect(useWikiStore.getState().skillLibraryDraftDirty).toBe(true)
+
+    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false)
+    const checkbox = container.querySelector<HTMLInputElement>('[data-skill-id="built-in:reduce-explanation"] input')
+    expect(checkbox).not.toBeNull()
+
+    await act(async () => {
+      checkbox?.click()
+    })
+    await flushEffects()
+
+    expect(confirmSpy).toHaveBeenCalledWith("当前 Skill 还有未保存修改,确定放弃修改吗?")
+    expect(writeFileMock).not.toHaveBeenCalled()
+    expect(checkbox?.checked).toBe(true)
+    expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("未保存的技能草稿")
+
+    confirmSpy.mockRestore()
+    cleanup(root, container)
+  })
+
+  it("does not pretend to save when no project is open", async () => {
+    useWikiStore.getState().setProject(null)
+    const { container, root } = await renderView()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    const saveButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-save-button"]')
+    expect(nameInput).not.toBeNull()
+    expect(saveButton).not.toBeNull()
+    expect(nameInput?.disabled).toBe(true)
+
+    await setInputValue(nameInput!, "综合去AI味-无项目")
+
+    expect(saveButton?.disabled).toBe(true)
+    expect(container.textContent).toContain("请先打开项目")
+    expect(writeFileMock).not.toHaveBeenCalled()
+
+    cleanup(root, container)
+  })
+
+  it("loads the skill config once when sidebar and detail render together", async () => {
+    const { container, root } = await renderLibrary()
+
+    expect(container.textContent).toContain("综合去AI味")
+    expect(readFileMock).toHaveBeenCalledTimes(2)
+    expect(readFileMock).toHaveBeenNthCalledWith(1, "C:/project/de-ai-skills.json")
+    expect(readFileMock).toHaveBeenNthCalledWith(2, "C:/project/de-ai-skill.txt")
+
+    cleanup(root, container)
+  })
+
+  it("offers recovery actions when the json skill config is corrupt and restores from backup", async () => {
+    const backup = JSON.stringify({
+      version: 1,
+      defaultSkillId: "project:backup",
+      disabledSkillIds: [],
+      projectSkills: [{
+        id: "project:backup",
+        name: "备份 Skill",
+        description: "从备份恢复",
+        templateId: "backup",
+        content: "备份规则",
+        source: "project",
+        createdAt: 1000,
+        updatedAt: 1000,
+      }],
+      builtInSkillOverrides: [],
+    })
+    readFileMock
+      .mockResolvedValueOnce("{bad json")
+      .mockResolvedValueOnce(backup)
+
+    const { container, root } = await renderView()
+
+    expect(container.textContent).toContain("技能库配置文件损坏")
+    const restoreButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-restore-backup-button"]')
+    expect(restoreButton).not.toBeNull()
+
+    await act(async () => {
+      restoreButton?.click()
+    })
+    await flushEffects()
+
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      JSON.stringify({ ...JSON.parse(backup), lastChapterDeAiSkillId: null }, null, 2),
+    )
+    expect(container.textContent).toContain("备份 Skill")
+
+    cleanup(root, container)
+  })
+
+  it("can recreate a default config after the json skill config is corrupt", async () => {
+    readFileMock.mockResolvedValueOnce("{bad json")
+    const { container, root } = await renderView()
+
+    expect(container.textContent).toContain("技能库配置文件损坏")
+    const recreateButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-recreate-config-button"]')
+    expect(recreateButton).not.toBeNull()
+    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
+
+    await act(async () => {
+      recreateButton?.click()
+    })
+    await flushEffects()
+
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      expect.stringContaining('"defaultSkillId": "built-in:comprehensive"'),
+    )
+    expect(container.textContent).toContain("综合去AI味")
+
+    confirmSpy.mockRestore()
+    cleanup(root, container)
+  })
 })

+ 163 - 57
src/components/skill-library/skill-library-view.tsx

@@ -5,8 +5,11 @@ import {
   deleteProjectDeAiSkill,
   getAllDeAiSkills,
   isDeAiSkillModified,
+  isDeAiSkillConfigCorruptError,
   loadDeAiSkillConfig,
+  recreateDeAiSkillConfig,
   resetBuiltInDeAiSkill,
+  restoreDeAiSkillConfigFromBackup,
   saveDeAiSkillConfig,
   setDeAiSkillEnabled,
   setDefaultDeAiSkill,
@@ -38,47 +41,81 @@ function hasSkillDraftChanged(skill: DeAiSkill, name: string, description: strin
     || normalizeDraftText(content) !== normalizeDraftText(skill.content)
 }
 
-export function SkillLibrarySidebarPanel() {
-  const project = useWikiStore((s) => s.project)
-  const dataVersion = useWikiStore((s) => s.dataVersion)
-  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
-  const selectedSkillId = useWikiStore((s) => s.selectedSkillLibrarySkillId)
-  const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
-  const draftDirty = useWikiStore((s) => s.skillLibraryDraftDirty)
-  const setDraftDirty = useWikiStore((s) => s.setSkillLibraryDraftDirty)
+const skillLibraryLoadPromises = new Map<string, Promise<DeAiSkillConfig>>()
+
+function loadSharedDeAiSkillConfig(projectPath: string | null | undefined, dataVersion: number) {
+  const key = `${projectPath ?? ""}:${dataVersion}`
+  const existing = skillLibraryLoadPromises.get(key)
+  if (existing) return existing
+  const promise = loadDeAiSkillConfig(projectPath).finally(() => {
+    skillLibraryLoadPromises.delete(key)
+  })
+  skillLibraryLoadPromises.set(key, promise)
+  return promise
+}
 
+function useSkillLibraryConfig(
+  projectPath: string | null | undefined,
+  dataVersion: number,
+  selectedSkillId: string | null,
+  setSelectedSkillId: (id: string | null) => void,
+) {
   const [config, setConfig] = useState<DeAiSkillConfig | null>(null)
-  const [message, setMessage] = useState("")
-  const [saving, setSaving] = useState(false)
-
-  const allSkills = useMemo(() => config ? getAllDeAiSkills(config) : [], [config])
-  const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
+  const [loadError, setLoadError] = useState("")
 
   useEffect(() => {
     let cancelled = false
     setConfig(null)
-    setMessage("")
-    loadDeAiSkillConfig(project?.path)
+    setLoadError("")
+    loadSharedDeAiSkillConfig(projectPath, dataVersion)
       .then((loaded) => {
         if (cancelled) return
         setConfig(loaded)
         setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
       })
-      .catch(() => {
-        if (!cancelled) setMessage("技能库加载失败")
+      .catch((error) => {
+        if (cancelled) return
+        setConfig(null)
+        setLoadError(isDeAiSkillConfigCorruptError(error) ? "技能库配置文件损坏" : "技能库加载失败")
       })
     return () => {
       cancelled = true
     }
-  }, [dataVersion, project?.path])
+  }, [dataVersion, projectPath])
+
+  return { config, setConfig, loadError, setLoadError }
+}
+
+export function SkillLibrarySidebarPanel() {
+  const project = useWikiStore((s) => s.project)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const selectedSkillId = useWikiStore((s) => s.selectedSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
+  const draftDirty = useWikiStore((s) => s.skillLibraryDraftDirty)
+  const setDraftDirty = useWikiStore((s) => s.setSkillLibraryDraftDirty)
+
+  const { config, setConfig, loadError } = useSkillLibraryConfig(
+    project?.path,
+    dataVersion,
+    selectedSkillId,
+    setSelectedSkillId,
+  )
+  const [message, setMessage] = useState("")
+  const [saving, setSaving] = useState(false)
+
+  const allSkills = useMemo(() => config ? getAllDeAiSkills(config) : [], [config])
+  const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
 
   async function persist(nextConfig: DeAiSkillConfig, nextSelectedSkillId: string) {
+    if (!project) {
+      setMessage("请先打开项目")
+      return
+    }
     setSaving(true)
     try {
-      if (project) {
-        await saveDeAiSkillConfig(project.path, nextConfig)
-        bumpDataVersion()
-      }
+      await saveDeAiSkillConfig(project.path, nextConfig)
+      bumpDataVersion()
       setConfig(nextConfig)
       setSelectedSkillId(nextSelectedSkillId)
       setMessage("已保存")
@@ -90,7 +127,7 @@ export function SkillLibrarySidebarPanel() {
   }
 
   async function handleCreateSkill() {
-    if (!config || saving) return
+    if (!config || !project || saving) return
     if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
     if (draftDirty) setDraftDirty(false)
     const now = Date.now()
@@ -99,7 +136,9 @@ export function SkillLibrarySidebarPanel() {
   }
 
   async function handleToggleSkill(skill: DeAiSkill, enabled: boolean) {
-    if (!config || saving) return
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
     const next = setDeAiSkillEnabled(config, skill.id, enabled)
     await persist(next, selectedSkillId ?? next.defaultSkillId)
   }
@@ -117,13 +156,15 @@ export function SkillLibrarySidebarPanel() {
           type="button"
           onClick={() => void handleCreateSkill()}
           className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
-          disabled={!config || saving}
+          disabled={!config || !project || saving}
         >
           新建技能
         </button>
       </div>
 
-      {message ? <div className="border-b px-3 py-2 text-xs text-muted-foreground">{message}</div> : null}
+      {loadError || message ? (
+        <div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError || message}</div>
+      ) : null}
 
       <div data-testid="skill-list" className="min-h-0 flex-1 overflow-y-auto p-2">
         {allSkills.map((skill) => {
@@ -193,7 +234,12 @@ export function SkillLibraryView() {
   const draftDirty = useWikiStore((s) => s.skillLibraryDraftDirty)
   const setDraftDirty = useWikiStore((s) => s.setSkillLibraryDraftDirty)
 
-  const [config, setConfig] = useState<DeAiSkillConfig | null>(null)
+  const { config, setConfig, loadError, setLoadError } = useSkillLibraryConfig(
+    project?.path,
+    dataVersion,
+    selectedSkillId,
+    setSelectedSkillId,
+  )
   const [draftName, setDraftName] = useState("")
   const [draftDescription, setDraftDescription] = useState("")
   const [draftContent, setDraftContent] = useState("")
@@ -202,33 +248,18 @@ export function SkillLibraryView() {
 
   const allSkills = useMemo(() => config ? getAllDeAiSkills(config) : [], [config])
   const selectedSkill = allSkills.find((skill) => skill.id === selectedSkillId) ?? allSkills[0] ?? null
-  const selectedIsEditable = selectedSkill != null
+  const selectedIsEditable = Boolean(project && selectedSkill)
   const selectedIsBuiltIn = selectedSkill?.id.startsWith("built-in:") ?? false
   const selectedHasBuiltInOverride = selectedIsBuiltIn
     && Boolean(config?.builtInSkillOverrides.some((skill) => skill.id === selectedSkill?.id))
   const selectedModified = Boolean(config && selectedSkill && isDeAiSkillModified(config, selectedSkill.id))
+  const selectedEnabled = Boolean(
+    config && selectedSkill && !config.disabledSkillIds.includes(selectedSkill.id),
+  )
   const draftChanged = Boolean(
     selectedSkill && hasSkillDraftChanged(selectedSkill, draftName, draftDescription, draftContent),
   )
-  const canSaveDraft = selectedIsEditable && draftChanged && !saving
-
-  useEffect(() => {
-    let cancelled = false
-    setConfig(null)
-    setMessage("")
-    loadDeAiSkillConfig(project?.path)
-      .then((loaded) => {
-        if (cancelled) return
-        setConfig(loaded)
-        setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
-      })
-      .catch(() => {
-        if (!cancelled) setMessage("技能库加载失败")
-      })
-    return () => {
-      cancelled = true
-    }
-  }, [dataVersion, project?.path])
+  const canSaveDraft = Boolean(project) && selectedIsEditable && draftChanged && !saving
 
   useEffect(() => {
     if (!draftDirty) return
@@ -260,12 +291,14 @@ export function SkillLibraryView() {
   }
 
   async function persist(nextConfig: DeAiSkillConfig, nextSelectedSkillId = selectedSkillId ?? "") {
+    if (!project) {
+      setMessage("请先打开项目")
+      return
+    }
     setSaving(true)
     try {
-      if (project) {
-        await saveDeAiSkillConfig(project.path, nextConfig)
-        bumpDataVersion()
-      }
+      await saveDeAiSkillConfig(project.path, nextConfig)
+      bumpDataVersion()
       setConfig(nextConfig)
       setDraftDirty(false)
       setSelectedSkillId(nextSelectedSkillId)
@@ -278,7 +311,7 @@ export function SkillLibraryView() {
   }
 
   async function handleCopySkill() {
-    if (!config || !selectedSkill) return
+    if (!config || !project || !selectedSkill) return
     if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
     if (draftDirty) setDraftDirty(false)
     const now = Date.now()
@@ -317,11 +350,15 @@ export function SkillLibraryView() {
 
   async function handleSetDefault() {
     if (!config || !selectedSkill) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
     await persist(setDefaultDeAiSkill(config, selectedSkill.id))
   }
 
   async function handleDeleteSkill() {
     if (!config || !selectedSkill || selectedIsBuiltIn) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
     const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
     if (!confirmed) return
     const next = deleteProjectDeAiSkill(config, selectedSkill.id)
@@ -331,11 +368,51 @@ export function SkillLibraryView() {
 
   async function handleResetBuiltInSkill() {
     if (!config || !selectedSkill || !selectedIsBuiltIn || !selectedHasBuiltInOverride) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
     const confirmed = window.confirm(`确定将「${selectedSkill.name}」恢复为内置默认内容吗?当前项目对此技能的修改会被清除。`)
     if (!confirmed) return
     await persist(resetBuiltInDeAiSkill(config, selectedSkill.id), selectedSkill.id)
   }
 
+  async function handleRestoreBackup() {
+    if (!project || saving) return
+    setSaving(true)
+    try {
+      const restored = await restoreDeAiSkillConfigFromBackup(project.path)
+      setConfig(restored)
+      setLoadError("")
+      setDraftDirty(false)
+      setSelectedSkillId(resolveInitialSkillId(restored, selectedSkillId))
+      setMessage("已从备份恢复")
+      bumpDataVersion()
+    } catch {
+      setMessage("备份恢复失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleRecreateConfig() {
+    if (!project || saving) return
+    const confirmed = window.confirm("确定重新创建技能库配置吗?这会用默认内置 Skill 覆盖当前损坏的配置文件。")
+    if (!confirmed) return
+    setSaving(true)
+    try {
+      const recreated = await recreateDeAiSkillConfig(project.path)
+      setConfig(recreated)
+      setLoadError("")
+      setDraftDirty(false)
+      setSelectedSkillId(resolveInitialSkillId(recreated, selectedSkillId))
+      setMessage("已重新创建技能库配置")
+      bumpDataVersion()
+    } catch {
+      setMessage("重新创建技能库配置失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
   useEffect(() => {
     const handleKeyDown = (event: KeyboardEvent) => {
       if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
@@ -353,10 +430,39 @@ export function SkillLibraryView() {
       <div className="shrink-0 border-b px-5 py-4">
         <h1 className="text-lg font-semibold">技能库</h1>
         <p className="mt-1 text-sm text-muted-foreground">编辑当前选中的去AI味 Skill 内容。</p>
+        {!project ? <p className="mt-1 text-sm text-destructive">请先打开项目</p> : null}
       </div>
 
       <main className="min-h-0 flex-1 overflow-y-auto p-5">
-        {!selectedSkill ? (
+        {loadError ? (
+          <div className="mx-auto max-w-3xl rounded-lg border border-destructive/30 bg-destructive/5 p-4">
+            <div className="text-sm font-medium text-destructive">{loadError}</div>
+            <p className="mt-2 text-sm text-muted-foreground">
+              当前项目的技能库配置无法读取。你可以先尝试从备份恢复;如果没有可用备份,再重新创建默认配置。
+            </p>
+            <div className="mt-4 flex flex-wrap gap-2">
+              <button
+                data-testid="skill-restore-backup-button"
+                type="button"
+                onClick={() => void handleRestoreBackup()}
+                disabled={!project || saving}
+                className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
+              >
+                使用备份恢复
+              </button>
+              <button
+                data-testid="skill-recreate-config-button"
+                type="button"
+                onClick={() => void handleRecreateConfig()}
+                disabled={!project || saving}
+                className="rounded-md border px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-60"
+              >
+                重新创建配置
+              </button>
+              {message ? <span className="text-sm text-muted-foreground">{message}</span> : null}
+            </div>
+          </div>
+        ) : !selectedSkill ? (
           <div className="text-sm text-muted-foreground">暂无技能。</div>
         ) : (
           <div className="mx-auto flex max-w-5xl flex-col gap-4">
@@ -381,7 +487,7 @@ export function SkillLibraryView() {
                   type="button"
                   onClick={() => void handleCopySkill()}
                   className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
-                  disabled={saving}
+                  disabled={!project || saving}
                 >
                   复制为项目技能
                 </button>
@@ -390,7 +496,7 @@ export function SkillLibraryView() {
                   type="button"
                   onClick={() => void handleSetDefault()}
                   className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
-                  disabled={saving || config?.defaultSkillId === selectedSkill.id}
+                  disabled={!project || saving || !selectedEnabled || config?.defaultSkillId === selectedSkill.id}
                 >
                   设为默认
                 </button>
@@ -400,7 +506,7 @@ export function SkillLibraryView() {
                     type="button"
                     onClick={() => void handleResetBuiltInSkill()}
                     className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
-                    disabled={saving || !selectedHasBuiltInOverride}
+                    disabled={!project || saving || !selectedHasBuiltInOverride}
                   >
                     恢复默认
                   </button>
@@ -411,7 +517,7 @@ export function SkillLibraryView() {
                     type="button"
                     onClick={() => void handleDeleteSkill()}
                     className="rounded-md border px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
-                    disabled={saving}
+                    disabled={!project || saving}
                   >
                     删除
                   </button>

+ 84 - 0
src/components/skill-library/use-de-ai-skill-options.ts

@@ -0,0 +1,84 @@
+import { useEffect, useState } from "react"
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  isDeAiSkillConfigCorruptError,
+  isDeAiSkillModified,
+  loadDeAiSkillConfig,
+  resolveAvailableDeAiSkills,
+  resolveEffectiveDeAiSkill,
+  type DeAiSkill,
+} from "@/lib/novel/de-ai-skill-library"
+import { getDeAiSkillLoadErrorMessage } from "./de-ai-skill-errors"
+
+interface UseDeAiSkillOptionsParams {
+  projectPath?: string | null
+  selectedSkillId?: string | null
+  useLastChapterSkill?: boolean
+}
+
+interface DeAiSkillOptionsState {
+  loading: boolean
+  skills: DeAiSkill[]
+  effectiveName: string
+  currentSkillId: string | null
+  defaultSkillId: string | null
+  modifiedSkillIds: string[]
+  loadError: string
+}
+
+export function useDeAiSkillOptions({
+  projectPath,
+  selectedSkillId,
+  useLastChapterSkill = false,
+}: UseDeAiSkillOptionsParams): DeAiSkillOptionsState {
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const [state, setState] = useState<DeAiSkillOptionsState>({
+    loading: true,
+    skills: [],
+    effectiveName: "技能",
+    currentSkillId: null,
+    defaultSkillId: null,
+    modifiedSkillIds: [],
+    loadError: "",
+  })
+
+  useEffect(() => {
+    let cancelled = false
+    setState((current) => ({ ...current, loading: true, loadError: "" }))
+    loadDeAiSkillConfig(projectPath)
+      .then((config) => {
+        if (cancelled) return
+        const skills = resolveAvailableDeAiSkills(config)
+        const requestedSkillId = useLastChapterSkill && typeof selectedSkillId === "undefined"
+          ? config.lastChapterDeAiSkillId
+          : selectedSkillId
+        const effectiveSkill = resolveEffectiveDeAiSkill(config, requestedSkillId)
+        setState({
+          loading: false,
+          skills,
+          effectiveName: effectiveSkill?.name ?? "未启用",
+          currentSkillId: effectiveSkill?.id ?? null,
+          defaultSkillId: config.defaultSkillId,
+          modifiedSkillIds: skills.filter((skill) => isDeAiSkillModified(config, skill.id)).map((skill) => skill.id),
+          loadError: "",
+        })
+      })
+      .catch((error) => {
+        if (cancelled) return
+        setState({
+          loading: false,
+          skills: [],
+          effectiveName: isDeAiSkillConfigCorruptError(error) ? "配置损坏" : "未启用",
+          currentSkillId: null,
+          defaultSkillId: null,
+          modifiedSkillIds: [],
+          loadError: getDeAiSkillLoadErrorMessage(error),
+        })
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [dataVersion, projectPath, selectedSkillId, useLastChapterSkill])
+
+  return state
+}

+ 8 - 0
src/i18n/en.json

@@ -1096,6 +1096,14 @@
     "moveSourceSelected": "Move source selected",
     "moveToVolume": "Move to volume",
     "moveTargetExists": "A file with the same name already exists in the target volume",
+    "batchChapters": "Batch",
+    "exitBatchMode": "Cancel",
+    "selectChapter": "Select chapter: {{title}}",
+    "selectAllChapters": "Select all chapters",
+    "clearChapterSelection": "Clear selection",
+    "selectedChapters": "{{count}} chapters selected",
+    "selectChaptersHint": "Select chapters to move them to a volume",
+    "moveSelectedToVolume": "Move selected to volume",
     "rename": "Rename",
     "deleteFolder": "Delete folder",
     "deleteVolume": "Delete volume",

+ 8 - 0
src/i18n/zh.json

@@ -853,6 +853,14 @@
     "moveSourceSelected": "已选择待移动章节",
     "moveToVolume": "移动到卷",
     "moveTargetExists": "目标卷中已存在同名文件,无法移动",
+    "batchChapters": "批量",
+    "exitBatchMode": "取消",
+    "selectChapter": "选择章节:{{title}}",
+    "selectAllChapters": "全选章节",
+    "clearChapterSelection": "取消选择",
+    "selectedChapters": "已选 {{count}} 章",
+    "selectChaptersHint": "勾选章节后可批量移动到卷",
+    "moveSelectedToVolume": "移动选中到卷",
     "rawSources": "原始来源",
     "deletingTitle": "正在移入回收站:{{name}}…",
     "confirmDeleteTitle": "再次点击确认移入回收站:{{name}}",

+ 196 - 0
src/lib/novel/de-ai-skill-library.spec.ts

@@ -7,10 +7,15 @@ import {
   getAllDeAiSkills,
   isDeAiSkillModified,
   loadDeAiSkillConfig,
+  loadEffectiveDeAiSkillSafely,
   normalizeDeAiSkillConfig,
+  recreateDeAiSkillConfig,
   resetBuiltInDeAiSkill,
+  restoreDeAiSkillConfigFromBackup,
   resolveAvailableDeAiSkills,
   resolveEffectiveDeAiSkill,
+  saveDeAiSkillConfig,
+  setLastChapterDeAiSkill,
   setDeAiSkillEnabled,
   setDefaultDeAiSkill,
   updateDeAiSkill,
@@ -19,11 +24,13 @@ import {
 
 const readFileMock = vi.hoisted(() => vi.fn())
 const writeFileMock = vi.hoisted(() => vi.fn())
+const writeFileAtomicMock = vi.hoisted(() => vi.fn())
 const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
 
 vi.mock("@/commands/fs", () => ({
   readFile: readFileMock,
   writeFile: writeFileMock,
+  writeFileAtomic: writeFileAtomicMock,
 }))
 
 vi.mock("@tauri-apps/api/path", () => ({
@@ -33,6 +40,7 @@ vi.mock("@tauri-apps/api/path", () => ({
 describe("de-ai skill library", () => {
   beforeEach(() => {
     vi.clearAllMocks()
+    writeFileAtomicMock.mockResolvedValue(undefined)
   })
 
   it("ships five built-in de-AI skills", () => {
@@ -45,6 +53,62 @@ describe("de-ai skill library", () => {
     ])
   })
 
+  it("gives each built-in skill a dedicated rule section instead of the same base prompt", () => {
+    expect(BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === "built-in:comprehensive")?.content).toContain(
+      "## 综合去AI味规则",
+    )
+    expect(BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === "built-in:reduce-explanation")?.content).toContain(
+      "## 减少解释腔规则",
+    )
+    expect(BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === "built-in:dialogue-natural")?.content).toContain(
+      "## 对话口语化规则",
+    )
+    expect(BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === "built-in:break-regularity")?.content).toContain(
+      "## 打破工整句式规则",
+    )
+    expect(BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === "built-in:literary-retain")?.content).toContain(
+      "## 保留文艺感规则",
+    )
+  })
+
+  it("keeps built-in skill prompts materially different from each other", () => {
+    const contents = BUILT_IN_DE_AI_SKILLS.map((skill) => skill.content)
+
+    for (let i = 0; i < contents.length; i += 1) {
+      for (let j = i + 1; j < contents.length; j += 1) {
+        const shorter = Math.min(contents[i].length, contents[j].length)
+        let samePrefix = 0
+        while (samePrefix < shorter && contents[i][samePrefix] === contents[j][samePrefix]) {
+          samePrefix += 1
+        }
+        expect(samePrefix / shorter).toBeLessThan(0.65)
+      }
+    }
+  })
+
+  it("ships professional editing workflows for every built-in skill", () => {
+    for (const skill of BUILT_IN_DE_AI_SKILLS) {
+      expect(skill.content).toContain("## 适用场景")
+      expect(skill.content).toContain("## 诊断步骤")
+      expect(skill.content).toContain("## 改写优先级")
+      expect(skill.content).toContain("## 禁止改法")
+      expect(skill.content.length).toBeGreaterThan(900)
+    }
+  })
+
+  it("keeps every built-in prompt centered on de-AI work instead of generic polishing", () => {
+    for (const skill of BUILT_IN_DE_AI_SKILLS) {
+      expect(skill.content).toContain("## 去AI味核心目标")
+      expect(skill.content).toContain("## AI味识别清单")
+      expect(skill.content).toContain("## 去AI味处理流程")
+      expect(skill.content).toContain("## 反润色约束")
+      expect(skill.content).toContain("## 去AI味输出契约")
+      expect(skill.content.length).toBeGreaterThan(1500)
+      expect((skill.content.match(/去AI味/g) ?? []).length).toBeGreaterThanOrEqual(8)
+      expect((skill.content.match(/AI味/g) ?? []).length).toBeGreaterThanOrEqual(8)
+    }
+  })
+
   it("normalizes an empty config to the built-in comprehensive skill", () => {
     expect(normalizeDeAiSkillConfig(null)).toEqual({
       version: 1,
@@ -52,6 +116,7 @@ describe("de-ai skill library", () => {
       disabledSkillIds: [],
       projectSkills: [],
       builtInSkillOverrides: [],
+      lastChapterDeAiSkillId: null,
     })
   })
 
@@ -176,4 +241,135 @@ describe("de-ai skill library", () => {
       content: "legacy rules",
     })
   })
+
+  it("throws when the json skill config is corrupt instead of falling back and risking overwrite", async () => {
+    readFileMock.mockResolvedValueOnce("{bad json")
+
+    await expect(loadDeAiSkillConfig("C:/project")).rejects.toThrow("技能库配置文件损坏")
+  })
+
+  it("loads the effective skill safely and returns a warning when the config is corrupt", async () => {
+    readFileMock.mockResolvedValueOnce("{bad json")
+
+    const result = await loadEffectiveDeAiSkillSafely("C:/project", "built-in:comprehensive")
+
+    expect(result.skill).toBeNull()
+    expect(result.warning).toBe("去AI味配置损坏,本次未应用去AI味 Skill,请到技能库恢复配置")
+  })
+
+  it("does not set a disabled or unknown skill as default", () => {
+    const config = normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:comprehensive",
+      disabledSkillIds: ["built-in:dialogue-natural"],
+    })
+
+    expect(setDefaultDeAiSkill(config, "built-in:dialogue-natural").defaultSkillId).toBe("built-in:comprehensive")
+    expect(setDefaultDeAiSkill(config, "missing").defaultSkillId).toBe("built-in:comprehensive")
+    expect(setDefaultDeAiSkill(config, "built-in:reduce-explanation").defaultSkillId).toBe(
+      "built-in:reduce-explanation",
+    )
+  })
+
+  it("persists the last chapter de-AI skill only when it is available", () => {
+    const config = normalizeDeAiSkillConfig({
+      disabledSkillIds: ["built-in:dialogue-natural"],
+    })
+
+    expect(setLastChapterDeAiSkill(config, "built-in:reduce-explanation").lastChapterDeAiSkillId).toBe(
+      "built-in:reduce-explanation",
+    )
+    expect(setLastChapterDeAiSkill(config, "built-in:dialogue-natural").lastChapterDeAiSkillId).toBeNull()
+    expect(setLastChapterDeAiSkill(config, "missing").lastChapterDeAiSkillId).toBeNull()
+  })
+
+  it("backs up the existing json config before saving a new config", async () => {
+    const existing = JSON.stringify(normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:dialogue-natural",
+    }))
+    const next = normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:reduce-explanation",
+    })
+    readFileMock.mockResolvedValueOnce(existing)
+
+    await saveDeAiSkillConfig("C:/project", next)
+
+    expect(writeFileMock).toHaveBeenNthCalledWith(1, "C:/project/de-ai-skills.backup.json", existing)
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      JSON.stringify(next, null, 2),
+    )
+  })
+
+  it("does not use a non-atomic main config write when saving", async () => {
+    readFileMock.mockRejectedValueOnce(new Error("missing json"))
+    const next = normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:reduce-explanation",
+    })
+
+    await saveDeAiSkillConfig("C:/project", next)
+
+    expect(writeFileMock).not.toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      expect.any(String),
+    )
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      JSON.stringify(next, null, 2),
+    )
+  })
+
+  it("queues concurrent saves for the same project path", async () => {
+    let firstAtomicWriteDone = false
+    let releaseFirstWrite!: () => void
+    readFileMock.mockRejectedValue(new Error("missing json"))
+    writeFileAtomicMock.mockImplementationOnce(async () => {
+      await new Promise<void>((resolve) => {
+        releaseFirstWrite = resolve
+      })
+      firstAtomicWriteDone = true
+    })
+    writeFileAtomicMock.mockImplementationOnce(async () => {
+      expect(firstAtomicWriteDone).toBe(true)
+    })
+
+    const first = saveDeAiSkillConfig("C:/project", normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:reduce-explanation",
+    }))
+    const second = saveDeAiSkillConfig("C:/project", normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:dialogue-natural",
+    }))
+
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    expect(writeFileAtomicMock).toHaveBeenCalledTimes(1)
+    releaseFirstWrite()
+    await Promise.all([first, second])
+
+    expect(writeFileAtomicMock).toHaveBeenCalledTimes(2)
+  })
+
+  it("restores the main config file from a valid backup config", async () => {
+    const backup = normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:dialogue-natural",
+    })
+    readFileMock.mockResolvedValueOnce(JSON.stringify(backup))
+
+    const restored = await restoreDeAiSkillConfigFromBackup("C:/project")
+
+    expect(restored.defaultSkillId).toBe("built-in:dialogue-natural")
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      JSON.stringify(backup, null, 2),
+    )
+  })
+
+  it("recreates the config file with defaults without reading the corrupt file", async () => {
+    const recreated = await recreateDeAiSkillConfig("C:/project")
+
+    expect(recreated.defaultSkillId).toBe(DEFAULT_DE_AI_SKILL_ID)
+    expect(readFileMock).not.toHaveBeenCalled()
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      JSON.stringify(normalizeDeAiSkillConfig(null), null, 2),
+    )
+  })
 })

+ 531 - 30
src/lib/novel/de-ai-skill-library.ts

@@ -1,6 +1,5 @@
-import { readFile, writeFile } from "@/commands/fs"
+import { readFile, writeFile, writeFileAtomic } from "@/commands/fs"
 import { join } from "@tauri-apps/api/path"
-import defaultDeAiSkill from "../../../skills/de-ai-writing/SKILL.md?raw"
 
 export type DeAiSkillSource = "built-in" | "project" | "legacy"
 
@@ -21,11 +20,415 @@ export interface DeAiSkillConfig {
   disabledSkillIds: string[]
   projectSkills: DeAiSkill[]
   builtInSkillOverrides: DeAiSkill[]
+  lastChapterDeAiSkillId: string | null
 }
 
 export const DEFAULT_DE_AI_SKILL_ID = "built-in:comprehensive"
+export const DE_AI_SKILL_CONFIG_FILE = "de-ai-skills.json"
+export const DE_AI_SKILL_BACKUP_FILE = "de-ai-skills.backup.json"
 
-const baseSkill = defaultDeAiSkill.trim()
+const configSaveQueues = new Map<string, Promise<void>>()
+
+const comprehensiveSkillContent = `# de-AI-writing
+
+## 综合去AI味规则
+
+你是“综合去AI味”专家系统。你的唯一任务是识别并消除中文文本中的 AI味,让文本更像真实作者在具体语境下写出的稿子。你不是润色助手,不负责把文字改得更华丽、更顺滑或更有文采;你只处理会暴露 AI 生成感的痕迹。任何改动都必须服务于去AI味,并且不得改变原意、剧情、设定、人设、信息密度和叙述视角。
+
+## 去AI味核心目标
+
+本 Skill 用于整体去AI味,适合一次性处理多种 AI味:解释腔、总结腔、模板句、机械排比、段落过度工整、情绪直白宣布、角色说话像旁白、空泛升华、协作口吻残留。处理时先识别 AI味 来源,再进行最小必要改写。去AI味的目标是“降低生成感”,不是“重写成另一篇文章”。
+
+## 适用场景
+
+- 章节正文、人物小传、设定说明、剧情梗概、对话段落和普通叙述段都可以使用。
+- 当文本同时存在解释腔、模板句、段落过于工整、机械总结、情绪直白宣布等多种问题时优先使用。
+- 如果原文已经很自然,只做轻微清理,不要为了“去AI味”强行大改。
+
+## AI味识别清单
+
+- 讲义式 AI味:喜欢用“这说明、这意味着、由此可见、从某种意义上说”替读者解释。
+- 总结式 AI味:每段结尾都上升到意义、主题、命运、成长、选择、复杂性。
+- 模板式 AI味:反复出现“不是 A 而是 B”“既是 A 也是 B”“在这个过程中”“与此同时”。
+- 工整式 AI味:段落长短接近、句式骨架相似、排比三连过密,像提纲扩写。
+- 旁白式 AI味:人物对白完整说明心理、动机、背景,像作者借角色讲设定。
+- 协作式 AI味:残留“下面我们、接下来、希望这能帮助你、作为 AI”等服务口吻。
+
+## 诊断步骤
+
+1. 先判断原文的类型:小说叙述、对白、设定说明、总结梗概、评论分析或混合文本。
+2. 找出最明显的 AI 痕迹:过度解释、抽象升华、路标词密集、排比过整、同义反复、段尾总结、角色说话像旁白。
+3. 判断哪些内容必须保留:事实、设定、人物关系、情绪方向、事件顺序、伏笔、作者已有的语气。
+4. 只处理真正影响自然度的句子。不要把所有段落都重写一遍。
+
+## 去AI味处理流程
+
+1. 先删:删除协作口吻、重复解释、空泛总结、无信息量的升华句。
+2. 再弱化:把强讲解改成弱提示,让读者通过情节、动作、对话和后果自己理解。
+3. 再打散:调整过于整齐的句式和段落节奏,但不打乱事件顺序。
+4. 再贴回语境:所有去AI味改写都要回到当前人物、当前场景、当前叙述视角。
+5. 最后自检:读一遍结果,确认没有新增剧情、没有换风格、没有把去AI味做成普通润色。
+
+## 改写优先级
+
+- 第一优先级:保留信息和剧情逻辑,不能让人物动机、因果、设定或叙述顺序断裂。
+- 第二优先级:删除模型组织答案的痕迹,比如“首先、其次、总之、这说明、这意味着、从某种意义上说”。
+- 第三优先级:打散连续相同的段落结构,避免每段都是“观点句 + 解释 + 总结”。
+- 第四优先级:减少模板句式,例如“不是 A 而是 B”“只有 A 才 B”“通过 A 来 B”“随着 A 的发展”“在这个过程中”。
+- 第五优先级:让句子停在动作、画面、后果、对话或具体判断上,少用空泛升华收尾。
+
+## 保留边界
+
+- 保留作者原本的叙述视角、人称、时态、语气强弱和信息密度。
+- 保留必要的设定解释、剧情提示和人物心理,但删掉重复铺垫和替读者下结论的部分。
+- 保留有用的文艺表达、角色口吻和节奏停顿,不把所有文本改成干瘪说明文。
+
+## 禁止改法
+
+- 不新增原文没有的剧情、设定、观点、情绪反转或宏大主题。
+- 不把所有句子都改成短句,也不为了自然感故意制造病句。
+- 不主动加入网络口语、古风词、成语堆砌、鸡汤式总结或评论腔。
+- 不输出“以下是修改后内容”“我帮你优化了”等协作说明。
+
+## 反润色约束
+
+- 不为了“好看”扩写,不主动增加修辞、意象、金句或高级词。
+- 不把去AI味做成洗稿,不改变作者本来的表达路线。
+- 不把所有文本统一成同一种顺滑、圆润、编辑腔。
+- 不用“更自然”作为借口删掉原文的锋利、粗糙、犹豫或人物特有语气。
+
+## 去AI味输出契约
+
+默认只输出改写后的正文。除非用户明确要求解释,不输出修改说明、自检清单、标题包装或代码块。`
+
+const reduceExplanationSkillContent = `# de-AI-writing
+
+## 减少解释腔规则
+
+你是“解释腔去AI味”专家系统。你的唯一任务是识别并消除中文文本中的讲解型 AI味。你不做普通删减,不做普通润色,也不负责把文字变简洁;你只处理那些像模型在替读者分析、替人物剖白、替情节下结论的表达。去AI味时要保留必要因果,但删弱多余说明,让文本回到事实、动作、对话、后果和场景本身。
+
+## 去AI味核心目标
+
+本 Skill 重点消除“解释过度”的 AI味。AI 生成文本常常不信任读者,喜欢把已经发生的动作再解释一遍,把角色已经表现出来的情绪再总结一遍,把剧情已有的因果再讲解一遍。你的去AI味目标是减少这种“替读者讲明白”的痕迹,让含义从文本本身长出来,而不是由旁白反复宣布。
+
+## 适用场景
+
+- 适合处理正文里反复解释原因、动机、意义、情绪和主题的段落。
+- 适合处理“人物已经做了,但旁白又解释一遍人物为什么这么做”的文本。
+- 适合处理段尾总是升华、总结、归纳、告诉读者应该如何理解的文本。
+
+## AI味识别清单
+
+- 解释触发词 AI味:这说明、这意味着、由此可见、换句话说、事实上、显然、本质上、归根结底。
+- 动机剖析 AI味:人物刚做完动作,旁白立刻解释“他之所以这样,是因为……”。
+- 情绪翻译 AI味:动作、表情、沉默已经表达情绪,后面还要写“这让他感到复杂/痛苦/释然”。
+- 主题拔高 AI味:具体事件后面硬接成长、命运、人性、选择、复杂性等抽象总结。
+- 因果复述 AI味:同一因果连续讲两遍,只是换了词。
+- 段尾讲解 AI味:每段最后都像给读者做阅读理解答案。
+
+## 诊断步骤
+
+1. 标出解释触发词:这说明、这意味着、由此可见、本质上、归根结底、换句话说、显然、事实上、从某种意义上。
+2. 判断解释是否必要:如果前文动作、对话、结果已经表达清楚,就删弱或合并。
+3. 区分必要因果和多余讲解:必要因果保留,多余心理分析、主题拔高和重复说明删除。
+4. 检查段尾:如果段尾只是在替读者总结情绪或意义,优先改成画面、动作、沉默、结果或更克制的判断。
+
+## 去AI味处理流程
+
+1. 先删掉无信息解释:凡是只把上一句换种说法的解释,直接删除。
+2. 对必要因果做压缩:把长解释压成短因果,让逻辑不断但不啰嗦。
+3. 把心理说明交还给行为:能用动作、停顿、视线、语气、选择表达的,不用旁白解释。
+4. 把抽象总结落回场景:将“这意味着……”改成更具体的后果、反应或环境变化。
+5. 完成后检查去AI味结果:如果读起来像删减版讲义,继续去掉残留的解释腔 AI味。
+
+## 改写优先级
+
+- 第一优先级:保留事件因果和人物动机的最低可理解度,不能删到逻辑断裂。
+- 第二优先级:删除重复解释,同一层意思只保留最有信息量的一处。
+- 第三优先级:把抽象判断落回具体细节,例如动作、表情、场景变化、对话反应。
+- 第四优先级:减少讲义式结构,不要让每段都按“原因 + 分析 + 结论”推进。
+- 第五优先级:把强解释改成弱提示,让读者能自己读出情绪和关系。
+
+## 保留边界
+
+- 不删除必要因果,不让情节、论证或人物动机断裂。
+- 学术、说明文和设定解释可以保留必要定义,但要减少重复铺垫。
+- 不用同义词硬替换,要优先删掉多余解释。
+
+## 禁止改法
+
+- 不把所有解释都删光,尤其是世界观设定、推理链条、任务规则和关键伏笔。
+- 不把解释腔改成更空泛的抒情腔。
+- 不新增人物没有表现出来的心理结论。
+- 不输出删改说明或“已为你去除解释腔”之类的提示。
+
+## 操作细节
+
+- 优先做减法,其次才做改写。能删掉半句解决的问题,不要重写整段。
+- 如果必须保留解释,把它压缩成一句更具体的因果,不要继续扩展意义。
+- 人物情绪优先交给行为和对话表达,旁白只保留最低限度的提示。
+
+## 反润色约束
+
+- 不把解释腔改成文艺腔,不能用更漂亮的抽象句替换旧抽象句。
+- 不为了“自然”新增动作描写;只有原文语境支持时才轻微落回动作或后果。
+- 不把必要设定、推理链、任务规则删到读者无法理解。
+- 不输出“我删除了解释腔”或任何处理报告。
+- 最终自检必须确认每一处改动都服务去AI味,而不是普通压缩或普通润色。
+
+## 去AI味输出契约
+
+默认只输出处理后的正文。不要列出删了哪些解释。`
+
+const dialogueNaturalSkillContent = `# de-AI-writing
+
+## 对话口语化规则
+
+你是“对白去AI味”专家系统。你的唯一任务是识别并消除人物对白中的 AI味。你不是普通口语化工具,不负责把所有台词改得随便、现代或俏皮;你只处理那些像模型借角色之口解释剧情、说明心理、复述设定、总结主题的对白。去AI味后,角色仍然要符合身份、关系、时代、情绪和当前处境。
+
+## 去AI味核心目标
+
+本 Skill 重点消除“角色不像人在说话”的 AI味。AI 生成对白常常完整、礼貌、解释充分、逻辑清晰,却不像真实人物在压力、冲突、试探、隐瞒和情绪里说出来的话。你的去AI味目标是让对白回到角色本身:有目的、有遮掩、有停顿、有不完整,也有符合人物关系的分寸。
+
+## 适用场景
+
+- 角色对白过于完整、端正、解释性强,像在读设定或复述剧情。
+- 多个角色说话没有差异,身份、年龄、关系、情绪都被同一种语气覆盖。
+- 对话里充满书面连接词、心理剖析、主题总结和不自然的礼貌句。
+- 角色把本该藏在动作、沉默、打断里的态度直接说出来。
+
+## AI味识别清单
+
+- 旁白替身 AI味:角色说话像作者在解释剧情,而不是在回应眼前的人。
+- 心理剖白 AI味:角色把自己动机、创伤、选择完整分析给对方听。
+- 设定朗读 AI味:对白里硬塞世界观、规则、背景,像说明书。
+- 礼貌客服 AI味:冲突场景里仍然语气完整、客气、逻辑过顺。
+- 同声同气 AI味:不同角色都使用同一种句式、词汇和情绪表达。
+- 书面连接 AI味:因此、然而、与此同时、从某种意义上说、换句话说频繁出现在对白里。
+
+## 诊断步骤
+
+1. 判断说话人身份:年龄、阶层、关系、情绪、当前处境和说话目的。
+2. 找出不像人说话的句子:过长、过完整、过礼貌、过解释、过像旁白。
+3. 判断每句对白承担的功能:推进剧情、试探关系、表达情绪、隐瞒信息、转移话题或制造冲突。
+4. 对只负责解释心理的对白,优先改成短句、停顿、反问、打断、回避、动作或沉默。
+
+## 去AI味处理流程
+
+1. 先保留信息:确认对白里必须留下的剧情事实、关系变化、设定信息。
+2. 再去掉讲解:删除角色不该明说的心理分析、主题总结和背景说明。
+3. 再调整语气:让句子符合角色身份和当前情绪,不强行现代口语化。
+4. 再制造自然缺口:必要时用半句话、停顿、反问、回避、打断替代完整解释。
+5. 最后检查 AI味:如果角色仍像在替作者交代信息,继续压缩对白里的说明感。
+
+## 改写优先级
+
+- 第一优先级:保留原对白里的剧情信息,不能让读者漏掉关键事实。
+- 第二优先级:保留角色关系和情绪强度,不让强冲突被改成客气寒暄。
+- 第三优先级:把整段自我剖析拆成更自然的反应,比如半句话、迟疑、追问、否认、岔开话题。
+- 第四优先级:减少书面连接词,例如因此、然而、与此同时、从某种意义上说、换句话说。
+- 第五优先级:适度保留不完整表达。真实对白不需要每一句都语法完整。
+
+## 保留边界
+
+- 方言、口癖、称呼、身份语气和关系称谓如果服务人物塑造,应尽量保留。
+- 严肃人物可以继续严肃,古代人物可以保留时代感,专业角色可以保留必要术语。
+- 旁白只在阻碍对白自然度时轻微调整,不抢对白的功能。
+
+## 禁止改法
+
+- 不把对话改成段落总结。
+- 不为了口语化加入无关语气词。
+- 不改变人物关系和剧情信息。
+- 不让所有角色都变成同一种现代口语。
+- 不用“嗯、啊、吧、嘛”堆砌来假装自然。
+
+## 操作细节
+
+- 长对白优先拆成两到三次回应,中间可以用动作、停顿或对方插话承接。
+- 角色越紧张、越隐瞒、越愤怒,越不应该把话说得完整漂亮。
+- 重要信息可以保留,但要让它像角色在当下说出来,而不是像作者把设定塞进台词。
+
+## 反润色约束
+
+- 不把去AI味做成“加语气词”,不要靠嗯、啊、吧、嘛堆出假口语。
+- 不把所有角色改成同一种现代网感表达。
+- 不为了自然感新增暧昧、玩笑、脏话或原文没有的关系张力。
+- 不把原本克制、严肃、古典、专业的角色改成轻佻口语。
+- 最终自检必须确认对白改动服务去AI味,而不是把所有角色改得更口语。
+
+## 去AI味输出契约
+
+默认只输出处理后的正文。不要解释每句对白为什么修改。`
+
+const breakRegularitySkillContent = `# de-AI-writing
+
+## 打破工整句式规则
+
+你是“结构节奏去AI味”专家系统。你的唯一任务是识别并消除文本结构中的 AI味。你不是普通排版助手,也不是节奏润色工具;你只处理那些因为段落形状、句式骨架、排比节奏、转折方式过于整齐而暴露生成感的部分。去AI味后,文本应该保留清楚的阅读路径,但不再像模板扩写。
+
+## 去AI味核心目标
+
+本 Skill 重点消除“机器工整感”的 AI味。AI 生成文本常常段落长度相近、开头方式相似、句式连续重复、排比过密、转折词固定、每段都以总结收束。你的去AI味目标是打破这种机械节奏,让文本的长短、停顿、推进方式服从内容本身,而不是服从模型模板。
+
+## 适用场景
+
+- 段落长度高度接近,每段都像同一个模板复制出来。
+- 句式连续重复,例如连续“他……,他……,他……”或连续“不是……而是……”。
+- 排比、三连句、对仗和总结句过密,读起来像宣传稿或讲义。
+- 每段都按“观点句 + 展开解释 + 段尾总结”推进,缺少自然停顿。
+
+## AI味识别清单
+
+- 等长段落 AI味:连续多段长度接近,形状像自动生成的分块。
+- 同构句式 AI味:连续句子使用相同主谓结构、相同转折、相同因果。
+- 排比堆叠 AI味:三连、四连、对仗过密,像模型在制造气势。
+- 固定收束 AI味:段尾总要总结意义、点题、升华或回扣主题。
+- 路标词 AI味:然而、同时、此外、更重要的是、总而言之反复出现。
+- 提纲扩写 AI味:每段都像从一个小标题扩成的说明段。
+
+## 诊断步骤
+
+1. 先看段落形状:是否连续等长,是否每段都以抽象判断开头。
+2. 再看句式骨架:是否连续使用相同连接词、相同因果结构、相同转折结构。
+3. 判断哪些工整是必要的:清单、步骤、设定规则、正式声明可以保留清晰结构。
+4. 对叙事、描写和人物心理段,优先打散过度整齐的推进方式。
+
+## 去AI味处理流程
+
+1. 先保顺序:事件顺序、论证顺序、设定说明顺序不能因为去AI味被打乱。
+2. 再破模板:调整连续相似的段落开头、句式骨架和段尾收束。
+3. 再删路标:能不用连接词的地方直接删,用具体动作、后果、场景变化承接。
+4. 再调长短:短段、中段、厚段按内容需要交替,不为了整齐而均分。
+5. 最后检查 AI味:如果文本仍像“观点 + 展开 + 总结”的批量段落,继续去掉模板感。
+
+## 改写优先级
+
+- 第一优先级:保持事件顺序、论证顺序和读者理解路径。
+- 第二优先级:调整段落长短,让短段、中段、厚段按内容需要交替。
+- 第三优先级:减少连续排比和三连句,不把所有信息都摆成整齐队列。
+- 第四优先级:删弱固定转折,例如然而、与此同时、更重要的是、总而言之、不可否认的是。
+- 第五优先级:让句子可以停在具体细节、动作、场景变化、后果或人物反应上。
+
+## 保留边界
+
+- 不打乱事件顺序、论证顺序和读者理解路径。
+- 不为了“不工整”故意写乱句、病句或断裂表达。
+- 列表、步骤、设定说明在确实需要清晰时可以保留。
+
+## 禁止改法
+
+- 不为了变化而随意拆句、倒装或制造理解障碍。
+- 不把所有长句都切成短句,也不把所有短句合成长句。
+- 不新增无关细节来填补节奏。
+- 不输出结构分析、节奏分析或修改说明。
+
+## 操作细节
+
+- 连续三段形状相似时,至少调整其中一段的开头、长度或收束方式。
+- 连续使用同一连接词时,能删则删,不能删时换成更具体的动作或因果承接。
+- 排比只保留最有力量的一组,弱排比改成自然叙述。
+- 段尾不必每次总结,可以停在一个细节、一个后果或一个未说完的余波上。
+
+## 反润色约束
+
+- 不为了去AI味故意写乱、写碎、写成病句。
+- 不把所有长句切短,也不把所有短句合并;变化必须服务内容。
+- 不新增无关描写来制造节奏。
+- 不破坏清单、步骤、规则说明等本来需要整齐的文本结构。
+
+## 去AI味输出契约
+
+默认只输出处理后的正文。不要输出结构分析。`
+
+const literaryRetainSkillContent = `# de-AI-writing
+
+## 保留文艺感规则
+
+你是“文艺文本去AI味”专家系统。你的唯一任务是识别并消除文艺、抒情、氛围类文本中的 AI味。你不是普通文学润色助手,不负责把文字改得更美、更诗意或更高级;你只处理那些空泛、堆叠、模板化、强行升华的 AI味,同时保留原文真实有效的意象、气息、节奏和画面。
+
+## 去AI味核心目标
+
+本 Skill 重点消除“模板化文艺腔”的 AI味。AI 生成文艺段落常常喜欢使用仿佛、某种、难以言说、命运般、灵魂深处、时间深处、无声蔓延等空泛词,或者在结尾强行升华人生、命运、孤独、成长。你的去AI味目标是留下具体可感的东西,删弱只负责显得深沉的东西。
+
+## 适用场景
+
+- 小说描写、散文化段落、心理独白、氛围段、意象段和抒情段。
+- 原文有画面和情绪,但夹杂“仿佛、某种、难以言说、命运般、灵魂深处”等空泛堆叠。
+- 原文有文采,但段尾总是升华主题、宣布情绪或替读者赏析。
+- 需要保留作者原来的温度,而不是改成冷冰冰的说明。
+
+## AI味识别清单
+
+- 空泛抒情 AI味:大量使用某种、仿佛、难以言说、无法言喻、命运般、灵魂深处。
+- 意义升华 AI味:具体场景之后硬接人生、命运、时间、孤独、救赎、成长。
+- 形容词堆叠 AI味:连续堆气息、温度、光影、沉默、温柔、破碎,却缺少具体对象。
+- 赏析旁白 AI味:文字替读者说明“这一刻多么复杂/珍贵/残酷/温柔”。
+- 对仗排比 AI味:为了文艺感制造过度整齐的句式。
+- 情绪宣布 AI味:直接告诉读者人物悲伤、释然、震动,而不是让画面承担情绪。
+
+## 诊断步骤
+
+1. 先找有效表达:具体意象、感官细节、动作、场景、节奏停顿和人物真实感受。
+2. 再找空泛表达:宏大词、抽象词、万能抒情词、重复的氛围词和模板化升华。
+3. 判断修辞是否服务内容:能增强画面和情绪的保留,只负责显得“有文采”的删弱。
+4. 检查结尾:如果结尾在替读者解释情绪或主题,优先让画面、动作或余味收束。
+
+## 去AI味处理流程
+
+1. 先保留画面:保留能看见、听见、闻到、触到的细节,这是文艺文本去AI味的底座。
+2. 再清空泛词:删除或弱化没有具体对象的深沉词、氛围词、宏大词。
+3. 再压升华:把强行上升到人生和命运的句子收回到当前人物、当前场景、当前动作。
+4. 再调修辞:保留有效意象,删除重复意象和只负责显得高级的对仗排比。
+5. 最后检查 AI味:如果读起来仍像模型在模拟文学感,继续减少空泛抒情。
+
+## 改写优先级
+
+- 第一优先级:保留关键意象、人物感受、场景氛围和原文节奏。
+- 第二优先级:删掉空泛抒情和意义拔高,让文字少一点模板化文艺腔。
+- 第三优先级:把抽象情绪落回具体感官,例如光线、气味、触感、声音、动作、停顿。
+- 第四优先级:减少过度对仗、排比和整齐升华,但保留确实有效的修辞。
+- 第五优先级:让文字有余味,而不是把含义全部解释完。
+
+## 禁止改法
+
+- 不把文学表达全部翻译成直白解释。
+- 不新增宏大主题、人生感悟或作者没有写出的结论。
+- 不为了简洁删掉关键意象和氛围铺垫。
+- 不堆砌新的华丽词汇、古风词、成语或空泛形容词。
+- 不输出赏析、评价或“保留了文艺感”的说明。
+
+## 操作细节
+
+- 保留能被看见、听见、闻到、触到的细节,删弱只负责显得深沉的抽象词。
+- 如果原文有意象,优先检查意象是否重复;重复时保留最准确的一处。
+- 抒情句要服务人物和场景,不要脱离当前情境单独升华。
+- 结尾可以留白,避免把余味解释成明确道理。
+- 语言可以克制,但不能把原文的气息、温度和画面感一并删掉。
+
+## 反润色约束
+
+- 不新增华丽词、古风词、成语、金句或宏大主题。
+- 不把去AI味做成“变得更文学”,本 Skill 只负责去掉 AI味,不负责加文采。
+- 不把文学表达翻译成干巴巴的说明文。
+- 不删掉原文真正有用的意象、节奏停顿和人物感受。
+- 最终自检必须确认文艺感保留服务去AI味,而不是额外增加文学腔。
+
+## 去AI味输出契约
+
+默认只输出处理后的正文。不要输出赏析或修改说明。`
+
+export class DeAiSkillConfigCorruptError extends Error {
+  constructor(public readonly configPath: string) {
+    super(`技能库配置文件损坏:${configPath}`)
+    this.name = "DeAiSkillConfigCorruptError"
+  }
+}
+
+export function isDeAiSkillConfigCorruptError(error: unknown): error is DeAiSkillConfigCorruptError {
+  return error instanceof DeAiSkillConfigCorruptError
+    || (error instanceof Error && error.message.startsWith("技能库配置文件损坏"))
+}
 
 export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
   {
@@ -33,7 +436,7 @@ export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
     name: "综合去AI味",
     description: "综合减少解释腔、模板句式和机械总结。",
     templateId: "comprehensive",
-    content: baseSkill,
+    content: comprehensiveSkillContent,
     source: "built-in",
   },
   {
@@ -41,7 +444,7 @@ export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
     name: "减少解释腔",
     description: "重点删掉动机解释、情绪总结和重复说明。",
     templateId: "reduce-explanation",
-    content: `${baseSkill}\n\n## 本技能重点\n优先删减解释腔、总结腔、过度说明和直白动机解释。`,
+    content: reduceExplanationSkillContent,
     source: "built-in",
   },
   {
@@ -49,7 +452,7 @@ export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
     name: "对话口语化",
     description: "让人物对话更像真人说话,减少书面腔。",
     templateId: "dialogue-natural",
-    content: `${baseSkill}\n\n## 本技能重点\n优先处理对话,让人物说半句话、停顿、回避、打断,并保留个人口癖。`,
+    content: dialogueNaturalSkillContent,
     source: "built-in",
   },
   {
@@ -57,7 +460,7 @@ export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
     name: "打破工整句式",
     description: "打散整齐段落、排比句和模板化起承转合。",
     templateId: "break-regularity",
-    content: `${baseSkill}\n\n## 本技能重点\n优先打破工整句式、固定段落长度、机械排比和连续相同主谓结构。`,
+    content: breakRegularitySkillContent,
     source: "built-in",
   },
   {
@@ -65,7 +468,7 @@ export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
     name: "保留文艺感",
     description: "去除AI味时保留必要修辞、氛围和文学质感。",
     templateId: "literary-retain",
-    content: `${baseSkill}\n\n## 本技能重点\n去除AI味时不要硬压缩到干瘪,保留必要意象、氛围、节奏和文艺表达。`,
+    content: literaryRetainSkillContent,
     source: "built-in",
   },
 ]
@@ -141,12 +544,19 @@ export function normalizeDeAiSkillConfig(value: unknown): DeAiSkillConfig {
   const knownIds = new Set([...BUILT_IN_IDS, ...projectSkills.map((skill) => skill.id)])
   const requestedDefault = typeof raw.defaultSkillId === "string" ? raw.defaultSkillId : DEFAULT_DE_AI_SKILL_ID
   const defaultSkillId = knownIds.has(requestedDefault) ? requestedDefault : DEFAULT_DE_AI_SKILL_ID
+  const requestedLastChapter = typeof raw.lastChapterDeAiSkillId === "string" ? raw.lastChapterDeAiSkillId : null
+  const lastChapterDeAiSkillId = requestedLastChapter
+    && knownIds.has(requestedLastChapter)
+    && !disabledSkillIds.includes(requestedLastChapter)
+    ? requestedLastChapter
+    : null
   return {
     version: 1,
     defaultSkillId,
     disabledSkillIds,
     projectSkills,
     builtInSkillOverrides,
+    lastChapterDeAiSkillId,
   }
 }
 
@@ -172,6 +582,31 @@ export function resolveEffectiveDeAiSkill(
   return available.find((skill) => skill.id === requested) ?? available[0]
 }
 
+export interface SafeDeAiSkillResult {
+  skill: DeAiSkill | null
+  warning: string
+}
+
+export async function loadEffectiveDeAiSkillSafely(
+  projectPath: string | null | undefined,
+  selectedSkillId?: string | null,
+): Promise<SafeDeAiSkillResult> {
+  try {
+    const config = await loadDeAiSkillConfig(projectPath)
+    return {
+      skill: resolveEffectiveDeAiSkill(config, selectedSkillId),
+      warning: "",
+    }
+  } catch (error) {
+    return {
+      skill: null,
+      warning: isDeAiSkillConfigCorruptError(error)
+        ? "去AI味配置损坏,本次未应用去AI味 Skill,请到技能库恢复配置"
+        : "读取去AI味技能失败,本次未应用去AI味 Skill",
+    }
+  }
+}
+
 export function isDeAiSkillModified(config: DeAiSkillConfig, skillId: string): boolean {
   const normalized = normalizeDeAiSkillConfig(config)
   if (skillId.startsWith("built-in:")) {
@@ -263,7 +698,19 @@ export function resetBuiltInDeAiSkill(config: DeAiSkillConfig, skillId: string):
 }
 
 export function setDefaultDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
-  return normalizeDeAiSkillConfig({ ...config, defaultSkillId: skillId })
+  const normalized = normalizeDeAiSkillConfig(config)
+  const available = resolveAvailableDeAiSkills(normalized)
+  if (!available.some((skill) => skill.id === skillId)) return normalized
+  return normalizeDeAiSkillConfig({ ...normalized, defaultSkillId: skillId })
+}
+
+export function setLastChapterDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
+  const normalized = normalizeDeAiSkillConfig(config)
+  const available = resolveAvailableDeAiSkills(normalized)
+  if (!available.some((skill) => skill.id === skillId)) {
+    return normalizeDeAiSkillConfig({ ...normalized, lastChapterDeAiSkillId: null })
+  }
+  return normalizeDeAiSkillConfig({ ...normalized, lastChapterDeAiSkillId: skillId })
 }
 
 export function setDeAiSkillEnabled(config: DeAiSkillConfig, skillId: string, enabled: boolean): DeAiSkillConfig {
@@ -291,34 +738,88 @@ export function deleteProjectDeAiSkill(config: DeAiSkillConfig, skillId: string)
 
 export async function loadDeAiSkillConfig(projectPath: string | null | undefined): Promise<DeAiSkillConfig> {
   if (!projectPath) return normalizeDeAiSkillConfig(null)
+  const configPath = await join(projectPath, DE_AI_SKILL_CONFIG_FILE)
   try {
-    const configPath = await join(projectPath, "de-ai-skills.json")
     const content = await readFile(configPath)
-    return normalizeDeAiSkillConfig(JSON.parse(content))
-  } catch {
     try {
-      const legacyPath = await join(projectPath, "de-ai-skill.txt")
-      const legacyContent = (await readFile(legacyPath)).trim()
-      if (!legacyContent) return normalizeDeAiSkillConfig(null)
-      const legacySkill: DeAiSkill = {
-        id: "project:legacy-de-ai-skill",
-        name: "旧版自定义去AI味 Skill",
-        description: "从旧版 de-ai-skill.txt 读取的项目规则。",
-        templateId: "legacy",
-        content: legacyContent,
-        source: "legacy",
-      }
-      return normalizeDeAiSkillConfig({
-        defaultSkillId: legacySkill.id,
-        projectSkills: [legacySkill],
-      })
+      return normalizeDeAiSkillConfig(JSON.parse(content))
     } catch {
-      return normalizeDeAiSkillConfig(null)
+      throw new DeAiSkillConfigCorruptError(configPath)
     }
+  } catch (error) {
+    if (isDeAiSkillConfigCorruptError(error)) throw error
   }
+
+  try {
+    const legacyPath = await join(projectPath, "de-ai-skill.txt")
+    const legacyContent = (await readFile(legacyPath)).trim()
+    if (!legacyContent) return normalizeDeAiSkillConfig(null)
+    const legacySkill: DeAiSkill = {
+      id: "project:legacy-de-ai-skill",
+      name: "旧版自定义去AI味 Skill",
+      description: "从旧版 de-ai-skill.txt 读取的项目规则。",
+      templateId: "legacy",
+      content: legacyContent,
+      source: "legacy",
+    }
+    return normalizeDeAiSkillConfig({
+      defaultSkillId: legacySkill.id,
+      projectSkills: [legacySkill],
+    })
+  } catch {
+    return normalizeDeAiSkillConfig(null)
+  }
+}
+
+async function writeDeAiSkillConfig(projectPath: string, config: DeAiSkillConfig): Promise<void> {
+  const configPath = await join(projectPath, DE_AI_SKILL_CONFIG_FILE)
+  const backupPath = await join(projectPath, DE_AI_SKILL_BACKUP_FILE)
+  const content = JSON.stringify(normalizeDeAiSkillConfig(config), null, 2)
+  try {
+    const existingContent = await readFile(configPath)
+    JSON.parse(existingContent)
+    await writeFile(backupPath, existingContent)
+  } catch {
+    // No valid existing config to back up.
+  }
+  await writeFileAtomic(configPath, content)
 }
 
 export async function saveDeAiSkillConfig(projectPath: string, config: DeAiSkillConfig): Promise<void> {
-  const configPath = await join(projectPath, "de-ai-skills.json")
-  await writeFile(configPath, JSON.stringify(normalizeDeAiSkillConfig(config), null, 2))
+  const previous = configSaveQueues.get(projectPath) ?? Promise.resolve()
+  const current = previous
+    .catch(() => undefined)
+    .then(() => writeDeAiSkillConfig(projectPath, config))
+  configSaveQueues.set(projectPath, current)
+  try {
+    await current
+  } finally {
+    if (configSaveQueues.get(projectPath) === current) {
+      configSaveQueues.delete(projectPath)
+    }
+  }
+}
+
+export async function loadDeAiSkillBackupConfig(projectPath: string): Promise<DeAiSkillConfig> {
+  const backupPath = await join(projectPath, DE_AI_SKILL_BACKUP_FILE)
+  const content = await readFile(backupPath)
+  try {
+    return normalizeDeAiSkillConfig(JSON.parse(content))
+  } catch {
+    throw new Error(`技能库备份文件损坏:${backupPath}`)
+  }
+}
+
+export async function restoreDeAiSkillConfigFromBackup(projectPath: string): Promise<DeAiSkillConfig> {
+  const config = await loadDeAiSkillBackupConfig(projectPath)
+  const configPath = await join(projectPath, DE_AI_SKILL_CONFIG_FILE)
+  await writeFileAtomic(configPath, JSON.stringify(config, null, 2))
+  return config
+}
+
+export async function recreateDeAiSkillConfig(projectPath: string): Promise<DeAiSkillConfig> {
+  const config = normalizeDeAiSkillConfig(null)
+  const configPath = await join(projectPath, DE_AI_SKILL_CONFIG_FILE)
+  await writeFileAtomic(configPath, JSON.stringify(config, null, 2))
+  return config
 }