Pārlūkot izejas kodu

feat: 完善去AI味技能库

Mochocyang 3 mēneši atpakaļ
vecāks
revīzija
babad762a0

+ 9 - 3
jinengku.md

@@ -15,10 +15,16 @@
 - 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。
+- 技能库功能代码准备提交并合并到 main。
+- 布局修复、内置 Skill 编辑改动、第一阶段优化和第二阶段优化纳入本次提交。
+- 已完成 Windows 本地便携版打包测试。
+- 第二阶段按用户要求未打包。
+- 本次按用户要求合并到 main。
 - 尚未推送 GitHub。

+ 9 - 1
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")
@@ -64,7 +65,7 @@ function LoadingView() {
 export function ContentArea() {
   const activeView = useWikiStore((s) => s.activeView)
   const novelMode = useWikiStore((s) => s.novelMode)
-  const showWritingWorkspace = activeView === "wiki" || activeView === "trash" || activeView === "skillLibrary"
+  const showWritingWorkspace = activeView === "wiki" || activeView === "trash"
 
   let content = null
   if (showWritingWorkspace) {
@@ -95,6 +96,13 @@ export function ContentArea() {
           </Suspense>
         )
         break
+      case "skillLibrary":
+        content = (
+          <Suspense fallback={<LoadingView />}>
+            <SkillLibraryView />
+          </Suspense>
+        )
+        break
       case "lint":
         content = (
           <Suspense fallback={<LoadingView />}>

+ 36 - 6
src/components/layout/preview-panel.tsx

@@ -23,6 +23,7 @@ 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,
@@ -189,14 +190,17 @@ 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)
@@ -276,6 +280,8 @@ export function PreviewPanel() {
     selectedFileRef.current = selectedFile
     setSelectionTransformOpen(false)
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
+    setSelectionTransformSkillName("")
     setLoadedFilePath(null)
 
     if (!selectedFile) {
@@ -760,9 +766,10 @@ export function PreviewPanel() {
     startOutlineIngestTask(project.path, selectedFile)
   }, [canIngestOutline, isOutlineIngesting, project, selectedFile])
 
-  const runWholeChapterDeAi = useCallback(async (skillContent: string) => {
+  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)) {
@@ -770,6 +777,7 @@ export function PreviewPanel() {
       setSaveStatus("未配置可用的 AI 模型,无法去AI味")
       return
     }
+    setSaveStatus(`去AI味处理中,使用 Skill:${skillName}...`)
     const source = fileContent
     let result = ""
     try {
@@ -785,6 +793,7 @@ export function PreviewPanel() {
             setDeAiCandidateContent(result)
             setDeAiPreviewOpen(true)
             setDeAiProcessing(false)
+            setSaveStatus(`本次使用 Skill:${skillName}`)
           },
           onError: (error) => {
             console.error("去AI味处理失败:", error)
@@ -800,6 +809,7 @@ export function PreviewPanel() {
 
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
     handleSave(replaceWholeChapterBody(fileContent, deAiCandidateContent))
   }, [deAiCandidateContent, fileContent, handleSave])
 
@@ -823,12 +833,14 @@ export function PreviewPanel() {
 
   const handleDeAiClose = useCallback(() => {
     setDeAiPreviewOpen(false)
+    setDeAiSkillName("")
   }, [])
 
   const runSelectionTransform = useCallback(async (
     action: ChapterSelectionAction,
     selection: ChapterBodySelection,
     skillContent?: string,
+    skillName?: string,
   ) => {
     if (!selection.text.trim()) return
     const state = useWikiStore.getState()
@@ -840,7 +852,10 @@ export function PreviewPanel() {
 
     const actionFile = selectedFileRef.current
     const actionLabel = action === "polish" ? "AI润色" : "去AI味"
-    setSaveStatus(`${actionLabel}处理中...`)
+    setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
+    setSaveStatus(action === "de-ai" && skillName
+      ? `${actionLabel}处理中,使用 Skill:${skillName}...`
+      : `${actionLabel}处理中...`)
 
     let result = ""
     try {
@@ -859,6 +874,7 @@ export function PreviewPanel() {
             setSelectionTransformSelection(selection)
             setSelectionTransformSourceContent(selection.text)
             setSelectionTransformCandidateContent(result)
+            setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
             setSelectionTransformOpen(true)
             setSaveStatus("")
           },
@@ -887,12 +903,14 @@ export function PreviewPanel() {
       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)
@@ -915,10 +933,10 @@ export function PreviewPanel() {
     }
 
     if (selection) {
-      await runSelectionTransform("de-ai", selection, skill.content)
+      await runSelectionTransform("de-ai", selection, skill.content, skill.name)
       return
     }
-    await runWholeChapterDeAi(skill.content)
+    await runWholeChapterDeAi(skill.content, skill.name)
   }, [deAiSkillPickerSkills, pendingSelectionForDeAi, project?.path, runSelectionTransform, runWholeChapterDeAi])
 
   const handleSelectionAction = useCallback((action: ChapterSelectionAction, selection: ChapterBodySelection) => {
@@ -952,6 +970,7 @@ export function PreviewPanel() {
     setSelectionTransformSelection(null)
     setSelectionTransformSourceContent("")
     setSelectionTransformCandidateContent("")
+    setSelectionTransformSkillName("")
     setSaveStatus("")
   }, [fileContent, handleSave, selectionTransformCandidateContent, selectionTransformSelection])
 
@@ -961,6 +980,7 @@ export function PreviewPanel() {
     setSelectionTransformSelection(null)
     setSelectionTransformSourceContent("")
     setSelectionTransformCandidateContent("")
+    setSelectionTransformSkillName("")
   }, [])
 
   useEffect(() => {
@@ -1419,7 +1439,14 @@ export function PreviewPanel() {
                   className="block w-full rounded px-2 py-2 text-left hover:bg-accent"
                   onClick={() => void handlePickedDeAiSkill(skill.id)}
                 >
-                  <div className="truncate text-sm">{skill.name}</div>
+                  <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}
@@ -1433,6 +1460,7 @@ export function PreviewPanel() {
         open={deAiPreviewOpen}
         sourceContent={deAiSourceContent}
         candidateContent={deAiCandidateContent}
+        skillName={deAiSkillName}
         onApply={handleDeAiApply}
         onSaveDraft={() => void handleDeAiSaveDraft()}
         onClose={handleDeAiClose}
@@ -1440,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}

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

@@ -24,7 +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 { SkillLibraryView } from "@/components/skill-library/skill-library-view"
+import { SkillLibrarySidebarPanel } from "@/components/skill-library/skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -1274,7 +1274,7 @@ export function SidebarPanel() {
   }
 
   if (activeView === "skillLibrary") {
-    return <SkillLibraryView />
+    return <SkillLibrarySidebarPanel />
   }
 
   if (activeView === "reviewCenter") {

+ 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>
   )
-}
+}

+ 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)
+  })
+})

+ 14 - 3
src/components/skill-library/de-ai-skill-picker.tsx

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react"
 import { WandSparkles, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
 import {
+  isDeAiSkillModified,
   loadDeAiSkillConfig,
   resolveAvailableDeAiSkills,
   resolveEffectiveDeAiSkill,
@@ -25,6 +26,7 @@ export function DeAiSkillPicker({
   const [open, setOpen] = useState(false)
   const [skills, setSkills] = useState<DeAiSkill[]>([])
   const [effectiveName, setEffectiveName] = useState("技能")
+  const [modifiedSkillIds, setModifiedSkillIds] = useState<string[]>([])
 
   useEffect(() => {
     let cancelled = false
@@ -33,11 +35,13 @@ export function DeAiSkillPicker({
         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("未启用")
         }
       })
@@ -52,10 +56,10 @@ export function DeAiSkillPicker({
         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="使用 / 调用命令和技能"
+        title={`当前去AI味 Skill:${effectiveName}`}
       >
         <WandSparkles className="h-4 w-4" />
-        <span className="max-w-[8rem] truncate">{buttonLabel ?? effectiveName}</span>
+        <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">
@@ -71,7 +75,14 @@ export function DeAiSkillPicker({
                 setOpen(false)
               }}
             >
-              <div className="truncate text-sm">{skill.name}</div>
+              <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>
           ))}

+ 224 - 11
src/components/skill-library/skill-library-view.spec.tsx

@@ -4,13 +4,14 @@ 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 { SkillLibraryView } from "./skill-library-view"
+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,
@@ -28,6 +29,23 @@ async function renderView() {
   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 }
 }
 
@@ -36,15 +54,53 @@ function cleanup(root: Root, container: HTMLElement) {
   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()
-    readFileMock.mockRejectedValue(new Error("missing"))
+    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 () => {
@@ -57,28 +113,185 @@ describe("SkillLibraryView", () => {
     cleanup(root, container)
   })
 
-  it("prevents saving an empty project skill name", async () => {
+  it("allows editing a built-in skill and restoring its default content", async () => {
     const { container, root } = await renderView()
-    const copyButton = container.querySelector<HTMLButtonElement>('[data-testid="skill-copy-button"]')
-    expect(copyButton).not.toBeNull()
+    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 () => {
-      copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      container.querySelector<HTMLButtonElement>('[data-testid="skill-reset-default-button"]')?.click()
     })
-    writeFileMock.mockClear()
 
+    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 () => {
-      if (!nameInput) return
-      const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
-      valueSetter?.call(nameInput, "")
-      nameInput.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }))
+      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()

+ 368 - 171
src/components/skill-library/skill-library-view.tsx

@@ -4,15 +4,17 @@ import {
   createProjectDeAiSkillFromTemplate,
   deleteProjectDeAiSkill,
   getAllDeAiSkills,
+  isDeAiSkillModified,
   loadDeAiSkillConfig,
+  resetBuiltInDeAiSkill,
   saveDeAiSkillConfig,
   setDeAiSkillEnabled,
   setDefaultDeAiSkill,
-  updateProjectDeAiSkill,
+  updateDeAiSkill,
   type DeAiSkill,
   type DeAiSkillConfig,
 } from "@/lib/novel/de-ai-skill-library"
-import { useWikiStore } from "@/stores/wiki-store"
+import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
 
 function sourceLabel(skill: DeAiSkill): string {
   if (skill.source === "built-in") return "内置"
@@ -20,10 +22,178 @@ function sourceLabel(skill: DeAiSkill): string {
   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 [selectedSkillId, setSelectedSkillId] = useState<string>("")
   const [draftName, setDraftName] = useState("")
   const [draftDescription, setDraftDescription] = useState("")
   const [draftContent, setDraftContent] = useState("")
@@ -32,20 +202,25 @@ export function SkillLibraryView() {
 
   const allSkills = useMemo(() => config ? getAllDeAiSkills(config) : [], [config])
   const selectedSkill = allSkills.find((skill) => skill.id === selectedSkillId) ?? allSkills[0] ?? null
-  const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
-  const selectedIsEditable = selectedSkill != null && selectedSkill.source !== "built-in"
+  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)
-    setSelectedSkillId("")
     setMessage("")
     loadDeAiSkillConfig(project?.path)
       .then((loaded) => {
         if (cancelled) return
         setConfig(loaded)
-        const firstSkill = getAllDeAiSkills(loaded)[0]
-        setSelectedSkillId(loaded.defaultSkillId || firstSkill?.id || "")
+        setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
       })
       .catch(() => {
         if (!cancelled) setMessage("技能库加载失败")
@@ -53,31 +228,46 @@ export function SkillLibraryView() {
     return () => {
       cancelled = true
     }
-  }, [project?.path])
+  }, [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("")
-  }, [selectedSkill?.id])
+    setDraftDirty(false)
+  }, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
 
-  async function persist(nextConfig: DeAiSkillConfig, nextSelectedSkillId = selectedSkillId) {
-    if (!project) {
-      setConfig(nextConfig)
-      setSelectedSkillId(nextSelectedSkillId)
-      return
-    }
+  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 {
-      await saveDeAiSkillConfig(project.path, nextConfig)
+      if (project) {
+        await saveDeAiSkillConfig(project.path, nextConfig)
+        bumpDataVersion()
+      }
       setConfig(nextConfig)
+      setDraftDirty(false)
       setSelectedSkillId(nextSelectedSkillId)
       setMessage("已保存")
     } catch {
@@ -89,15 +279,18 @@ export function SkillLibraryView() {
 
   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 || !selectedIsEditable) return
-    const name = draftName.trim()
-    const content = draftContent.trim()
+    if (!config || !selectedSkill || !canSaveDraft) return
+    const name = normalizeDraftText(draftName)
+    const description = normalizeDraftText(draftDescription)
+    const content = normalizeDraftText(draftContent)
     if (!name) {
       setMessage("技能名称不能为空")
       return
@@ -106,26 +299,29 @@ export function SkillLibraryView() {
       setMessage("技能规则不能为空")
       return
     }
-    await persist(updateProjectDeAiSkill(config, selectedSkill.id, {
+    await persist(updateDeAiSkill(config, selectedSkill.id, {
       name,
-      description: draftDescription.trim(),
+      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 handleToggleSkill(skill: DeAiSkill, enabled: boolean) {
-    if (!config) return
-    const next = setDeAiSkillEnabled(config, skill.id, enabled)
-    await persist(next, next.defaultSkillId)
-  }
-
   async function handleDeleteSkill() {
-    if (!config || !selectedSkill || selectedSkill.source === "built-in") return
+    if (!config || !selectedSkill || selectedIsBuiltIn) return
     const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
     if (!confirmed) return
     const next = deleteProjectDeAiSkill(config, selectedSkill.id)
@@ -133,168 +329,169 @@ export function SkillLibraryView() {
     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-3 py-2">
-        <h1 className="text-sm font-semibold">技能库</h1>
-        <p className="mt-0.5 text-xs text-muted-foreground">管理当前项目可用的去AI味技能。</p>
+      <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>
 
-      <div className="min-h-0 flex-1 overflow-y-auto">
-        <section className="border-b">
-          <div className="flex items-center justify-between px-3 py-2">
-            <div className="text-sm font-medium">去AI味技能</div>
-            <button
-              type="button"
-              onClick={() => {
-                if (!config) return
-                const next = createProjectDeAiSkillFromTemplate(config, BUILT_IN_DE_AI_SKILLS[0].id)
-                void persist(next, next.projectSkills[0]?.id)
-              }}
-              className="rounded-md border px-2 py-1 text-xs hover:bg-accent"
-            >
-              新建技能
-            </button>
-          </div>
-
-          <div data-testid="skill-list" className="max-h-[42vh] overflow-y-auto px-2 pb-2">
-            {allSkills.map((skill) => {
-              const active = skill.id === selectedSkill?.id
-              const enabled = !disabledSkillIds.has(skill.id)
-              return (
-                <div
-                  key={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)}
+      <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>
-                    {config?.defaultSkillId === skill.id ? (
-                      <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] text-primary-foreground">默认</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"
-                    />
-                    显示在调用入口
-                  </label>
-                </div>
-              )
-            })}
-          </div>
-        </section>
-
-        <main className="min-w-0 p-3">
-          {!selectedSkill ? (
-            <div className="text-sm text-muted-foreground">暂无技能。</div>
-          ) : (
-            <div className="flex flex-col gap-3">
-              <div className="flex flex-col gap-2">
-                <div className="min-w-0">
-                  <div className="text-xs text-muted-foreground">{sourceLabel(selectedSkill)}技能</div>
-                  <h2 className="truncate text-base font-semibold">{selectedSkill.name}</h2>
+                  ) : null}
                 </div>
-                <div className="grid grid-cols-2 gap-2">
+              </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-copy-button"
+                    data-testid="skill-reset-default-button"
                     type="button"
-                    onClick={() => void handleCopySkill()}
-                    className="rounded-md border px-2 py-1.5 text-xs hover:bg-accent"
-                    disabled={saving}
+                    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-default-button"
+                    data-testid="skill-delete-button"
                     type="button"
-                    onClick={() => void handleSetDefault()}
-                    className="rounded-md border px-2 py-1.5 text-xs hover:bg-accent"
-                    disabled={saving || config?.defaultSkillId === selectedSkill.id}
+                    onClick={() => void handleDeleteSkill()}
+                    className="rounded-md border px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
+                    disabled={saving}
                   >
-                    设为默认
+                    删除
                   </button>
-                  {selectedSkill.source !== "built-in" ? (
-                    <button
-                      data-testid="skill-delete-button"
-                      type="button"
-                      onClick={() => void handleDeleteSkill()}
-                      className="rounded-md border px-2 py-1.5 text-xs text-destructive hover:bg-destructive/10"
-                      disabled={saving}
-                    >
-                      删除
-                    </button>
-                  ) : null}
-                </div>
+                ) : null}
               </div>
+            </div>
 
-              <label className="grid gap-1.5 text-xs">
-                <span className="font-medium">技能名称</span>
-                <input
-                  data-testid="skill-name-input"
-                  value={draftName}
-                  onChange={(event) => setDraftName(event.target.value)}
-                  disabled={!selectedIsEditable}
-                  className="rounded-md border bg-background px-2 py-1.5 text-sm 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-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-xs">
-                <span className="font-medium">说明</span>
-                <input
-                  data-testid="skill-description-input"
-                  value={draftDescription}
-                  onChange={(event) => setDraftDescription(event.target.value)}
-                  disabled={!selectedIsEditable}
-                  className="rounded-md border bg-background px-2 py-1.5 text-sm 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 gap-1.5 text-xs">
-                <span className="font-medium">规则正文</span>
-                <textarea
-                  data-testid="skill-content-input"
-                  value={draftContent}
-                  onChange={(event) => setDraftContent(event.target.value)}
-                  disabled={!selectedIsEditable}
-                  className="min-h-[280px] rounded-md border bg-background px-2 py-2 font-mono text-xs leading-5 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 flex-wrap items-center gap-2">
-                <button
-                  data-testid="skill-save-button"
-                  type="button"
-                  onClick={() => void handleSaveSkill()}
-                  disabled={!selectedIsEditable || saving}
-                  className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
-                >
-                  {saving ? "保存中..." : "保存"}
-                </button>
-                {message ? <span className="text-xs text-muted-foreground">{message}</span> : null}
-              </div>
+            <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>
-          )}
-        </main>
-      </div>
+          </div>
+        )}
+      </main>
     </div>
   )
 }

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

@@ -4,12 +4,16 @@ import {
   DEFAULT_DE_AI_SKILL_ID,
   createProjectDeAiSkillFromTemplate,
   deleteProjectDeAiSkill,
+  getAllDeAiSkills,
+  isDeAiSkillModified,
   loadDeAiSkillConfig,
   normalizeDeAiSkillConfig,
+  resetBuiltInDeAiSkill,
   resolveAvailableDeAiSkills,
   resolveEffectiveDeAiSkill,
   setDeAiSkillEnabled,
   setDefaultDeAiSkill,
+  updateDeAiSkill,
   updateProjectDeAiSkill,
 } from "./de-ai-skill-library"
 
@@ -47,6 +51,7 @@ describe("de-ai skill library", () => {
       defaultSkillId: DEFAULT_DE_AI_SKILL_ID,
       disabledSkillIds: [],
       projectSkills: [],
+      builtInSkillOverrides: [],
     })
   })
 
@@ -100,6 +105,53 @@ describe("de-ai skill library", () => {
     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" })
 

+ 81 - 1
src/lib/novel/de-ai-skill-library.ts

@@ -20,6 +20,7 @@ export interface DeAiSkillConfig {
   defaultSkillId: string
   disabledSkillIds: string[]
   projectSkills: DeAiSkill[]
+  builtInSkillOverrides: DeAiSkill[]
 }
 
 export const DEFAULT_DE_AI_SKILL_ID = "built-in:comprehensive"
@@ -106,11 +107,36 @@ function normalizeProjectSkill(value: unknown): DeAiSkill | null {
   }
 }
 
+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
@@ -120,11 +146,14 @@ export function normalizeDeAiSkillConfig(value: unknown): DeAiSkillConfig {
     defaultSkillId,
     disabledSkillIds,
     projectSkills,
+    builtInSkillOverrides,
   }
 }
 
 export function getAllDeAiSkills(config: DeAiSkillConfig): DeAiSkill[] {
-  return [...config.projectSkills, ...BUILT_IN_DE_AI_SKILLS]
+  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[] {
@@ -143,6 +172,18 @@ export function resolveEffectiveDeAiSkill(
   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,
@@ -182,6 +223,45 @@ export function updateProjectDeAiSkill(
   })
 }
 
+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 })
 }

+ 36 - 1
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[]
@@ -501,6 +508,8 @@ interface WikiState {
   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 }),