Browse Source

merge: 合并去AI味技能库

Mochocyang 3 months ago
parent
commit
20900b9fe1

+ 30 - 0
jinengku.md

@@ -0,0 +1,30 @@
+# 技能库分支说明
+
+## 分支目标
+
+实现一级入口「技能库」,第一版只管理多个去AI味技能,并接入 AI 会话、整章去AI味和选中文本去AI味。
+
+## 使用要求
+
+- 本分支只处理去AI味技能库,不扩展通用 Skill 库。
+- 不删除旧版 `de-ai-skill.txt`。
+- 不在本分支处理打包和 GitHub 推送。
+
+## 更新记录
+
+- 2026-06-29:创建分支,准备按已确认计划实现技能库。
+- 2026-06-29:新增去AI味技能库数据层、一级入口、管理界面、AI 会话技能选择、章节去AI味技能选择,并从灵魂入口移除旧单技能编辑入口。
+- 2026-06-29:根据界面测试反馈,将「技能库」从右侧主内容区调整到左侧章节列表所在区域,右侧保持章节写作工作区。
+- 2026-06-29:修复技能库布局:左侧只显示技能列表,右侧主内容区显示当前 Skill 内容和编辑表单。
+- 2026-06-29:内置去AI味 Skill 支持当前项目内编辑,并增加「恢复默认」按钮。
+- 2026-06-29:完成第一阶段优化:未保存提醒、已修改标记、调用时显示实际使用的 Skill。
+- 2026-06-29:完成第二阶段编辑体验优化:保存按钮只在真实改动后可用,新增「放弃修改」,并支持 Ctrl+S / Cmd+S 保存当前 Skill。
+
+## 提交状态
+
+- 技能库功能代码准备提交并合并到 main。
+- 布局修复、内置 Skill 编辑改动、第一阶段优化和第二阶段优化纳入本次提交。
+- 已完成 Windows 本地便携版打包测试。
+- 第二阶段按用户要求未打包。
+- 本次按用户要求合并到 main。
+- 尚未推送 GitHub。

+ 27 - 5
src/components/chat/chat-panel.tsx

@@ -11,6 +11,7 @@ import { ChatInput } from "./chat-input"
 import { ChatModelSelector } from "./chat-model-selector"
 import { useChatStore, chatMessagesToLLM, type DisplayMessage } from "@/stores/chat-store"
 import { useWikiStore } from "@/stores/wiki-store"
+import { DeAiSkillPicker } from "@/components/skill-library/de-ai-skill-picker"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { streamChat, type ChatMessage as LLMMessage } from "@/lib/llm-client"
 import { executeIngestWrites } from "@/lib/ingest"
@@ -18,7 +19,8 @@ import { routeTask, buildTaskDirective } from "@/lib/novel/task-router"
 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 { buildQmQuaiSystemPrompt, injectDeAiDirective } from "@/lib/novel/de-ai-adapter"
+import { buildDeAiSkillSystemPrompt, buildQmQuaiSystemPrompt, injectDeAiDirective } from "@/lib/novel/de-ai-adapter"
+import { loadDeAiSkillConfig, resolveEffectiveDeAiSkill } 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"
@@ -182,11 +184,16 @@ export function ChatPanel() {
   const removeLastAssistantMessage = useChatStore((s) => s.removeLastAssistantMessage)
   const maxHistoryMessages = useChatStore((s) => s.maxHistoryMessages)
   const isConversationStreaming = useChatStore((s) => s.isConversationStreaming)
+  const conversations = useChatStore((s) => s.conversations)
+  const setConversationDeAiSkillId = useChatStore((s) => s.setConversationDeAiSkillId)
   // Derive active messages via selector to re-render on message changes
   const allMessages = useChatStore((s) => s.messages)
   const activeMessages = activeConversationId
     ? allMessages.filter((m) => m.conversationId === activeConversationId)
     : []
+  const activeConversation = activeConversationId
+    ? conversations.find((conversation) => conversation.id === activeConversationId) ?? null
+    : null
 
   // 当前活跃会话的流式内容
   const streamingContent = activeConversationId ? streamingContents[activeConversationId] ?? "" : ""
@@ -1007,6 +1014,17 @@ export function ChatPanel() {
       const activeConvMessages = useChatStore.getState().getActiveMessages()
         .filter((m) => m.role === "user" || m.role === "assistant")
         .slice(-maxHistoryMessages)
+      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)
+      if (effectiveDeAiSkill) {
+        systemMessages.push({
+          role: "system",
+          content: buildDeAiSkillSystemPrompt(effectiveDeAiSkill.content),
+        })
+      }
 
       // Prepend the language reminder onto the final user turn rather than
       // inserting a second {role:"system"} between history and the final
@@ -1029,11 +1047,8 @@ export function ChatPanel() {
         }
       }
 
-      const conversations = useChatStore.getState().conversations
-      // 使用 capturedConvId 而非闭包中的 activeConversationId,防止切换会话后取错会话
-      const activeConv = conversations.find(c => c.id === capturedConvId)
       const deAiMode = activeConv?.deAiMode ?? false
-      if (deAiMode && llmMessages.length > 0) {
+      if (!effectiveDeAiSkill && deAiMode && llmMessages.length > 0) {
         const lastIdx = llmMessages.length - 1
         const last = llmMessages[lastIdx]
         if (last && last.role === "user" && typeof last.content === "string") {
@@ -1511,6 +1526,13 @@ export function ChatPanel() {
               <TooltipProvider delay={200}>
                 <div className="flex items-center gap-2 flex-nowrap overflow-x-auto">
                   <ChatDockControls />
+                  <DeAiSkillPicker
+                    value={activeConversation?.selectedDeAiSkillId}
+                    onChange={(skillId) => {
+                      const convId = useChatStore.getState().activeConversationId
+                      if (convId) setConversationDeAiSkillId(convId, skillId)
+                    }}
+                  />
                   {novelMode && (
                     <>
                       <Button

+ 8 - 0
src/components/layout/content-area.tsx

@@ -2,6 +2,7 @@ import { Suspense, lazy } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { WritingWorkspace } from "./writing-workspace"
 import { SearchView } from "@/components/search/search-view"
+import { SkillLibraryView } from "@/components/skill-library/skill-library-view"
 
 const ChatPanel = lazy(async () => {
   const mod = await import("@/components/chat/chat-panel")
@@ -95,6 +96,13 @@ export function ContentArea() {
           </Suspense>
         )
         break
+      case "skillLibrary":
+        content = (
+          <Suspense fallback={<LoadingView />}>
+            <SkillLibraryView />
+          </Suspense>
+        )
+        break
       case "lint":
         content = (
           <Suspense fallback={<LoadingView />}>

+ 2 - 1
src/components/layout/icon-sidebar.tsx

@@ -1,6 +1,6 @@
 import { useState, useRef, useEffect, useMemo } from "react"
 import {
-  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Eye, SunMoon, Check, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama,
+  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Eye, SunMoon, Check, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama, WandSparkles,
 } from "lucide-react"
 import { createPortal } from "react-dom"
 import {
@@ -46,6 +46,7 @@ const CONFIGURABLE_NAV_ITEMS: ConfigurableNavItem[] = [
   { id: "graph", view: "graph", icon: Network, labelKey: "novel.nav.graph" },
   { id: "lint", view: "lint", icon: Brain, labelKey: "novel.nav.lint" },
   { id: "soul", view: "soul", icon: Sparkles, labelKey: "novel.nav.soul" },
+  { id: "skillLibrary", view: "skillLibrary", icon: WandSparkles, labelKey: "novel.nav.skillLibrary" },
   { id: "bookAnalysis", view: "bookAnalysis", icon: BookOpen, labelKey: "novel.nav.dismantling" },
   { id: "reviewCenter", view: "reviewCenter", icon: LayoutDashboard, labelKey: "novel.nav.reviewCenter" },
   { id: "storySimulation", view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },

+ 147 - 17
src/components/layout/preview-panel.tsx

@@ -22,6 +22,13 @@ import { getNextChatExpanded } from "./chat-layout"
 import { DeAiPreviewDialog } from "@/components/novel/de-ai-preview-dialog"
 import { TextTransformPreviewDialog } from "@/components/novel/text-transform-preview-dialog"
 import { buildDeAiRewriteMessages } from "@/lib/novel/de-ai-adapter"
+import {
+  isDeAiSkillModified,
+  loadDeAiSkillConfig,
+  resolveAvailableDeAiSkills,
+  resolveEffectiveDeAiSkill,
+  type DeAiSkill,
+} from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { streamChat } from "@/lib/llm-client"
 import { makeChapterFileName, makeDefaultChapterTitle } from "@/lib/wiki-filename"
@@ -183,11 +190,18 @@ export function PreviewPanel() {
   const [deAiPreviewOpen, setDeAiPreviewOpen] = useState(false)
   const [deAiSourceContent, setDeAiSourceContent] = useState("")
   const [deAiCandidateContent, setDeAiCandidateContent] = useState("")
+  const [deAiSkillName, setDeAiSkillName] = useState("")
   const [selectionTransformOpen, setSelectionTransformOpen] = useState(false)
   const [selectionTransformAction, setSelectionTransformAction] = useState<ChapterSelectionAction | null>(null)
   const [selectionTransformSelection, setSelectionTransformSelection] = useState<ChapterBodySelection | null>(null)
   const [selectionTransformSourceContent, setSelectionTransformSourceContent] = useState("")
   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 [pendingSelectionForDeAi, setPendingSelectionForDeAi] = useState<ChapterBodySelection | null>(null)
   const [chapterTitleDraft, setChapterTitleDraft] = useState("")
   const [chapterTitleEditing, setChapterTitleEditing] = useState(false)
   const [chapterTitleWidthPx, setChapterTitleWidthPx] = useState(CHAPTER_TITLE_MIN_WIDTH_PX)
@@ -266,6 +280,8 @@ export function PreviewPanel() {
     selectedFileRef.current = selectedFile
     setSelectionTransformOpen(false)
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
+    setSelectionTransformSkillName("")
     setLoadedFilePath(null)
 
     if (!selectedFile) {
@@ -750,23 +766,24 @@ export function PreviewPanel() {
     startOutlineIngestTask(project.path, selectedFile)
   }, [canIngestOutline, isOutlineIngesting, project, selectedFile])
 
-  const handleDeAiProcess = useCallback(async () => {
+  const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
     if (!fileContent.trim()) return
     setDeAiProcessing(true)
+    setDeAiSkillName(skillName)
     const state = useWikiStore.getState()
     const llmConfig = resolveDefaultModel(state.llmConfig)
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setDeAiProcessing(false)
+      setSaveStatus("未配置可用的 AI 模型,无法去AI味")
       return
     }
-    const { loadSmartDeAiSkill } = await import("@/lib/novel/de-ai-adapter")
-    const customDeAiSkill = await loadSmartDeAiSkill(project?.path ?? null, "去AI味润色", undefined)
+    setSaveStatus(`去AI味处理中,使用 Skill:${skillName}...`)
     const source = fileContent
     let result = ""
     try {
       await streamChat(
         llmConfig,
-        buildDeAiRewriteMessages(source, customDeAiSkill || undefined),
+        buildDeAiRewriteMessages(source, skillContent),
         {
           onToken: (token) => {
             result += token
@@ -776,6 +793,7 @@ export function PreviewPanel() {
             setDeAiCandidateContent(result)
             setDeAiPreviewOpen(true)
             setDeAiProcessing(false)
+            setSaveStatus(`本次使用 Skill:${skillName}`)
           },
           onError: (error) => {
             console.error("去AI味处理失败:", error)
@@ -791,6 +809,7 @@ export function PreviewPanel() {
 
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
     handleSave(replaceWholeChapterBody(fileContent, deAiCandidateContent))
   }, [deAiCandidateContent, fileContent, handleSave])
 
@@ -814,9 +833,15 @@ export function PreviewPanel() {
 
   const handleDeAiClose = useCallback(() => {
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
   }, [])
 
-  const handleSelectionAction = useCallback(async (action: ChapterSelectionAction, selection: ChapterBodySelection) => {
+  const runSelectionTransform = useCallback(async (
+    action: ChapterSelectionAction,
+    selection: ChapterBodySelection,
+    skillContent?: string,
+    skillName?: string,
+  ) => {
     if (!selection.text.trim()) return
     const state = useWikiStore.getState()
     const llmConfig = resolveDefaultModel(state.llmConfig)
@@ -827,14 +852,10 @@ export function PreviewPanel() {
 
     const actionFile = selectedFileRef.current
     const actionLabel = action === "polish" ? "AI润色" : "去AI味"
-    setSaveStatus(`${actionLabel}处理中...`)
-
-    const { loadSmartDeAiSkill } = await import("@/lib/novel/de-ai-adapter")
-    const customDeAiSkill = await loadSmartDeAiSkill(
-      project?.path ?? null,
-      action === "de-ai" ? "去AI味" : "润色",
-      undefined
-    )
+    setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
+    setSaveStatus(action === "de-ai" && skillName
+      ? `${actionLabel}处理中,使用 Skill:${skillName}...`
+      : `${actionLabel}处理中...`)
 
     let result = ""
     try {
@@ -842,7 +863,7 @@ export function PreviewPanel() {
         llmConfig,
         action === "polish"
           ? buildPolishSelectionMessages(selection.text)
-          : buildDeAiRewriteMessages(selection.text, customDeAiSkill || undefined),
+          : buildDeAiRewriteMessages(selection.text, skillContent),
         {
           onToken: (token) => {
             result += token
@@ -853,6 +874,7 @@ export function PreviewPanel() {
             setSelectionTransformSelection(selection)
             setSelectionTransformSourceContent(selection.text)
             setSelectionTransformCandidateContent(result)
+            setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
             setSelectionTransformOpen(true)
             setSaveStatus("")
           },
@@ -871,6 +893,60 @@ export function PreviewPanel() {
     }
   }, [])
 
+  const openDeAiSkillPicker = useCallback(async (selection: ChapterBodySelection | null) => {
+    if (deAiProcessing) return
+    setPendingSelectionForDeAi(selection)
+    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)
+    }
+  }, [deAiProcessing, project?.path])
+
+  const handlePickedDeAiSkill = useCallback(async (skillId: string) => {
+    const selection = pendingSelectionForDeAi
+    setDeAiSkillPickerOpen(false)
+    setPendingSelectionForDeAi(null)
+
+    let skill = deAiSkillPickerSkills.find((item) => item.id === skillId) ?? null
+    if (!skill) {
+      const config = await loadDeAiSkillConfig(project?.path ?? null)
+      skill = resolveEffectiveDeAiSkill(config, skillId)
+    }
+    if (!skill) {
+      setSaveStatus("暂无可用去AI味技能")
+      return
+    }
+
+    if (selection) {
+      await runSelectionTransform("de-ai", selection, skill.content, skill.name)
+      return
+    }
+    await runWholeChapterDeAi(skill.content, skill.name)
+  }, [deAiSkillPickerSkills, pendingSelectionForDeAi, project?.path, runSelectionTransform, runWholeChapterDeAi])
+
+  const handleSelectionAction = useCallback((action: ChapterSelectionAction, selection: ChapterBodySelection) => {
+    if (action === "de-ai") {
+      void openDeAiSkillPicker(selection)
+      return
+    }
+    void runSelectionTransform(action, selection)
+  }, [openDeAiSkillPicker, runSelectionTransform])
+
   const handleApplySelectionTransform = useCallback(() => {
     if (!selectionTransformSelection || !selectionTransformCandidateContent) return
 
@@ -894,6 +970,7 @@ export function PreviewPanel() {
     setSelectionTransformSelection(null)
     setSelectionTransformSourceContent("")
     setSelectionTransformCandidateContent("")
+    setSelectionTransformSkillName("")
     setSaveStatus("")
   }, [fileContent, handleSave, selectionTransformCandidateContent, selectionTransformSelection])
 
@@ -903,6 +980,7 @@ export function PreviewPanel() {
     setSelectionTransformSelection(null)
     setSelectionTransformSourceContent("")
     setSelectionTransformCandidateContent("")
+    setSelectionTransformSkillName("")
   }, [])
 
   useEffect(() => {
@@ -1065,7 +1143,7 @@ export function PreviewPanel() {
                       type="button"
                       onClick={() => {
                         setChapterToolbarMoreOpen(false)
-                        void handleDeAiProcess()
+                        void openDeAiSkillPicker(null)
                       }}
                       disabled={deAiProcessing}
                       className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
@@ -1180,7 +1258,7 @@ export function PreviewPanel() {
             <div className="relative shrink-0">
               <button
                 type="button"
-                onClick={() => void handleDeAiProcess()}
+                onClick={() => void openDeAiSkillPicker(null)}
                 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"
               >
@@ -1329,10 +1407,60 @@ 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 className="mb-1 flex items-center justify-between gap-2 px-1">
+            <div className="truncate text-sm font-medium">
+              {pendingSelectionForDeAi ? "选择选中文本去AI味技能" : "选择去AI味技能"}
+            </div>
+            <button
+              type="button"
+              className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+              onClick={() => {
+                setDeAiSkillPickerOpen(false)
+                setPendingSelectionForDeAi(null)
+              }}
+              aria-label="关闭技能选择"
+              title="关闭技能选择"
+            >
+              <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>
+          )}
+        </div>
+      ) : null}
       <DeAiPreviewDialog
         open={deAiPreviewOpen}
         sourceContent={deAiSourceContent}
         candidateContent={deAiCandidateContent}
+        skillName={deAiSkillName}
         onApply={handleDeAiApply}
         onSaveDraft={() => void handleDeAiSaveDraft()}
         onClose={handleDeAiClose}
@@ -1340,7 +1468,9 @@ export function PreviewPanel() {
       <TextTransformPreviewDialog
         open={selectionTransformOpen}
         title={selectionTransformAction === "polish" ? "AI润色预览" : "去AI味预览"}
-        description="确认后会替换当前选中的正文片段。"
+        description={selectionTransformAction === "de-ai" && selectionTransformSkillName
+          ? `本次使用 Skill:${selectionTransformSkillName}。确认后会替换当前选中的正文片段。`
+          : "确认后会替换当前选中的正文片段。"}
         sourceLabel="原文片段"
         candidateLabel={selectionTransformAction === "polish" ? "润色结果" : "去AI味结果"}
         sourceContent={selectionTransformSourceContent}

+ 5 - 0
src/components/layout/sidebar-panel.tsx

@@ -24,6 +24,7 @@ import { SoulSidebarPanel } from "./soul-sidebar-panel"
 import { ReviewCenterSidebarPanel } from "./review-center-sidebar-panel"
 import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
 import { FrameworkList } from "@/components/novel/story-simulation/framework-list"
+import { SkillLibrarySidebarPanel } from "@/components/skill-library/skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -1272,6 +1273,10 @@ export function SidebarPanel() {
     return <SoulSidebarPanel />
   }
 
+  if (activeView === "skillLibrary") {
+    return <SkillLibrarySidebarPanel />
+  }
+
   if (activeView === "reviewCenter") {
     return <ReviewCenterSidebarPanel />
   }

+ 0 - 11
src/components/layout/soul-sidebar-panel.tsx

@@ -119,17 +119,6 @@ export function SoulSidebarPanel() {
               <div className="mt-1 text-xs opacity-80">{t("novel.soul.projectSoulDesc")}</div>
             </button>
 
-            <button
-              type="button"
-              onClick={() => setSelectedSoulId("de-ai-skill")}
-              className={`mb-1 w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
-                selectedSoulId === "de-ai-skill" ? "qm-selected" : "text-muted-foreground qm-hover"
-              }`}
-            >
-              <div className="font-medium">去AI味Skill</div>
-              <div className="mt-1 text-xs opacity-80">自定义去AI味规则,应用到全局</div>
-            </button>
-
             <div className="mt-3 rounded-md border bg-muted/20 p-3">
               <div className="text-xs font-medium text-muted-foreground">已绑定人物</div>
               {bindings.length > 0 ? (

+ 6 - 1
src/components/novel/de-ai-preview-dialog.tsx

@@ -11,6 +11,7 @@ export interface DeAiPreviewDialogProps {
   open: boolean
   sourceContent: string
   candidateContent: string
+  skillName?: string
   onApply: () => void
   onSaveDraft: () => void
   onClose: () => void
@@ -20,6 +21,7 @@ export function DeAiPreviewDialog({
   open,
   sourceContent,
   candidateContent,
+  skillName,
   onApply,
   onSaveDraft,
   onClose,
@@ -29,6 +31,9 @@ export function DeAiPreviewDialog({
       <DialogContent className="sm:max-w-3xl">
         <DialogHeader>
           <DialogTitle>去AI味预览</DialogTitle>
+          {skillName ? (
+            <div className="text-xs text-muted-foreground">本次使用 Skill:{skillName}</div>
+          ) : null}
         </DialogHeader>
         <div className="grid grid-cols-2 gap-4">
           <div className="flex flex-col gap-2">
@@ -52,4 +57,4 @@ export function DeAiPreviewDialog({
       </DialogContent>
     </Dialog>
   )
-}
+}

+ 0 - 11
src/components/novel/soul-view.tsx

@@ -1,22 +1,11 @@
 import { useWikiStore } from "@/stores/wiki-store"
 import { SoulDocEditor } from "./soul-doc-editor"
 import { CharacterAuraView } from "./character-aura-view"
-import { DeAiSkillEditor } from "./de-ai-skill-editor"
 
 export function SoulView() {
   const selectedSoulId = useWikiStore((s) => s.selectedSoulId)
   const selectedSoulTab = useWikiStore((s) => s.selectedSoulTab)
 
-  if (selectedSoulId === "de-ai-skill") {
-    return (
-      <div className="flex h-full overflow-y-auto p-6">
-        <div className="mx-auto max-w-3xl">
-          <DeAiSkillEditor />
-        </div>
-      </div>
-    )
-  }
-
   if (selectedSoulTab === "project" || selectedSoulId === "project-soul") {
     return (
       <div className="flex h-full overflow-y-auto p-6">

+ 1 - 0
src/components/settings/interface-sidebar-nav.spec.ts

@@ -17,6 +17,7 @@ describe("settings sidebar nav preferences", () => {
 
   it("renders a Chinese sidebar feature visibility section in interface settings", () => {
     expect(interfaceSectionSource).toContain("SIDEBAR_NAV_LABEL_KEYS")
+    expect(interfaceSectionSource).toContain("skillLibrary")
     expect(interfaceSectionSource).toContain('setDraft("sidebarNavConfig"')
     expect(interfaceSectionSource).toContain('type="checkbox"')
     expect(zh.settings.sections.interface.sidebarNavTitle).toBe("左侧功能栏")

+ 1 - 0
src/components/settings/sections/interface-section.tsx

@@ -29,6 +29,7 @@ const SIDEBAR_NAV_LABEL_KEYS: Record<SidebarNavItemId, string> = {
   graph: "novel.nav.graph",
   lint: "novel.nav.lint",
   soul: "novel.nav.soul",
+  skillLibrary: "novel.nav.skillLibrary",
   bookAnalysis: "novel.nav.dismantling",
   reviewCenter: "novel.nav.reviewCenter",
   storySimulation: "novel.nav.storySimulation",

+ 79 - 0
src/components/skill-library/de-ai-skill-picker.spec.tsx

@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+
+import { act } 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"
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const readFileMock = vi.hoisted(() => vi.fn())
+const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: readFileMock,
+  writeFile: vi.fn(),
+}))
+
+vi.mock("@tauri-apps/api/path", () => ({
+  join: joinMock,
+}))
+
+async function renderPicker() {
+  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()} />)
+  })
+  return { container, root }
+}
+
+function cleanup(root: Root, container: HTMLElement) {
+  act(() => root.unmount())
+  document.body.removeChild(container)
+}
+
+describe("DeAiSkillPicker", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    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: 2000,
+      }],
+    }))
+    useWikiStore.getState().setProject({
+      id: "p1",
+      name: "测试项目",
+      path: "C:/project",
+    })
+  })
+
+  it("shows the effective de-AI skill and marks modified skills", async () => {
+    const { container, root } = await renderPicker()
+
+    const button = container.querySelector<HTMLButtonElement>("button")
+    expect(button?.textContent).toContain("去AI味:综合去AI味-项目版")
+    expect(button?.title).toBe("当前去AI味 Skill:综合去AI味-项目版")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    expect(container.textContent).toContain("已修改")
+
+    cleanup(root, container)
+  })
+})

+ 106 - 0
src/components/skill-library/de-ai-skill-picker.tsx

@@ -0,0 +1,106 @@
+import { useEffect, 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"
+
+interface DeAiSkillPickerProps {
+  value?: string | null
+  onChange: (skillId: string | null | undefined) => void
+  includeDisableOption?: boolean
+  buttonLabel?: string
+}
+
+export function DeAiSkillPicker({
+  value,
+  onChange,
+  includeDisableOption = true,
+  buttonLabel,
+}: 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[]>([])
+
+  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("未启用")
+        }
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [project?.path, value])
+
+  return (
+    <div 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}`}
+      >
+        <WandSparkles className="h-4 w-4" />
+        <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>
+          ))}
+          {includeDisableOption ? (
+            <button
+              type="button"
+              className="mt-1 flex w-full items-center gap-2 rounded px-2 py-2 text-left text-muted-foreground hover:bg-accent"
+              onClick={() => {
+                onChange(null)
+                setOpen(false)
+              }}
+            >
+              <X className="h-3.5 w-3.5" />
+              关闭去AI味技能
+            </button>
+          ) : null}
+        </div>
+      ) : null}
+    </div>
+  )
+}

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

@@ -0,0 +1,301 @@
+// @vitest-environment jsdom
+
+import { act } 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 { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view"
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const readFileMock = vi.hoisted(() => vi.fn())
+const writeFileMock = 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,
+}))
+
+vi.mock("@tauri-apps/api/path", () => ({
+  join: joinMock,
+}))
+
+async function renderView() {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  await act(async () => {
+    root.render(<SkillLibraryView />)
+  })
+  await flushEffects()
+  return { container, root }
+}
+
+async function renderLibrary() {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  await act(async () => {
+    root.render(
+      <>
+        <SkillLibrarySidebarPanel />
+        <SkillLibraryView />
+      </>,
+    )
+  })
+  await flushEffects()
+  return { container, root }
+}
+
+function cleanup(root: Root, container: HTMLElement) {
+  act(() => root.unmount())
+  document.body.removeChild(container)
+}
+
+async function flushEffects() {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
+  })
+}
+
+async function setInputValue(input: HTMLInputElement, value: string) {
+  await act(async () => {
+    const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
+    valueSetter?.call(input, value)
+    input.dispatchEvent(new InputEvent("input", {
+      bubbles: true,
+      inputType: value ? "insertText" : "deleteContentBackward",
+      data: value,
+    }))
+    input.dispatchEvent(new Event("change", { bubbles: true }))
+  })
+}
+
+async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+  await act(async () => {
+    const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+    valueSetter?.call(textarea, value)
+    textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }))
+    textarea.dispatchEvent(new Event("change", { bubbles: true }))
+  })
+}
+
+describe("SkillLibraryView", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    savedConfigContent = ""
+    readFileMock.mockImplementation(async () => {
+      if (savedConfigContent) return savedConfigContent
+      throw new Error("missing")
+    })
+    writeFileMock.mockImplementation(async (_path: string, content: string) => {
+      savedConfigContent = content
+    })
+    useWikiStore.getState().setProject({
+      id: "p1",
+      name: "测试项目",
+      path: "C:/project",
+    })
+    useWikiStore.getState().setSkillLibraryDraftDirty(false)
+    useWikiStore.getState().setSelectedSkillLibrarySkillId(null)
+  })
+
+  it("renders built-in skills and selected detail editor", async () => {
+    const { container, root } = await renderView()
+
+    expect(container.querySelector('[data-testid="skill-library-view"]')).not.toBeNull()
+    expect(container.textContent).toContain("综合去AI味")
+    expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("综合去AI味")
+
+    cleanup(root, container)
+  })
+
+  it("allows editing a built-in skill and restoring its default content", async () => {
+    const { container, root } = await renderView()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    const contentInput = container.querySelector<HTMLTextAreaElement>('[data-testid="skill-content-input"]')
+    const saveButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-save-button"]')
+    const resetButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-reset-default-button"]')
+
+    expect(nameInput).not.toBeNull()
+    expect(contentInput).not.toBeNull()
+    expect(saveButton).not.toBeNull()
+    expect(resetButton).not.toBeNull()
+    expect(nameInput?.disabled).toBe(false)
+    expect(contentInput?.disabled).toBe(false)
+    expect(saveButton?.disabled).toBe(true)
+    expect(resetButton?.disabled).toBe(true)
+
+    await setInputValue(nameInput!, "综合去AI味-项目版")
+    await setTextareaValue(contentInput!, "当前项目覆盖后的内置规则")
+    expect(saveButton?.disabled).toBe(false)
+    await act(async () => {
+      saveButton?.click()
+    })
+
+    const saved = JSON.parse(savedConfigContent)
+    expect(saved.builtInSkillOverrides[0]).toMatchObject({
+      id: "built-in:comprehensive",
+      name: "综合去AI味-项目版",
+      content: "当前项目覆盖后的内置规则",
+    })
+    expect(container.querySelector<HTMLButtonElement>('[data-testid="skill-reset-default-button"]')?.disabled).toBe(
+      false,
+    )
+
+    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
+    await act(async () => {
+      container.querySelector<HTMLButtonElement>('[data-testid="skill-reset-default-button"]')?.click()
+    })
+
+    const restored = JSON.parse(savedConfigContent)
+    expect(restored.builtInSkillOverrides).toEqual([])
+    expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("综合去AI味")
+    expect(confirmSpy).toHaveBeenCalledWith(
+      "确定将「综合去AI味-项目版」恢复为内置默认内容吗?当前项目对此技能的修改会被清除。",
+    )
+    confirmSpy.mockRestore()
+
+    cleanup(root, container)
+  })
+
+  it("warns before switching skills when the current draft is unsaved", 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 reduceExplanationCard = container.querySelector<HTMLElement>('[data-skill-id="built-in:reduce-explanation"]')
+    expect(reduceExplanationCard).not.toBeUndefined()
+
+    await act(async () => {
+      reduceExplanationCard?.click()
+    })
+    await flushEffects()
+
+    expect(confirmSpy).toHaveBeenCalledWith("当前 Skill 还有未保存修改,确定放弃修改吗?")
+    expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("未保存的技能草稿")
+    expect(useWikiStore.getState().selectedSkillLibrarySkillId).toBe("built-in:comprehensive")
+
+    confirmSpy.mockRestore()
+    cleanup(root, container)
+  })
+
+  it("keeps save disabled when the draft has no real saved change", async () => {
+    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(saveButton?.disabled).toBe(true)
+    await setInputValue(nameInput!, "综合去AI味   ")
+
+    expect(useWikiStore.getState().skillLibraryDraftDirty).toBe(false)
+    expect(saveButton?.disabled).toBe(true)
+    await act(async () => {
+      saveButton?.click()
+    })
+
+    expect(writeFileMock).not.toHaveBeenCalled()
+    cleanup(root, container)
+  })
+
+  it("discards unsaved draft changes and restores the selected skill content", async () => {
+    const { container, root } = await renderView()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    const contentInput = container.querySelector<HTMLTextAreaElement>('[data-testid="skill-content-input"]')
+    const discardButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-discard-button"]')
+    expect(nameInput).not.toBeNull()
+    expect(contentInput).not.toBeNull()
+    expect(discardButton).not.toBeNull()
+    expect(discardButton?.disabled).toBe(true)
+
+    await setInputValue(nameInput!, "未保存的技能草稿")
+    await setTextareaValue(contentInput!, "未保存的规则")
+    expect(useWikiStore.getState().skillLibraryDraftDirty).toBe(true)
+    expect(discardButton?.disabled).toBe(false)
+
+    await act(async () => {
+      discardButton?.click()
+    })
+
+    expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("综合去AI味")
+    expect(container.querySelector<HTMLTextAreaElement>('[data-testid="skill-content-input"]')?.value).toContain(
+      "# de-AI-writing",
+    )
+    expect(useWikiStore.getState().skillLibraryDraftDirty).toBe(false)
+    expect(writeFileMock).not.toHaveBeenCalled()
+
+    cleanup(root, container)
+  })
+
+  it("saves the selected skill with Ctrl+S when the draft changed", async () => {
+    const { container, root } = await renderView()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    expect(nameInput).not.toBeNull()
+
+    await setInputValue(nameInput!, "综合去AI味-快捷保存")
+    await act(async () => {
+      window.dispatchEvent(new KeyboardEvent("keydown", {
+        bubbles: true,
+        cancelable: true,
+        ctrlKey: true,
+        key: "s",
+      }))
+    })
+    await flushEffects()
+
+    const saved = JSON.parse(savedConfigContent)
+    expect(saved.builtInSkillOverrides[0]).toMatchObject({
+      id: "built-in:comprehensive",
+      name: "综合去AI味-快捷保存",
+    })
+
+    cleanup(root, container)
+  })
+
+  it("marks modified skills in the list and detail header", async () => {
+    const { container, root } = await renderLibrary()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    const contentInput = container.querySelector<HTMLTextAreaElement>('[data-testid="skill-content-input"]')
+    expect(nameInput).not.toBeNull()
+    expect(contentInput).not.toBeNull()
+
+    await setInputValue(nameInput!, "综合去AI味-项目版")
+    await setTextareaValue(contentInput!, "当前项目覆盖后的内置规则")
+    await act(async () => {
+      container.querySelector<HTMLButtonElement>('[data-testid="skill-save-button"]')?.click()
+    })
+
+    expect(container.querySelectorAll('[data-testid="skill-modified-badge"]').length).toBeGreaterThanOrEqual(2)
+    expect(container.textContent).toContain("已修改")
+
+    cleanup(root, container)
+  })
+
+  it("prevents saving an empty skill name", async () => {
+    const { container, root } = await renderView()
+    const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
+    expect(nameInput).not.toBeNull()
+
+    await setInputValue(nameInput!, "")
+    await flushEffects()
+    expect(nameInput!.value).toBe("")
+    await act(async () => {
+      container.querySelector<HTMLButtonElement>('[data-testid="skill-save-button"]')?.click()
+    })
+    await flushEffects()
+
+    expect(container.textContent).toContain("技能名称不能为空")
+    expect(writeFileMock).not.toHaveBeenCalled()
+
+    cleanup(root, container)
+  })
+})

+ 497 - 0
src/components/skill-library/skill-library-view.tsx

@@ -0,0 +1,497 @@
+import { useEffect, useMemo, useState } from "react"
+import {
+  BUILT_IN_DE_AI_SKILLS,
+  createProjectDeAiSkillFromTemplate,
+  deleteProjectDeAiSkill,
+  getAllDeAiSkills,
+  isDeAiSkillModified,
+  loadDeAiSkillConfig,
+  resetBuiltInDeAiSkill,
+  saveDeAiSkillConfig,
+  setDeAiSkillEnabled,
+  setDefaultDeAiSkill,
+  updateDeAiSkill,
+  type DeAiSkill,
+  type DeAiSkillConfig,
+} from "@/lib/novel/de-ai-skill-library"
+import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
+
+function sourceLabel(skill: DeAiSkill): string {
+  if (skill.source === "built-in") return "内置"
+  if (skill.source === "legacy") return "旧版"
+  return "项目"
+}
+
+function resolveInitialSkillId(config: DeAiSkillConfig, requested: string | null): string {
+  const allSkills = getAllDeAiSkills(config)
+  if (requested && allSkills.some((skill) => skill.id === requested)) return requested
+  return config.defaultSkillId || allSkills[0]?.id || ""
+}
+
+function normalizeDraftText(value: string): string {
+  return value.trim()
+}
+
+function hasSkillDraftChanged(skill: DeAiSkill, name: string, description: string, content: string): boolean {
+  return normalizeDraftText(name) !== normalizeDraftText(skill.name)
+    || normalizeDraftText(description) !== normalizeDraftText(skill.description)
+    || 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 [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 ?? [])
+
+  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])
+
+  async function persist(nextConfig: DeAiSkillConfig, nextSelectedSkillId: string) {
+    setSaving(true)
+    try {
+      if (project) {
+        await saveDeAiSkillConfig(project.path, nextConfig)
+        bumpDataVersion()
+      }
+      setConfig(nextConfig)
+      setSelectedSkillId(nextSelectedSkillId)
+      setMessage("已保存")
+    } catch {
+      setMessage("技能库保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleCreateSkill() {
+    if (!config || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const now = Date.now()
+    const next = createProjectDeAiSkillFromTemplate(config, BUILT_IN_DE_AI_SKILLS[0].id, now)
+    await persist(next, `project:${now}`)
+  }
+
+  async function handleToggleSkill(skill: DeAiSkill, enabled: boolean) {
+    if (!config || saving) return
+    const next = setDeAiSkillEnabled(config, skill.id, enabled)
+    await persist(next, selectedSkillId ?? next.defaultSkillId)
+  }
+
+  return (
+    <div data-testid="skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
+      <div className="shrink-0 border-b px-3 py-2">
+        <h1 className="text-sm font-semibold">技能库</h1>
+        <p className="mt-0.5 text-xs text-muted-foreground">管理当前项目可用的去AI味技能。</p>
+      </div>
+
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-medium">去AI味技能</div>
+        <button
+          type="button"
+          onClick={() => void handleCreateSkill()}
+          className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+          disabled={!config || saving}
+        >
+          新建技能
+        </button>
+      </div>
+
+      {message ? <div className="border-b px-3 py-2 text-xs text-muted-foreground">{message}</div> : null}
+
+      <div data-testid="skill-list" className="min-h-0 flex-1 overflow-y-auto p-2">
+        {allSkills.map((skill) => {
+          const active = skill.id === selectedSkillId
+          const enabled = !disabledSkillIds.has(skill.id)
+          const modified = isDeAiSkillModified(config!, skill.id)
+          return (
+            <div
+              key={skill.id}
+              data-skill-id={skill.id}
+              onClick={() => setSelectedSkillId(skill.id)}
+              onKeyDown={(event) => {
+                if (event.key === "Enter" || event.key === " ") {
+                  event.preventDefault()
+                  setSelectedSkillId(skill.id)
+                }
+              }}
+              role="button"
+              tabIndex={0}
+              className={`mb-2 w-full rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
+                active ? "border-primary bg-accent/60" : "border-border"
+              }`}
+            >
+              <div className="flex items-center gap-2">
+                <span className="min-w-0 flex-1 truncate text-sm font-medium">{skill.name}</span>
+                <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                  {sourceLabel(skill)}
+                </span>
+                {config?.defaultSkillId === skill.id ? (
+                  <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] text-primary-foreground">默认</span>
+                ) : null}
+                {modified ? (
+                  <span
+                    data-testid="skill-modified-badge"
+                    className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800"
+                  >
+                    已修改
+                  </span>
+                ) : null}
+              </div>
+              <div className="mt-1 truncate text-xs text-muted-foreground">{skill.description}</div>
+              <label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
+                <input
+                  type="checkbox"
+                  checked={enabled}
+                  onClick={(event) => event.stopPropagation()}
+                  onChange={(event) => void handleToggleSkill(skill, event.target.checked)}
+                  className="h-3.5 w-3.5 accent-primary"
+                  disabled={saving}
+                />
+                显示在调用入口
+              </label>
+            </div>
+          )
+        })}
+      </div>
+    </div>
+  )
+}
+
+export function SkillLibraryView() {
+  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] = useState<DeAiSkillConfig | null>(null)
+  const [draftName, setDraftName] = useState("")
+  const [draftDescription, setDraftDescription] = useState("")
+  const [draftContent, setDraftContent] = useState("")
+  const [message, setMessage] = useState("")
+  const [saving, setSaving] = useState(false)
+
+  const allSkills = useMemo(() => config ? getAllDeAiSkills(config) : [], [config])
+  const selectedSkill = allSkills.find((skill) => skill.id === selectedSkillId) ?? allSkills[0] ?? null
+  const selectedIsEditable = selectedSkill != null
+  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 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])
+
+  useEffect(() => {
+    if (!draftDirty) return
+    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+      event.preventDefault()
+      event.returnValue = ""
+    }
+    window.addEventListener("beforeunload", handleBeforeUnload)
+    return () => window.removeEventListener("beforeunload", handleBeforeUnload)
+  }, [draftDirty])
+
+  useEffect(() => {
+    if (!selectedSkill) {
+      setDraftName("")
+      setDraftDescription("")
+      setDraftContent("")
+      setDraftDirty(false)
+      return
+    }
+    setDraftName(selectedSkill.name)
+    setDraftDescription(selectedSkill.description)
+    setDraftContent(selectedSkill.content)
+    setMessage("")
+    setDraftDirty(false)
+  }, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
+
+  function updateDraftDirty(name: string, description: string, content: string) {
+    setDraftDirty(selectedSkill ? hasSkillDraftChanged(selectedSkill, name, description, content) : false)
+  }
+
+  async function persist(nextConfig: DeAiSkillConfig, nextSelectedSkillId = selectedSkillId ?? "") {
+    setSaving(true)
+    try {
+      if (project) {
+        await saveDeAiSkillConfig(project.path, nextConfig)
+        bumpDataVersion()
+      }
+      setConfig(nextConfig)
+      setDraftDirty(false)
+      setSelectedSkillId(nextSelectedSkillId)
+      setMessage("已保存")
+    } catch {
+      setMessage("技能库保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleCopySkill() {
+    if (!config || !selectedSkill) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const now = Date.now()
+    const next = createProjectDeAiSkillFromTemplate(config, selectedSkill.id, now)
+    await persist(next, `project:${now}`)
+  }
+
+  async function handleSaveSkill() {
+    if (!config || !selectedSkill || !canSaveDraft) return
+    const name = normalizeDraftText(draftName)
+    const description = normalizeDraftText(draftDescription)
+    const content = normalizeDraftText(draftContent)
+    if (!name) {
+      setMessage("技能名称不能为空")
+      return
+    }
+    if (!content) {
+      setMessage("技能规则不能为空")
+      return
+    }
+    await persist(updateDeAiSkill(config, selectedSkill.id, {
+      name,
+      description,
+      content,
+    }))
+  }
+
+  function handleDiscardDraft() {
+    if (!selectedSkill || !draftChanged || saving) return
+    setDraftName(selectedSkill.name)
+    setDraftDescription(selectedSkill.description)
+    setDraftContent(selectedSkill.content)
+    setDraftDirty(false)
+    setMessage("已放弃未保存修改")
+  }
+
+  async function handleSetDefault() {
+    if (!config || !selectedSkill) return
+    await persist(setDefaultDeAiSkill(config, selectedSkill.id))
+  }
+
+  async function handleDeleteSkill() {
+    if (!config || !selectedSkill || selectedIsBuiltIn) return
+    const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
+    if (!confirmed) return
+    const next = deleteProjectDeAiSkill(config, selectedSkill.id)
+    const nextSelected = next.defaultSkillId || getAllDeAiSkills(next)[0]?.id || ""
+    await persist(next, nextSelected)
+  }
+
+  async function handleResetBuiltInSkill() {
+    if (!config || !selectedSkill || !selectedIsBuiltIn || !selectedHasBuiltInOverride) return
+    const confirmed = window.confirm(`确定将「${selectedSkill.name}」恢复为内置默认内容吗?当前项目对此技能的修改会被清除。`)
+    if (!confirmed) return
+    await persist(resetBuiltInDeAiSkill(config, selectedSkill.id), selectedSkill.id)
+  }
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
+      event.preventDefault()
+      if (canSaveDraft) {
+        void handleSaveSkill()
+      }
+    }
+    window.addEventListener("keydown", handleKeyDown)
+    return () => window.removeEventListener("keydown", handleKeyDown)
+  }, [canSaveDraft, config, draftContent, draftDescription, draftName, selectedSkill?.id])
+
+  return (
+    <div data-testid="skill-library-view" className="flex h-full flex-col overflow-hidden">
+      <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>
+      </div>
+
+      <main className="min-h-0 flex-1 overflow-y-auto p-5">
+        {!selectedSkill ? (
+          <div className="text-sm text-muted-foreground">暂无技能。</div>
+        ) : (
+          <div className="mx-auto flex max-w-5xl flex-col gap-4">
+            <div className="flex flex-wrap items-center justify-between gap-2">
+              <div className="min-w-0">
+                <div className="text-sm text-muted-foreground">{sourceLabel(selectedSkill)}技能</div>
+                <div className="flex min-w-0 items-center gap-2">
+                  <h2 className="truncate text-xl font-semibold">{selectedSkill.name}</h2>
+                  {selectedModified ? (
+                    <span
+                      data-testid="skill-modified-badge"
+                      className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800"
+                    >
+                      已修改
+                    </span>
+                  ) : null}
+                </div>
+              </div>
+              <div className="flex flex-wrap gap-2">
+                <button
+                  data-testid="skill-copy-button"
+                  type="button"
+                  onClick={() => void handleCopySkill()}
+                  className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+                  disabled={saving}
+                >
+                  复制为项目技能
+                </button>
+                <button
+                  data-testid="skill-default-button"
+                  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}
+                >
+                  设为默认
+                </button>
+                {selectedIsBuiltIn ? (
+                  <button
+                    data-testid="skill-reset-default-button"
+                    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}
+                  >
+                    恢复默认
+                  </button>
+                ) : null}
+                {!selectedIsBuiltIn ? (
+                  <button
+                    data-testid="skill-delete-button"
+                    type="button"
+                    onClick={() => void handleDeleteSkill()}
+                    className="rounded-md border px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
+                    disabled={saving}
+                  >
+                    删除
+                  </button>
+                ) : null}
+              </div>
+            </div>
+
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">技能名称</span>
+              <input
+                data-testid="skill-name-input"
+                value={draftName}
+                onChange={(event) => {
+                  const nextName = event.target.value
+                  setDraftName(nextName)
+                  updateDraftDirty(nextName, draftDescription, draftContent)
+                }}
+                disabled={!selectedIsEditable}
+                className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
+              />
+            </label>
+
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">说明</span>
+              <input
+                data-testid="skill-description-input"
+                value={draftDescription}
+                onChange={(event) => {
+                  const nextDescription = event.target.value
+                  setDraftDescription(nextDescription)
+                  updateDraftDirty(draftName, nextDescription, draftContent)
+                }}
+                disabled={!selectedIsEditable}
+                className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
+              />
+            </label>
+
+            <label className="grid min-h-0 gap-1.5 text-sm">
+              <span className="font-medium">规则正文</span>
+              <textarea
+                data-testid="skill-content-input"
+                value={draftContent}
+                onChange={(event) => {
+                  const nextContent = event.target.value
+                  setDraftContent(nextContent)
+                  updateDraftDirty(draftName, draftDescription, nextContent)
+                }}
+                disabled={!selectedIsEditable}
+                className="min-h-[520px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
+              />
+            </label>
+
+            <div className="flex items-center gap-3">
+              <button
+                data-testid="skill-save-button"
+                type="button"
+                onClick={() => void handleSaveSkill()}
+                disabled={!canSaveDraft}
+                className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
+              >
+                {saving ? "保存中..." : "保存"}
+              </button>
+              <button
+                data-testid="skill-discard-button"
+                type="button"
+                onClick={handleDiscardDraft}
+                disabled={!draftChanged || saving}
+                className="rounded-md border px-4 py-2 text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
+              >
+                放弃修改
+              </button>
+              {message ? (
+                <span className="text-sm text-muted-foreground">{message}</span>
+              ) : draftDirty ? (
+                <span className="text-sm text-amber-700">未保存</span>
+              ) : null}
+            </div>
+          </div>
+        )}
+      </main>
+    </div>
+  )
+}

+ 2 - 0
src/i18n/en.json

@@ -221,6 +221,7 @@
     "chat": "Chat",
     "lint": "Check",
     "soul": "Soul",
+    "skillLibrary": "Skill Library",
     "reviewCenter": "Review Center",
     "dismantling": "Dismantling",
     "settings": "Settings",
@@ -1116,6 +1117,7 @@
       "chat": "写作对话",
       "lint": "Memory Center",
       "soul": "Soul",
+      "skillLibrary": "Skill Library",
       "reviewCenter": "Review Center",
       "storySimulation": "Story Simulation",
       "dismantling": "Dismantling",

+ 2 - 0
src/i18n/zh.json

@@ -113,6 +113,7 @@
     "graph": "关系图",
     "lint": "检查",
     "soul": "灵魂",
+    "skillLibrary": "技能库",
     "reviewCenter": "审查中心",
     "dismantling": "拆文库",
     "settings": "设置",
@@ -997,6 +998,7 @@
       "graph": "小说图谱",
       "lint": "记忆中心",
       "soul": "灵魂",
+      "skillLibrary": "技能库",
       "reviewCenter": "审查中心",
       "storySimulation": "剧情推演室",
       "dismantling": "拆文库",

+ 4 - 0
src/lib/novel/de-ai-adapter.ts

@@ -29,6 +29,10 @@ export function buildDeAiSystemPrompt(customSkill?: string): string {
   return buildQmQuaiSystemPrompt(customSkill)
 }
 
+export function buildDeAiSkillSystemPrompt(skillContent: string | null | undefined): string {
+  return buildQmQuaiSystemPrompt(skillContent || undefined)
+}
+
 export function buildQmQuaiRewriteMessages(content: string, customSkill?: string): ChatMessage[] {
   if (!content.trim()) throw new Error("去AI味内容为空,无法处理")
   return [

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

@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  BUILT_IN_DE_AI_SKILLS,
+  DEFAULT_DE_AI_SKILL_ID,
+  createProjectDeAiSkillFromTemplate,
+  deleteProjectDeAiSkill,
+  getAllDeAiSkills,
+  isDeAiSkillModified,
+  loadDeAiSkillConfig,
+  normalizeDeAiSkillConfig,
+  resetBuiltInDeAiSkill,
+  resolveAvailableDeAiSkills,
+  resolveEffectiveDeAiSkill,
+  setDeAiSkillEnabled,
+  setDefaultDeAiSkill,
+  updateDeAiSkill,
+  updateProjectDeAiSkill,
+} from "./de-ai-skill-library"
+
+const readFileMock = vi.hoisted(() => vi.fn())
+const writeFileMock = vi.hoisted(() => vi.fn())
+const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: readFileMock,
+  writeFile: writeFileMock,
+}))
+
+vi.mock("@tauri-apps/api/path", () => ({
+  join: joinMock,
+}))
+
+describe("de-ai skill library", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("ships five built-in de-AI skills", () => {
+    expect(BUILT_IN_DE_AI_SKILLS.map((skill) => skill.id)).toEqual([
+      "built-in:comprehensive",
+      "built-in:reduce-explanation",
+      "built-in:dialogue-natural",
+      "built-in:break-regularity",
+      "built-in:literary-retain",
+    ])
+  })
+
+  it("normalizes an empty config to the built-in comprehensive skill", () => {
+    expect(normalizeDeAiSkillConfig(null)).toEqual({
+      version: 1,
+      defaultSkillId: DEFAULT_DE_AI_SKILL_ID,
+      disabledSkillIds: [],
+      projectSkills: [],
+      builtInSkillOverrides: [],
+    })
+  })
+
+  it("filters disabled skills from available skills", () => {
+    const config = normalizeDeAiSkillConfig({
+      disabledSkillIds: ["built-in:comprehensive", "built-in:dialogue-natural"],
+    })
+
+    const available = resolveAvailableDeAiSkills(config)
+
+    expect(available.some((skill) => skill.id === "built-in:comprehensive")).toBe(false)
+    expect(available.some((skill) => skill.id === "built-in:reduce-explanation")).toBe(true)
+  })
+
+  it("falls back when selected skill is disabled", () => {
+    const config = normalizeDeAiSkillConfig({
+      defaultSkillId: "built-in:comprehensive",
+      disabledSkillIds: ["built-in:comprehensive"],
+    })
+
+    const skill = resolveEffectiveDeAiSkill(config, "built-in:comprehensive")
+
+    expect(skill?.id).toBe("built-in:reduce-explanation")
+  })
+
+  it("creates a project skill from a built-in template", () => {
+    const config = normalizeDeAiSkillConfig(null)
+
+    const next = createProjectDeAiSkillFromTemplate(config, "built-in:reduce-explanation", 1000)
+
+    expect(next.projectSkills).toHaveLength(1)
+    expect(next.projectSkills[0].source).toBe("project")
+    expect(next.projectSkills[0].name).toContain("减少解释腔")
+    expect(next.defaultSkillId).toBe(next.projectSkills[0].id)
+  })
+
+  it("updates and deletes project skills without deleting built-ins", () => {
+    const created = createProjectDeAiSkillFromTemplate(
+      normalizeDeAiSkillConfig(null),
+      "built-in:dialogue-natural",
+      1000,
+    )
+    const id = created.projectSkills[0].id
+
+    const updated = updateProjectDeAiSkill(created, id, { name: "对话规则", content: "只输出正文" }, 2000)
+    const deleted = deleteProjectDeAiSkill(updated, id)
+
+    expect(updated.projectSkills[0].name).toBe("对话规则")
+    expect(updated.projectSkills[0].updatedAt).toBe(2000)
+    expect(deleted.projectSkills).toHaveLength(0)
+    expect(deleteProjectDeAiSkill(deleted, "built-in:comprehensive")).toEqual(deleted)
+  })
+
+  it("stores edited built-in skills as project overrides and can restore defaults", () => {
+    const config = normalizeDeAiSkillConfig(null)
+
+    const updated = updateDeAiSkill(config, "built-in:comprehensive", {
+      name: "综合去AI味-项目版",
+      description: "当前项目专用规则",
+      content: "当前项目覆盖后的内置规则",
+    }, 2000)
+    const allUpdatedSkills = getAllDeAiSkills(updated)
+
+    expect(updated.builtInSkillOverrides).toHaveLength(1)
+    expect(updated.builtInSkillOverrides[0]).toMatchObject({
+      id: "built-in:comprehensive",
+      name: "综合去AI味-项目版",
+      description: "当前项目专用规则",
+      content: "当前项目覆盖后的内置规则",
+      source: "built-in",
+      updatedAt: 2000,
+    })
+    expect(allUpdatedSkills.filter((skill) => skill.id === "built-in:comprehensive")).toHaveLength(1)
+    expect(resolveEffectiveDeAiSkill(updated, "built-in:comprehensive")?.content).toBe("当前项目覆盖后的内置规则")
+
+    const restored = resetBuiltInDeAiSkill(updated, "built-in:comprehensive")
+
+    expect(restored.builtInSkillOverrides).toEqual([])
+    expect(resolveEffectiveDeAiSkill(restored, "built-in:comprehensive")?.content).toBe(
+      BUILT_IN_DE_AI_SKILLS[0].content,
+    )
+  })
+
+  it("detects modified built-in and project skills", () => {
+    const builtInUpdated = updateDeAiSkill(normalizeDeAiSkillConfig(null), "built-in:comprehensive", {
+      name: "综合去AI味-项目版",
+      content: "项目覆盖规则",
+    }, 2000)
+    const projectCreated = createProjectDeAiSkillFromTemplate(builtInUpdated, "built-in:dialogue-natural", 3000)
+    const projectId = projectCreated.projectSkills[0].id
+    const projectUpdated = updateDeAiSkill(projectCreated, projectId, {
+      name: "对话规则",
+      content: "新的对话规则",
+    }, 4000)
+
+    expect(isDeAiSkillModified(projectUpdated, "built-in:comprehensive")).toBe(true)
+    expect(isDeAiSkillModified(projectUpdated, projectId)).toBe(true)
+    expect(isDeAiSkillModified(projectUpdated, "built-in:reduce-explanation")).toBe(false)
+  })
+
+  it("disables a default skill and moves default to an available skill", () => {
+    const config = normalizeDeAiSkillConfig({ defaultSkillId: "built-in:comprehensive" })
+
+    const next = setDeAiSkillEnabled(config, "built-in:comprehensive", false)
+
+    expect(next.disabledSkillIds).toContain("built-in:comprehensive")
+    expect(next.defaultSkillId).toBe("built-in:reduce-explanation")
+    expect(setDefaultDeAiSkill(next, "built-in:dialogue-natural").defaultSkillId).toBe("built-in:dialogue-natural")
+  })
+
+  it("loads legacy de-ai-skill.txt as the default project skill when json config is absent", async () => {
+    readFileMock
+      .mockRejectedValueOnce(new Error("missing json"))
+      .mockResolvedValueOnce("legacy rules")
+
+    const config = await loadDeAiSkillConfig("C:/project")
+
+    expect(config.defaultSkillId).toBe("project:legacy-de-ai-skill")
+    expect(config.projectSkills[0]).toMatchObject({
+      id: "project:legacy-de-ai-skill",
+      name: "旧版自定义去AI味 Skill",
+      content: "legacy rules",
+    })
+  })
+})

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

@@ -0,0 +1,324 @@
+import { readFile, writeFile } 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"
+
+export interface DeAiSkill {
+  id: string
+  name: string
+  description: string
+  templateId: string
+  content: string
+  source: DeAiSkillSource
+  createdAt?: number
+  updatedAt?: number
+}
+
+export interface DeAiSkillConfig {
+  version: 1
+  defaultSkillId: string
+  disabledSkillIds: string[]
+  projectSkills: DeAiSkill[]
+  builtInSkillOverrides: DeAiSkill[]
+}
+
+export const DEFAULT_DE_AI_SKILL_ID = "built-in:comprehensive"
+
+const baseSkill = defaultDeAiSkill.trim()
+
+export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
+  {
+    id: "built-in:comprehensive",
+    name: "综合去AI味",
+    description: "综合减少解释腔、模板句式和机械总结。",
+    templateId: "comprehensive",
+    content: baseSkill,
+    source: "built-in",
+  },
+  {
+    id: "built-in:reduce-explanation",
+    name: "减少解释腔",
+    description: "重点删掉动机解释、情绪总结和重复说明。",
+    templateId: "reduce-explanation",
+    content: `${baseSkill}\n\n## 本技能重点\n优先删减解释腔、总结腔、过度说明和直白动机解释。`,
+    source: "built-in",
+  },
+  {
+    id: "built-in:dialogue-natural",
+    name: "对话口语化",
+    description: "让人物对话更像真人说话,减少书面腔。",
+    templateId: "dialogue-natural",
+    content: `${baseSkill}\n\n## 本技能重点\n优先处理对话,让人物说半句话、停顿、回避、打断,并保留个人口癖。`,
+    source: "built-in",
+  },
+  {
+    id: "built-in:break-regularity",
+    name: "打破工整句式",
+    description: "打散整齐段落、排比句和模板化起承转合。",
+    templateId: "break-regularity",
+    content: `${baseSkill}\n\n## 本技能重点\n优先打破工整句式、固定段落长度、机械排比和连续相同主谓结构。`,
+    source: "built-in",
+  },
+  {
+    id: "built-in:literary-retain",
+    name: "保留文艺感",
+    description: "去除AI味时保留必要修辞、氛围和文学质感。",
+    templateId: "literary-retain",
+    content: `${baseSkill}\n\n## 本技能重点\n去除AI味时不要硬压缩到干瘪,保留必要意象、氛围、节奏和文艺表达。`,
+    source: "built-in",
+  },
+]
+
+const BUILT_IN_IDS = new Set(BUILT_IN_DE_AI_SKILLS.map((skill) => skill.id))
+
+function uniqueStrings(values: unknown): string[] {
+  if (!Array.isArray(values)) return []
+  const result: string[] = []
+  for (const value of values) {
+    if (typeof value !== "string") continue
+    const trimmed = value.trim()
+    if (trimmed && !result.includes(trimmed)) {
+      result.push(trimmed)
+    }
+  }
+  return result
+}
+
+function normalizeProjectSkill(value: unknown): DeAiSkill | null {
+  if (!value || typeof value !== "object") return null
+  const raw = value as Partial<DeAiSkill>
+  const id = typeof raw.id === "string" && (raw.id.startsWith("project:") || raw.id.startsWith("legacy:"))
+    ? raw.id
+    : `project:${Date.now()}`
+  const name = typeof raw.name === "string" ? raw.name.trim() : ""
+  const content = typeof raw.content === "string" ? raw.content.trim() : ""
+  if (!name || !content) return null
+  const source: DeAiSkillSource = raw.source === "legacy" ? "legacy" : "project"
+  return {
+    id,
+    name,
+    description: typeof raw.description === "string" ? raw.description : "",
+    templateId: typeof raw.templateId === "string" ? raw.templateId : "custom",
+    content,
+    source,
+    createdAt: typeof raw.createdAt === "number" ? raw.createdAt : Date.now(),
+    updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : Date.now(),
+  }
+}
+
+function normalizeBuiltInSkillOverride(value: unknown): DeAiSkill | null {
+  if (!value || typeof value !== "object") return null
+  const raw = value as Partial<DeAiSkill>
+  const id = typeof raw.id === "string" && BUILT_IN_IDS.has(raw.id) ? raw.id : ""
+  const base = BUILT_IN_DE_AI_SKILLS.find((skill) => skill.id === id)
+  if (!base) return null
+  const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : base.name
+  const content = typeof raw.content === "string" && raw.content.trim() ? raw.content.trim() : base.content
+  return {
+    ...base,
+    name,
+    description: typeof raw.description === "string" ? raw.description : base.description,
+    content,
+    source: "built-in",
+    createdAt: typeof raw.createdAt === "number" ? raw.createdAt : Date.now(),
+    updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : Date.now(),
+  }
+}
+
+export function normalizeDeAiSkillConfig(value: unknown): DeAiSkillConfig {
+  const raw = value && typeof value === "object" ? value as Partial<DeAiSkillConfig> : {}
+  const projectSkills = Array.isArray(raw.projectSkills)
+    ? raw.projectSkills.map(normalizeProjectSkill).filter((skill): skill is DeAiSkill => Boolean(skill))
+    : []
+  const builtInSkillOverrides = Array.isArray(raw.builtInSkillOverrides)
+    ? raw.builtInSkillOverrides
+      .map(normalizeBuiltInSkillOverride)
+      .filter((skill): skill is DeAiSkill => Boolean(skill))
+      .filter((skill, index, skills) => skills.findIndex((item) => item.id === skill.id) === index)
+    : []
+  const disabledSkillIds = uniqueStrings(raw.disabledSkillIds)
+  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
+  return {
+    version: 1,
+    defaultSkillId,
+    disabledSkillIds,
+    projectSkills,
+    builtInSkillOverrides,
+  }
+}
+
+export function getAllDeAiSkills(config: DeAiSkillConfig): DeAiSkill[] {
+  const overrides = new Map((config.builtInSkillOverrides ?? []).map((skill) => [skill.id, skill]))
+  const builtInSkills = BUILT_IN_DE_AI_SKILLS.map((skill) => overrides.get(skill.id) ?? skill)
+  return [...config.projectSkills, ...builtInSkills]
+}
+
+export function resolveAvailableDeAiSkills(config: DeAiSkillConfig): DeAiSkill[] {
+  const disabled = new Set(config.disabledSkillIds)
+  return getAllDeAiSkills(config).filter((skill) => !disabled.has(skill.id))
+}
+
+export function resolveEffectiveDeAiSkill(
+  config: DeAiSkillConfig,
+  selectedSkillId?: string | null,
+): DeAiSkill | null {
+  if (selectedSkillId === null) return null
+  const available = resolveAvailableDeAiSkills(config)
+  if (available.length === 0) return null
+  const requested = selectedSkillId ?? config.defaultSkillId
+  return available.find((skill) => skill.id === requested) ?? available[0]
+}
+
+export function isDeAiSkillModified(config: DeAiSkillConfig, skillId: string): boolean {
+  const normalized = normalizeDeAiSkillConfig(config)
+  if (skillId.startsWith("built-in:")) {
+    return normalized.builtInSkillOverrides.some((skill) => skill.id === skillId)
+  }
+  const projectSkill = normalized.projectSkills.find((skill) => skill.id === skillId)
+  if (!projectSkill) return false
+  return typeof projectSkill.createdAt === "number"
+    && typeof projectSkill.updatedAt === "number"
+    && projectSkill.updatedAt > projectSkill.createdAt
+}
+
+export function createProjectDeAiSkillFromTemplate(
+  config: DeAiSkillConfig,
+  templateId: string,
+  now = Date.now(),
+): DeAiSkillConfig {
+  const template = getAllDeAiSkills(config).find((skill) => skill.id === templateId) ?? BUILT_IN_DE_AI_SKILLS[0]
+  const skill: DeAiSkill = {
+    id: `project:${now}`,
+    name: `${template.name}副本`,
+    description: template.description,
+    templateId: template.templateId,
+    content: template.content,
+    source: "project",
+    createdAt: now,
+    updatedAt: now,
+  }
+  return normalizeDeAiSkillConfig({
+    ...config,
+    defaultSkillId: skill.id,
+    projectSkills: [skill, ...config.projectSkills],
+  })
+}
+
+export function updateProjectDeAiSkill(
+  config: DeAiSkillConfig,
+  skillId: string,
+  patch: Pick<Partial<DeAiSkill>, "name" | "description" | "content">,
+  now = Date.now(),
+): DeAiSkillConfig {
+  return normalizeDeAiSkillConfig({
+    ...config,
+    projectSkills: config.projectSkills.map((skill) =>
+      skill.id === skillId
+        ? { ...skill, ...patch, source: "project", updatedAt: now }
+        : skill,
+    ),
+  })
+}
+
+export function updateDeAiSkill(
+  config: DeAiSkillConfig,
+  skillId: string,
+  patch: Pick<Partial<DeAiSkill>, "name" | "description" | "content">,
+  now = Date.now(),
+): DeAiSkillConfig {
+  const normalized = normalizeDeAiSkillConfig(config)
+  if (!skillId.startsWith("built-in:")) {
+    return updateProjectDeAiSkill(normalized, skillId, patch, now)
+  }
+  const existingOverride = normalized.builtInSkillOverrides.find((skill) => skill.id === skillId)
+  const current = getAllDeAiSkills(normalized).find((skill) => skill.id === skillId)
+  if (!current || !BUILT_IN_IDS.has(skillId)) return normalized
+  const override: DeAiSkill = {
+    ...current,
+    ...patch,
+    id: skillId,
+    source: "built-in",
+    createdAt: existingOverride?.createdAt ?? now,
+    updatedAt: now,
+  }
+  return normalizeDeAiSkillConfig({
+    ...normalized,
+    builtInSkillOverrides: [
+      override,
+      ...normalized.builtInSkillOverrides.filter((skill) => skill.id !== skillId),
+    ],
+  })
+}
+
+export function resetBuiltInDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
+  const normalized = normalizeDeAiSkillConfig(config)
+  if (!BUILT_IN_IDS.has(skillId)) return normalized
+  return normalizeDeAiSkillConfig({
+    ...normalized,
+    builtInSkillOverrides: normalized.builtInSkillOverrides.filter((skill) => skill.id !== skillId),
+  })
+}
+
+export function setDefaultDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
+  return normalizeDeAiSkillConfig({ ...config, defaultSkillId: skillId })
+}
+
+export function setDeAiSkillEnabled(config: DeAiSkillConfig, skillId: string, enabled: boolean): DeAiSkillConfig {
+  const disabledSkillIds = enabled
+    ? config.disabledSkillIds.filter((id) => id !== skillId)
+    : [...new Set([...config.disabledSkillIds, skillId])]
+  const normalized = normalizeDeAiSkillConfig({ ...config, disabledSkillIds })
+  if (normalized.defaultSkillId === skillId && !enabled) {
+    const fallback = resolveAvailableDeAiSkills(normalized)[0]
+    return normalizeDeAiSkillConfig({ ...normalized, defaultSkillId: fallback?.id ?? normalized.defaultSkillId })
+  }
+  return normalized
+}
+
+export function deleteProjectDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
+  if (!skillId.startsWith("project:")) return config
+  const projectSkills = config.projectSkills.filter((skill) => skill.id !== skillId)
+  const next = normalizeDeAiSkillConfig({ ...config, projectSkills })
+  if (next.defaultSkillId === skillId || !getAllDeAiSkills(next).some((skill) => skill.id === next.defaultSkillId)) {
+    const fallback = resolveAvailableDeAiSkills(next)[0]
+    return normalizeDeAiSkillConfig({ ...next, defaultSkillId: fallback?.id ?? DEFAULT_DE_AI_SKILL_ID })
+  }
+  return next
+}
+
+export async function loadDeAiSkillConfig(projectPath: string | null | undefined): Promise<DeAiSkillConfig> {
+  if (!projectPath) return normalizeDeAiSkillConfig(null)
+  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],
+      })
+    } catch {
+      return normalizeDeAiSkillConfig(null)
+    }
+  }
+}
+
+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))
+}

+ 19 - 2
src/lib/persist.ts

@@ -103,6 +103,17 @@ interface PersistedChatData {
   messages: DisplayMessage[]
 }
 
+function normalizeConversation(conv: Conversation): Conversation {
+  return {
+    ...conv,
+    deAiMode: Boolean(conv.deAiMode),
+    selectedDeAiSkillId:
+      conv.selectedDeAiSkillId === null || typeof conv.selectedDeAiSkillId === "string"
+        ? conv.selectedDeAiSkillId
+        : undefined,
+  }
+}
+
 export async function saveChatHistory(
   projectPath: string,
   conversations: Conversation[],
@@ -153,7 +164,7 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
   try {
     // Try new format: separate files per conversation
     const convContent = await readFile(`${pp}/.qmai/conversations.json`)
-    const conversations = safeParseArray<Conversation>(convContent, "conversations")
+    const conversations = safeParseArray<Conversation>(convContent, "conversations").map(normalizeConversation)
 
     const allMessages: DisplayMessage[] = []
     for (const conv of conversations) {
@@ -182,6 +193,7 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
           createdAt: legacyMessages[0]?.timestamp ?? Date.now(),
           updatedAt: legacyMessages[legacyMessages.length - 1]?.timestamp ?? Date.now(),
           deAiMode: false,
+          selectedDeAiSkillId: undefined,
         }
         const migratedMessages = legacyMessages.map((m) => ({
           ...m,
@@ -193,7 +205,12 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       // Old combined format
       if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
         const data = parsed as PersistedChatData
-        return data
+        return {
+          conversations: Array.isArray(data.conversations)
+            ? data.conversations.map(normalizeConversation)
+            : [],
+          messages: Array.isArray(data.messages) ? data.messages : [],
+        }
       }
       console.warn("persist: 聊天历史数据格式无效")
       return { conversations: [], messages: [] }

+ 7 - 0
src/lib/sidebar-nav-preferences.spec.ts

@@ -14,6 +14,7 @@ describe("sidebar nav preferences", () => {
       "graph",
       "lint",
       "soul",
+      "skillLibrary",
       "bookAnalysis",
       "reviewCenter",
       "storySimulation",
@@ -36,6 +37,7 @@ describe("sidebar nav preferences", () => {
       "graph",
       "lint",
       "soul",
+      "skillLibrary",
       "bookAnalysis",
       "reviewCenter",
       "storySimulation",
@@ -56,4 +58,9 @@ describe("sidebar nav preferences", () => {
 
     expect(order.slice(0, 3)).toEqual(["trash", "wiki", "sources"])
   })
+
+  it("includes skill library in the configurable sidebar order", () => {
+    expect(DEFAULT_SIDEBAR_NAV_ORDER).toContain("skillLibrary")
+    expect(normalizeSidebarNavConfig({ order: ["wiki"] }).order).toContain("skillLibrary")
+  })
 })

+ 1 - 0
src/lib/sidebar-nav-preferences.ts

@@ -4,6 +4,7 @@ export const DEFAULT_SIDEBAR_NAV_ORDER = [
   "graph",
   "lint",
   "soul",
+  "skillLibrary",
   "bookAnalysis",
   "reviewCenter",
   "storySimulation",

+ 10 - 0
src/stores/chat-store.ts

@@ -8,6 +8,7 @@ export interface Conversation {
   createdAt: number
   updatedAt: number
   deAiMode: boolean
+  selectedDeAiSkillId?: string | null
   inputDraft?: string
 }
 
@@ -42,6 +43,7 @@ interface ChatState {
   setActiveConversation: (id: string | null) => void
   renameConversation: (id: string, title: string) => void
   setConversationDeAiMode: (id: string, deAiMode: boolean) => void
+  setConversationDeAiSkillId: (id: string, skillId: string | null | undefined) => void
   setConversationInputDraft: (id: string, draft: string) => void
 
   // Message management
@@ -102,6 +104,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
       createdAt: now,
       updatedAt: now,
       deAiMode: false,
+      selectedDeAiSkillId: undefined,
       inputDraft: "",
     }
     set((state) => ({
@@ -144,6 +147,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
       ),
     })),
 
+  setConversationDeAiSkillId: (id, selectedDeAiSkillId) =>
+    set((state) => ({
+      conversations: state.conversations.map((c) =>
+        c.id === id ? { ...c, selectedDeAiSkillId, updatedAt: Date.now() } : c
+      ),
+    })),
+
   setConversationInputDraft: (id, draft) =>
     set((state) => ({
       conversations: state.conversations.map((c) =>

+ 37 - 2
src/stores/wiki-store.ts

@@ -470,6 +470,13 @@ export interface PendingEditorHighlight {
 type LintRunFinishState = Omit<Partial<LintRunState>, "runId" | "projectPath" | "filePath">
 type ReviewRunFinishState = Omit<Partial<ReviewRunState>, "runId" | "projectPath" | "filePath">
 
+export const SKILL_LIBRARY_UNSAVED_CONFIRM = "当前 Skill 还有未保存修改,确定放弃修改吗?"
+
+export function confirmDiscardSkillLibraryDraft(): boolean {
+  if (typeof window === "undefined" || typeof window.confirm !== "function") return true
+  return window.confirm(SKILL_LIBRARY_UNSAVED_CONFIRM)
+}
+
 interface WikiState {
   project: WikiProject | null
   fileTree: FileNode[]
@@ -496,11 +503,13 @@ interface WikiState {
   chatExpanded: boolean
   chatDockPosition: ChatDockPosition
   searchPanelOpen: boolean
-  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
+  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "skillLibrary" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
   activeSettingsCategory: SettingsCategoryId | null
   selectedSoulId: string | null
   selectedSoulTab: "project" | "character"
   selectedSoulSection: "builtIn" | "custom"
+  selectedSkillLibrarySkillId: string | null
+  skillLibraryDraftDirty: boolean
   selectedReviewDimension: string | null
   selectedReviewFilePath: string
   selectedDismantlingProjectId: string | null
@@ -567,6 +576,8 @@ interface WikiState {
   setSelectedSoulId: (id: string | null) => void
   setSelectedSoulTab: (tab: "project" | "character") => void
   setSelectedSoulSection: (section: "builtIn" | "custom") => void
+  setSelectedSkillLibrarySkillId: (id: string | null) => void
+  setSkillLibraryDraftDirty: (dirty: boolean) => void
   setSelectedReviewDimension: (dimension: string | null) => void
   setSelectedReviewFilePath: (path: string) => void
   setSelectedDismantlingProjectId: (id: string | null) => void
@@ -633,6 +644,8 @@ export const useWikiStore = create<WikiState>((set) => ({
   selectedSoulId: null,
   selectedSoulTab: "project",
   selectedSoulSection: "builtIn",
+  selectedSkillLibrarySkillId: null,
+  skillLibraryDraftDirty: false,
   selectedReviewDimension: null,
   selectedReviewFilePath: "",
   selectedDismantlingProjectId: null,
@@ -684,11 +697,33 @@ export const useWikiStore = create<WikiState>((set) => ({
     set({ chatDockPosition })
   },
   setSearchPanelOpen: (searchPanelOpen) => set({ searchPanelOpen }),
-  setActiveView: (activeView) => set({ activeView }),
+  setActiveView: (activeView) => set((state) => {
+    if (
+      state.activeView === "skillLibrary"
+      && activeView !== "skillLibrary"
+      && state.skillLibraryDraftDirty
+      && !confirmDiscardSkillLibraryDraft()
+    ) {
+      return {}
+    }
+    return {
+      activeView,
+      skillLibraryDraftDirty: activeView === "skillLibrary" ? state.skillLibraryDraftDirty : false,
+    }
+  }),
   setActiveSettingsCategory: (activeSettingsCategory) => set({ activeSettingsCategory }),
   setSelectedSoulId: (selectedSoulId) => set({ selectedSoulId }),
   setSelectedSoulTab: (selectedSoulTab) => set({ selectedSoulTab }),
   setSelectedSoulSection: (selectedSoulSection) => set({ selectedSoulSection }),
+  setSelectedSkillLibrarySkillId: (selectedSkillLibrarySkillId) => set((state) => {
+    if (state.selectedSkillLibrarySkillId === selectedSkillLibrarySkillId) return {}
+    if (state.skillLibraryDraftDirty && !confirmDiscardSkillLibraryDraft()) return {}
+    return {
+      selectedSkillLibrarySkillId,
+      skillLibraryDraftDirty: false,
+    }
+  }),
+  setSkillLibraryDraftDirty: (skillLibraryDraftDirty) => set({ skillLibraryDraftDirty }),
   setSelectedReviewDimension: (selectedReviewDimension) => set({ selectedReviewDimension }),
   setSelectedReviewFilePath: (selectedReviewFilePath) => set({ selectedReviewFilePath }),
   setSelectedDismantlingProjectId: (selectedDismantlingProjectId) => set({ selectedDismantlingProjectId }),