Răsfoiți Sursa

优化技能库与 Skill 选择流程

Mochocyang 2 luni în urmă
părinte
comite
ccd978822b

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

@@ -0,0 +1,183 @@
+// @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 { UnifiedSkillLibrarySidebarPanel, UnifiedSkillLibraryView } from "./unified-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 writeFileAtomicMock = vi.hoisted(() => vi.fn())
+const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: readFileMock,
+  writeFile: writeFileMock,
+  writeFileAtomic: writeFileAtomicMock,
+}))
+
+vi.mock("@tauri-apps/api/path", () => ({
+  join: joinMock,
+}))
+
+const deAiConfig = {
+  version: 1,
+  defaultSkillId: "project:quiet",
+  disabledSkillIds: [],
+  lastChapterDeAiSkillId: null,
+  projectSkills: [{
+    id: "project:quiet",
+    name: "沉浸式去AI味",
+    description: "减少解释腔和总结腔",
+    templateId: "custom",
+    content: "删除协作口吻,保留角色语气。",
+    source: "project",
+    createdAt: 100,
+    updatedAt: 100,
+  }],
+  builtInSkillOverrides: [],
+}
+
+const writingConfig = {
+  version: 1,
+  selectedSkillId: "skill:three",
+  disabledSkillIds: [],
+  skills: [{
+    id: "skill:three",
+    name: "三翻四抖",
+    description: "三次转折,四次震惊。",
+    kind: ["structure", "review"],
+    stages: ["planning", "review"],
+    modes: ["standard", "strict"],
+    content: "每章设置三次局势变化和四次信息冲击。",
+    source: "uploaded",
+    createdAt: 100,
+    updatedAt: 100,
+  }],
+}
+
+async function renderLibrary() {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  await act(async () => {
+    root.render(
+      <>
+        <UnifiedSkillLibrarySidebarPanel />
+        <UnifiedSkillLibraryView />
+      </>,
+    )
+  })
+  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()
+    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: "insertText", data: value }))
+    input.dispatchEvent(new Event("change", { bubbles: true }))
+  })
+}
+
+function getButton(container: HTMLElement, label: string): HTMLButtonElement | undefined {
+  return Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+    .find((button) => button.textContent?.trim() === label)
+}
+
+describe("UnifiedSkillLibraryView", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    readFileMock.mockImplementation(async (path: string) => {
+      if (path.endsWith("de-ai-skills.json")) return JSON.stringify(deAiConfig)
+      if (path.endsWith("writing-skills.json")) return JSON.stringify(writingConfig)
+      throw new Error("missing")
+    })
+    writeFileMock.mockResolvedValue(undefined)
+    writeFileAtomicMock.mockResolvedValue(undefined)
+    useWikiStore.getState().setProject({
+      id: "p1",
+      name: "测试项目",
+      path: "C:/project",
+    })
+    useWikiStore.getState().setActiveView("skillLibrary")
+    useWikiStore.getState().setSelectedSkillLibrarySkillId(null)
+    useWikiStore.getState().setSelectedWritingSkillLibrarySkillId(null)
+    useWikiStore.getState().setSkillLibraryDraftDirty(false)
+    useWikiStore.getState().setWritingSkillLibraryDraftDirty(false)
+  })
+
+  it("renders one unified sidebar with category filter chips", async () => {
+    const { container, root } = await renderLibrary()
+
+    expect(container.querySelector('[data-testid="unified-skill-library-sidebar"]')).not.toBeNull()
+    expect(container.querySelector("h1")?.textContent).toBe("技能库")
+    for (const label of ["全部", "写作", "去AI味", "审稿", "输出", "知识"]) {
+      expect(getButton(container, label)).not.toBeUndefined()
+    }
+
+    cleanup(root, container)
+  })
+
+  it("filters writing and de-AI skills from one search input", async () => {
+    const { container, root } = await renderLibrary()
+    const sidebar = container.querySelector<HTMLElement>('[data-testid="unified-skill-library-sidebar"]')
+    const searchInput = container.querySelector<HTMLInputElement>('[data-testid="unified-skill-search-input"]')
+    expect(sidebar).not.toBeNull()
+    expect(searchInput).not.toBeNull()
+
+    expect(sidebar?.textContent).toContain("三翻四抖")
+    expect(sidebar?.textContent).toContain("沉浸式去AI味")
+
+    await setInputValue(searchInput!, "三翻")
+    expect(sidebar?.textContent).toContain("三翻四抖")
+    expect(sidebar?.textContent).not.toContain("沉浸式去AI味")
+
+    await setInputValue(searchInput!, "解释腔")
+    expect(sidebar?.textContent).not.toContain("三翻四抖")
+    expect(sidebar?.textContent).toContain("沉浸式去AI味")
+
+    cleanup(root, container)
+  })
+
+  it("routes selected unified entries to their existing detail views", async () => {
+    const { container, root } = await renderLibrary()
+
+    await act(async () => {
+      container.querySelector<HTMLElement>('[data-testid="unified-skill-entry-writing:skill:three"]')?.click()
+    })
+    await flushEffects()
+
+    expect(useWikiStore.getState().activeView).toBe("writingSkillLibrary")
+    expect(useWikiStore.getState().selectedWritingSkillLibrarySkillId).toBe("skill:three")
+    expect(container.querySelector('[data-testid="writing-skill-library-view"]')).not.toBeNull()
+
+    await act(async () => {
+      container.querySelector<HTMLElement>('[data-testid="unified-skill-entry-de-ai:project:quiet"]')?.click()
+    })
+    await flushEffects()
+
+    expect(useWikiStore.getState().activeView).toBe("skillLibrary")
+    expect(useWikiStore.getState().selectedSkillLibrarySkillId).toBe("project:quiet")
+    expect(container.querySelector('[data-testid="skill-library-view"]')).not.toBeNull()
+
+    cleanup(root, container)
+  })
+})

+ 211 - 29
src/components/skill-library/unified-skill-library-view.tsx

@@ -1,34 +1,159 @@
+import { useEffect, useMemo, useState } from "react"
+import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+import { SKILL_KIND_LABELS, SKILL_MODE_LABELS, SKILL_STAGE_LABELS } from "@/lib/novel/skill-library"
+import { loadUserSkillConfig, type UserSkillConfig } from "@/lib/novel/user-skill-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view"
-import { WritingSkillLibrarySidebarPanel, WritingSkillLibraryView } from "./writing-skill-library-view"
+import { SkillLibraryView } from "./skill-library-view"
+import {
+  buildUnifiedSkillEntries,
+  filterUnifiedSkillEntries,
+  type UnifiedSkillEntry,
+  type UnifiedSkillFilter,
+} from "./unified-skill-model"
+import { WritingSkillLibraryView } from "./writing-skill-library-view"
 
-const skillLibraryTabs = [
-  { view: "skillLibrary" as const, label: "去AI味技能" },
-  { view: "writingSkillLibrary" as const, label: "写作 Skill" },
+interface SkillLibraryQuickFilter {
+  label: string
+  filter: UnifiedSkillFilter
+}
+
+const quickFilters: SkillLibraryQuickFilter[] = [
+  { label: "全部", filter: {} },
+  { label: "写作", filter: { library: "writing" } },
+  { label: "去AI味", filter: { category: "去AI味" } },
+  { label: "审稿", filter: { category: "审稿" } },
+  { label: "输出", filter: { category: "输出" } },
+  { label: "知识", filter: { category: "知识" } },
 ]
 
-function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
+function sourceLabel(entry: UnifiedSkillEntry): string {
+  if (entry.library === "de-ai") {
+    if (entry.source === "built-in") return "内置"
+    if (entry.source === "legacy") return "旧版"
+    return "项目"
+  }
+  if (entry.source === "built-in") return "内置"
+  if (entry.source === "project") return "项目"
+  return "写作"
+}
+
+function entryMeta(entry: UnifiedSkillEntry): string {
+  const modes = entry.modes.map((mode) => SKILL_MODE_LABELS[mode]).join("、")
+  const stages = entry.stages.map((stage) => SKILL_STAGE_LABELS[stage]).join("、")
+  const kinds = entry.kind.map((kind) => SKILL_KIND_LABELS[kind]).join("、")
+  return [modes, stages, kinds].filter(Boolean).join(" / ")
+}
+
+function useUnifiedSkillEntries() {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const [deAiConfig, setDeAiConfig] = useState<DeAiSkillConfig | null>(null)
+  const [writingConfig, setWritingConfig] = useState<UserSkillConfig | null>(null)
+  const [loadError, setLoadError] = useState("")
+
+  useEffect(() => {
+    let cancelled = false
+    setDeAiConfig(null)
+    setWritingConfig(null)
+    setLoadError("")
+
+    Promise.all([
+      loadDeAiSkillConfig(projectPath),
+      loadUserSkillConfig(projectPath),
+    ])
+      .then(([nextDeAiConfig, nextWritingConfig]) => {
+        if (cancelled) return
+        setDeAiConfig(nextDeAiConfig)
+        setWritingConfig(nextWritingConfig)
+      })
+      .catch(() => {
+        if (cancelled) return
+        setLoadError("技能库加载失败")
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [dataVersion, projectPath])
+
+  const entries = useMemo(() => {
+    if (!deAiConfig || !writingConfig) return []
+    return buildUnifiedSkillEntries(deAiConfig, writingConfig)
+  }, [deAiConfig, writingConfig])
+
+  return {
+    entries,
+    loading: !loadError && (!deAiConfig || !writingConfig),
+    loadError,
+  }
+}
+
+function selectUnifiedEntry(entry: UnifiedSkillEntry) {
+  const store = useWikiStore.getState()
+  if (entry.library === "writing") {
+    store.setActiveView("writingSkillLibrary")
+    if (useWikiStore.getState().activeView !== "writingSkillLibrary") return
+    useWikiStore.getState().setSelectedWritingSkillLibrarySkillId(entry.skillId)
+    return
+  }
+
+  store.setActiveView("skillLibrary")
+  if (useWikiStore.getState().activeView !== "skillLibrary") return
+  useWikiStore.getState().setSelectedSkillLibrarySkillId(entry.skillId)
+}
+
+function UnifiedSkillRow({ entry }: { entry: UnifiedSkillEntry }) {
   const activeView = useWikiStore((s) => s.activeView)
-  const setActiveView = useWikiStore((s) => s.setActiveView)
-  const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
+  const selectedDeAiSkillId = useWikiStore((s) => s.selectedSkillLibrarySkillId)
+  const selectedWritingSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const active = entry.library === "writing"
+    ? activeView === "writingSkillLibrary" && selectedWritingSkillId === entry.skillId
+    : activeView !== "writingSkillLibrary" && selectedDeAiSkillId === entry.skillId
 
   return (
-    <div className={`flex shrink-0 items-center gap-1 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
-      {skillLibraryTabs.map((tab) => (
-        <button
-          key={tab.view}
-          type="button"
-          aria-pressed={activeTab === tab.view}
-          onClick={() => setActiveView(tab.view)}
-          className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
-            activeTab === tab.view
-              ? "bg-primary text-primary-foreground"
-              : "text-muted-foreground hover:bg-accent hover:text-foreground"
-          }`}
+    <div
+      data-testid={`unified-skill-entry-${entry.id}`}
+      role="button"
+      tabIndex={0}
+      onClick={() => selectUnifiedEntry(entry)}
+      onKeyDown={(event) => {
+        if (event.key === "Enter" || event.key === " ") {
+          event.preventDefault()
+          selectUnifiedEntry(entry)
+        }
+      }}
+      className={`mb-2 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">{entry.name}</span>
+        <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+          {entry.library === "writing" ? "写作" : "去AI味"}
+        </span>
+        <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+          {sourceLabel(entry)}
+        </span>
+      </div>
+      <div className="mt-1 truncate text-xs text-muted-foreground">
+        {entry.description || "未填写说明"}
+      </div>
+      <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
+        <span className="rounded bg-secondary px-1.5 py-0.5 text-secondary-foreground">{entry.category}</span>
+        <span className={`rounded px-1.5 py-0.5 ${
+          entry.enabled ? "bg-emerald-50 text-emerald-700" : "bg-muted text-muted-foreground"
+        }`}
         >
-          {tab.label}
-        </button>
-      ))}
+          {entry.status}
+        </span>
+        {entry.defaultSkill ? (
+          <span className="rounded bg-primary px-1.5 py-0.5 text-primary-foreground">默认</span>
+        ) : null}
+        {entry.modified ? (
+          <span className="rounded bg-amber-100 px-1.5 py-0.5 text-amber-800">已修改</span>
+        ) : null}
+      </div>
+      <div className="mt-1 truncate text-[10px] text-muted-foreground">{entryMeta(entry)}</div>
     </div>
   )
 }
@@ -39,7 +164,6 @@ export function UnifiedSkillLibraryView() {
 
   return (
     <div data-testid="unified-skill-library-view" className="flex h-full flex-col overflow-hidden">
-      <SkillLibraryTabs />
       <div className="min-h-0 flex-1 overflow-hidden">
         {showWritingSkill ? <WritingSkillLibraryView /> : <SkillLibraryView />}
       </div>
@@ -48,14 +172,72 @@ export function UnifiedSkillLibraryView() {
 }
 
 export function UnifiedSkillLibrarySidebarPanel() {
-  const activeView = useWikiStore((s) => s.activeView)
-  const showWritingSkill = activeView === "writingSkillLibrary"
+  const { entries, loading, loadError } = useUnifiedSkillEntries()
+  const [query, setQuery] = useState("")
+  const [activeFilterLabel, setActiveFilterLabel] = useState("全部")
+  const activeFilter = quickFilters.find((filter) => filter.label === activeFilterLabel)?.filter ?? {}
+  const visibleEntries = useMemo(() => {
+    return filterUnifiedSkillEntries(entries, {
+      ...activeFilter,
+      query,
+    })
+  }, [activeFilter, entries, query])
 
   return (
     <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
-      <SkillLibraryTabs compact />
-      <div className="min-h-0 flex-1 overflow-hidden">
-        {showWritingSkill ? <WritingSkillLibrarySidebarPanel /> : <SkillLibrarySidebarPanel />}
+      <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">统一管理写作 Skill 与去AI味技能。</p>
+      </div>
+
+      <div className="shrink-0 border-b px-3 py-2">
+        <label className="sr-only" htmlFor="unified-skill-search-input">搜索技能</label>
+        <input
+          id="unified-skill-search-input"
+          data-testid="unified-skill-search-input"
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+          placeholder="搜索 Skill 名称、说明、规则"
+          className="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-primary"
+        />
+        <div className="mt-2 flex flex-wrap gap-1.5">
+          {quickFilters.map((filter) => {
+            const active = activeFilterLabel === filter.label
+            return (
+              <button
+                key={filter.label}
+                type="button"
+                aria-pressed={active}
+                onClick={() => setActiveFilterLabel(filter.label)}
+                className={`rounded-md border px-2 py-1 text-xs transition-colors ${
+                  active
+                    ? "border-primary bg-primary text-primary-foreground"
+                    : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
+                }`}
+              >
+                {filter.label}
+              </button>
+            )
+          })}
+        </div>
+      </div>
+
+      {loadError ? (
+        <div className="border-b px-3 py-2 text-xs text-destructive">{loadError}</div>
+      ) : null}
+
+      <div className="min-h-0 flex-1 overflow-y-auto p-2">
+        {loading ? (
+          <div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">正在加载技能库...</div>
+        ) : null}
+        {!loading && visibleEntries.length === 0 ? (
+          <div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
+            没有匹配的 Skill。
+          </div>
+        ) : null}
+        {visibleEntries.map((entry) => (
+          <UnifiedSkillRow key={entry.id} entry={entry} />
+        ))}
       </div>
     </div>
   )

+ 128 - 0
src/components/skill-library/unified-skill-model.spec.ts

@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest"
+import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
+import {
+  buildUnifiedSkillEntries,
+  filterUnifiedSkillEntries,
+  getUnifiedSkillCategory,
+  getUnifiedSkillStatus,
+} from "./unified-skill-model"
+
+const deAiConfig: DeAiSkillConfig = {
+  version: 1,
+  defaultSkillId: "built-in:comprehensive",
+  disabledSkillIds: ["project:quiet"],
+  lastChapterDeAiSkillId: null,
+  projectSkills: [
+    {
+      id: "project:quiet",
+      name: "沉浸式去AI味",
+      description: "减少解释腔和总结腔",
+      templateId: "custom",
+      content: "删除协作口吻,保留角色语气。",
+      source: "project",
+      createdAt: 100,
+      updatedAt: 200,
+    },
+  ],
+  builtInSkillOverrides: [
+    {
+      id: "built-in:comprehensive",
+      name: "综合去AI味-项目版",
+      description: "项目覆盖版本",
+      templateId: "comprehensive",
+      content: "去掉模板句和机械总结。",
+      source: "built-in",
+      updatedAt: 300,
+    },
+  ],
+}
+
+const writingConfig: UserSkillConfig = {
+  version: 1,
+  selectedSkillId: "skill:rhythm",
+  disabledSkillIds: [],
+  skills: [
+    {
+      id: "skill:rhythm",
+      name: "节奏压迫",
+      description: "用于严格模式下审稿和改写章节节奏",
+      kind: ["review", "structure"],
+      stages: ["review", "rewrite"],
+      modes: ["strict"],
+      content: "检查中段塌陷、重复场景和结尾钩子。",
+      source: "uploaded",
+      createdAt: 100,
+      updatedAt: 200,
+    },
+  ],
+}
+
+describe("unified skill model", () => {
+  it("builds unified entries from writing and de-AI skill configs", () => {
+    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
+
+    expect(entries.map((entry) => entry.id)).toContain("de-ai:project:quiet")
+    expect(entries.map((entry) => entry.id)).toContain("writing:skill:rhythm")
+
+    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
+    expect(deAi).toMatchObject({
+      library: "de-ai",
+      source: "project",
+      enabled: false,
+      defaultSkill: false,
+      category: "去AI味",
+      kind: ["style", "rewrite"],
+      stages: ["rewrite", "output"],
+      modes: ["fast", "standard", "strict"],
+    })
+
+    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
+    expect(writing).toMatchObject({
+      library: "writing",
+      source: "uploaded",
+      enabled: true,
+      defaultSkill: true,
+      category: "审稿",
+      kind: ["review", "structure"],
+      stages: ["review", "rewrite"],
+      modes: ["strict"],
+    })
+  })
+
+  it("marks overridden built-in de-AI skill as modified", () => {
+    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
+    const overridden = entries.find((entry) => entry.id === "de-ai:built-in:comprehensive")
+
+    expect(overridden).toMatchObject({
+      name: "综合去AI味-项目版",
+      modified: true,
+      defaultSkill: true,
+      status: "启用",
+    })
+  })
+
+  it("filters entries by query, category, status, mode, stage and kind", () => {
+    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
+
+    expect(filterUnifiedSkillEntries(entries, { query: "压迫" }).map((entry) => entry.id))
+      .toEqual(["writing:skill:rhythm"])
+    expect(filterUnifiedSkillEntries(entries, { category: "去AI味" }).every((entry) => entry.library === "de-ai"))
+      .toBe(true)
+    expect(filterUnifiedSkillEntries(entries, { status: "disabled" }).map((entry) => entry.id))
+      .toEqual(["de-ai:project:quiet"])
+    expect(filterUnifiedSkillEntries(entries, { mode: "strict", stage: "review", kind: "review" }).map((entry) => entry.id))
+      .toEqual(["writing:skill:rhythm"])
+  })
+
+  it("returns user-facing category and status labels", () => {
+    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
+    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
+    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
+
+    expect(deAi && getUnifiedSkillCategory(deAi)).toBe("去AI味")
+    expect(writing && getUnifiedSkillCategory(writing)).toBe("审稿")
+    expect(deAi && getUnifiedSkillStatus(deAi)).toBe("停用")
+    expect(writing && getUnifiedSkillStatus(writing)).toBe("启用")
+  })
+})

+ 169 - 0
src/components/skill-library/unified-skill-model.ts

@@ -0,0 +1,169 @@
+import {
+  getAllDeAiSkills,
+  isDeAiSkillModified,
+  type DeAiSkillConfig,
+  type DeAiSkillSource,
+} from "@/lib/novel/de-ai-skill-library"
+import {
+  SKILL_KIND_LABELS,
+  SKILL_STAGE_LABELS,
+  type SkillKind,
+  type SkillMode,
+  type SkillStage,
+} from "@/lib/novel/skill-library"
+import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
+
+export type UnifiedSkillLibrary = "writing" | "de-ai"
+export type UnifiedSkillSource = DeAiSkillSource | "built-in" | "project" | "uploaded"
+export type UnifiedSkillStatusFilter = "enabled" | "disabled"
+
+export interface UnifiedSkillEntry {
+  id: string
+  skillId: string
+  library: UnifiedSkillLibrary
+  name: string
+  description: string
+  content: string
+  kind: SkillKind[]
+  stages: SkillStage[]
+  modes: SkillMode[]
+  enabled: boolean
+  source: UnifiedSkillSource
+  modified: boolean
+  defaultSkill: boolean
+  category: string
+  status: "启用" | "停用"
+  searchText: string
+}
+
+export interface UnifiedSkillFilter {
+  query?: string
+  library?: UnifiedSkillLibrary
+  category?: string
+  status?: UnifiedSkillStatusFilter
+  mode?: SkillMode
+  stage?: SkillStage
+  kind?: SkillKind
+}
+
+const DE_AI_KIND: SkillKind[] = ["style", "rewrite"]
+const DE_AI_STAGES: SkillStage[] = ["rewrite", "output"]
+const DE_AI_MODES: SkillMode[] = ["fast", "standard", "strict"]
+
+function createSearchText(values: Array<string | string[] | undefined>): string {
+  return values
+    .flatMap((value) => Array.isArray(value) ? value : [value])
+    .filter((value): value is string => Boolean(value))
+    .join("\n")
+    .toLocaleLowerCase()
+}
+
+function getWritingSkillCategory(kind: SkillKind[], stages: SkillStage[]): string {
+  const preferredKind = kind.find((item) => item === "review" || item === "output" || item === "knowledge")
+    ?? kind[0]
+  if (preferredKind) return SKILL_KIND_LABELS[preferredKind]
+  const preferredStage = stages[0]
+  return preferredStage ? SKILL_STAGE_LABELS[preferredStage] : "写作"
+}
+
+export function buildUnifiedSkillEntries(
+  deAiConfig: DeAiSkillConfig,
+  writingConfig: UserSkillConfig,
+): UnifiedSkillEntry[] {
+  const disabledDeAiIds = new Set(deAiConfig.disabledSkillIds)
+  const disabledWritingIds = new Set(writingConfig.disabledSkillIds)
+
+  const deAiEntries = getAllDeAiSkills(deAiConfig).map((skill): UnifiedSkillEntry => {
+    const enabled = !disabledDeAiIds.has(skill.id)
+    const category = "去AI味"
+    const status = enabled ? "启用" : "停用"
+    return {
+      id: `de-ai:${skill.id}`,
+      skillId: skill.id,
+      library: "de-ai",
+      name: skill.name,
+      description: skill.description,
+      content: skill.content,
+      kind: DE_AI_KIND,
+      stages: DE_AI_STAGES,
+      modes: DE_AI_MODES,
+      enabled,
+      source: skill.source,
+      modified: isDeAiSkillModified(deAiConfig, skill.id),
+      defaultSkill: deAiConfig.defaultSkillId === skill.id,
+      category,
+      status,
+      searchText: createSearchText([
+        skill.name,
+        skill.description,
+        skill.content,
+        category,
+        status,
+        DE_AI_KIND,
+        DE_AI_STAGES,
+        DE_AI_MODES,
+      ]),
+    }
+  })
+
+  const writingEntries = writingConfig.skills.map((skill): UnifiedSkillEntry => {
+    const enabled = !disabledWritingIds.has(skill.id)
+    const category = getWritingSkillCategory(skill.kind, skill.stages)
+    const status = enabled ? "启用" : "停用"
+    return {
+      id: `writing:${skill.id}`,
+      skillId: skill.id,
+      library: "writing",
+      name: skill.name,
+      description: skill.description,
+      content: skill.content,
+      kind: skill.kind,
+      stages: skill.stages,
+      modes: skill.modes,
+      enabled,
+      source: skill.source,
+      modified: skill.source !== "built-in" && typeof skill.updatedAt === "number" && skill.updatedAt > (skill.createdAt ?? 0),
+      defaultSkill: writingConfig.selectedSkillId === skill.id,
+      category,
+      status,
+      searchText: createSearchText([
+        skill.name,
+        skill.description,
+        skill.content,
+        category,
+        status,
+        skill.kind,
+        skill.stages,
+        skill.modes,
+      ]),
+    }
+  })
+
+  return [...writingEntries, ...deAiEntries]
+}
+
+export function filterUnifiedSkillEntries(
+  entries: UnifiedSkillEntry[],
+  filter: UnifiedSkillFilter,
+): UnifiedSkillEntry[] {
+  const query = filter.query?.trim().toLocaleLowerCase()
+  return entries.filter((entry) => {
+    if (query && !entry.searchText.includes(query)) return false
+    if (filter.library && entry.library !== filter.library) return false
+    if (filter.category && entry.category !== filter.category) return false
+    if (filter.status === "enabled" && !entry.enabled) return false
+    if (filter.status === "disabled" && entry.enabled) return false
+    if (filter.mode && !entry.modes.includes(filter.mode)) return false
+    if (filter.stage && !entry.stages.includes(filter.stage)) return false
+    if (filter.kind && !entry.kind.includes(filter.kind)) return false
+    return true
+  })
+}
+
+export function getUnifiedSkillCategory(entry: UnifiedSkillEntry): string {
+  return entry.category
+}
+
+export function getUnifiedSkillStatus(entry: UnifiedSkillEntry): "启用" | "停用" {
+  return entry.enabled ? "启用" : "停用"
+}

+ 50 - 5
src/lib/agent/plugins/select-skills-plugin.spec.ts

@@ -51,6 +51,7 @@ describe("SelectSkillsPlugin", () => {
       "冲突升级",
       "剧情自检",
       "正文输出协议",
+      "去AI味",
     ])
   })
 
@@ -90,14 +91,20 @@ describe("SelectSkillsPlugin", () => {
       agentConfig: {} as any,
       novelMode: true,
       aiWorkflowMode: "fast",
-      availableSkills,
+      availableSkills: [
+        ...availableSkills,
+        skill({ id: "fast-structure", name: "快速结构扩写", kind: ["structure"], stages: ["drafting"], modes: ["fast"] }),
+        skill({ id: "fast-review", name: "快速审稿", kind: ["review"], stages: ["review"], modes: ["fast"] }),
+      ],
       taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
     })
 
-    expect(result.selectedSkills?.map((item) => item.name)).toEqual([
-      "正文输出协议",
-      "去AI味",
-    ])
+    expect(result.selectedSkills?.length).toBeLessThanOrEqual(3)
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual(["正文输出协议", "去AI味"])
+    expect(result.selectedSkills?.every((item) =>
+      item.kind.some((kind) => kind === "output" || kind === "style")
+      || item.stages.some((stage) => stage === "output" || stage === "rewrite"),
+    )).toBe(true)
   })
 
   it("selects strict review and structure skills for key chapter writing", async () => {
@@ -120,6 +127,7 @@ describe("SelectSkillsPlugin", () => {
       "冲突升级",
       "剧情自检",
       "正文输出协议",
+      "去AI味",
       "主线检查",
       "伏笔管理",
       "节奏检查",
@@ -127,6 +135,43 @@ describe("SelectSkillsPlugin", () => {
     ])
   })
 
+  it("prioritizes relevant uploaded project skills over generic built-ins", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "生成一份带世界观约束和主线推进的大纲",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableSkills: [
+        skill({
+          id: "builtin:outline-generic",
+          name: "通用大纲模板",
+          description: "普通大纲结构。",
+          kind: ["planning", "structure"],
+          stages: ["planning"],
+          modes: ["standard"],
+          source: "built-in",
+          content: "生成普通大纲。",
+        }),
+        skill({
+          id: "skill:project-outline",
+          name: "项目大纲约束",
+          description: "结合本书世界观、人物动机和主线推进生成大纲。",
+          kind: ["planning", "structure"],
+          stages: ["planning"],
+          modes: ["standard"],
+          source: "uploaded",
+          content: "必须读取项目世界观、人物动机和主线推进要求。",
+        }),
+      ],
+      taskRoute: { intent: "generate_outline", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)[0]).toBe("项目大纲约束")
+  })
+
   it("does not select skills outside novel routed tasks", async () => {
     const plugin = createSelectSkillsPlugin()
 

+ 107 - 53
src/lib/agent/plugins/select-skills-plugin.ts

@@ -26,6 +26,9 @@ const STANDARD_WRITING_SKILL_NAMES = [
   "冲突升级",
   "剧情自检",
   "正文输出协议",
+  "去AI味",
+  "基础去AI味",
+  "审稿返修",
 ]
 
 const STRICT_WRITING_SKILL_NAMES = [
@@ -38,6 +41,16 @@ const STRICT_WRITING_SKILL_NAMES = [
 
 const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "去AI味"]
 
+interface SkillSelectionProfile {
+  preferredNames?: string[]
+  kinds: SkillKind[]
+  stages: SkillStage[]
+  keywords: string[]
+  limit: number
+  fastHighImpactOnly?: boolean
+  requireKindOrKeyword?: boolean
+}
+
 export function createSelectSkillsPlugin(): PrePlugin {
   return {
     name: "select_skills",
@@ -53,7 +66,7 @@ export function createSelectSkillsPlugin(): PrePlugin {
 
       const mode = input.aiWorkflowMode ?? "standard"
       return {
-        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode),
+        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage),
       }
     },
   }
@@ -63,34 +76,38 @@ export function selectSkillsForRoute(
   skills: UserSkill[],
   intent: NovelTaskIntent,
   mode: AiWorkflowMode,
+  userMessage = "",
 ): UserSkill[] {
   const modeSkills = skills.filter((skill) => skill.modes.includes(mode))
   if (modeSkills.length === 0) return []
 
   if (WRITING_INTENTS.has(intent)) {
-    return selectWritingSkills(modeSkills, mode)
+    return selectWritingSkills(modeSkills, mode, userMessage)
   }
 
   if (intent === "generate_outline") {
-    return selectByShape(modeSkills, mode, {
+    return selectByProfile(modeSkills, mode, userMessage, {
       kinds: ["planning", "structure", "output"],
       stages: ["planning", "output"],
+      keywords: ["大纲", "主线", "世界观", "人物", "动机", "冲突", "伏笔", "章节", "计划"],
       limit: mode === "strict" ? 8 : 5,
     })
   }
 
   if (REVIEW_INTENTS.has(intent)) {
-    return selectByShape(modeSkills, mode, {
+    return selectByProfile(modeSkills, mode, userMessage, {
       kinds: ["review", "knowledge", "output"],
       stages: ["review", "output"],
+      keywords: ["审稿", "检查", "问题", "修改", "返修", "节奏", "逻辑", "人物", "伏笔", "去AI"],
       limit: mode === "strict" ? 8 : 5,
     })
   }
 
   if (QUERY_INTENTS.has(intent)) {
-    return selectByShape(modeSkills, mode, {
+    return selectByProfile(modeSkills, mode, userMessage, {
       kinds: ["knowledge", "review", "output"],
       stages: ["planning", "review", "output"],
+      keywords: ["查询", "检索", "资料", "世界观", "人物", "伏笔", "时间线", "设定"],
       limit: mode === "strict" ? 6 : 3,
     })
   }
@@ -98,74 +115,111 @@ export function selectSkillsForRoute(
   return []
 }
 
-function selectWritingSkills(skills: UserSkill[], mode: AiWorkflowMode): UserSkill[] {
+function selectWritingSkills(skills: UserSkill[], mode: AiWorkflowMode, userMessage: string): UserSkill[] {
   if (mode === "fast") {
-    return selectPreferredNames(skills, FAST_WRITING_SKILL_NAMES, 3, false)
+    return selectByProfile(skills, mode, userMessage, {
+      preferredNames: FAST_WRITING_SKILL_NAMES,
+      kinds: ["output", "style", "rewrite"],
+      stages: ["output", "rewrite"],
+      keywords: ["正文", "输出", "去AI", "AI味", "改写"],
+      limit: 3,
+      fastHighImpactOnly: true,
+      requireKindOrKeyword: true,
+    })
   }
   if (mode === "strict") {
-    return selectPreferredNames(skills, STRICT_WRITING_SKILL_NAMES, 12)
-  }
-  return selectPreferredNames(skills, STANDARD_WRITING_SKILL_NAMES, 8)
-}
-
-function selectPreferredNames(skills: UserSkill[], names: string[], limit: number, fillWithRelevant = true): UserSkill[] {
-  const selected: UserSkill[] = []
-  for (const name of names) {
-    const skill = skills.find((item) => item.name.includes(name))
-    if (skill && !selected.some((item) => item.id === skill.id)) {
-      selected.push(skill)
-    }
-  }
-
-  const fallback = skills
-    .filter((skill) => isWritingSkill(skill))
-
-  if (selected.length > 0) {
-    if (!fillWithRelevant) return selected.slice(0, limit)
-    for (const skill of fallback.filter((item) => item.source === "uploaded")) {
-      if (selected.length >= limit) break
-      if (!selected.some((item) => item.id === skill.id)) {
-        selected.push(skill)
-      }
-    }
-    return selected.slice(0, limit)
+    return selectByProfile(skills, mode, userMessage, {
+      preferredNames: STRICT_WRITING_SKILL_NAMES,
+      kinds: ["planning", "structure", "review", "output", "style", "rewrite"],
+      stages: ["planning", "drafting", "review", "rewrite", "output"],
+      keywords: ["章节", "正文", "剧情", "人物", "动机", "冲突", "伏笔", "节奏", "结尾", "钩子", "审稿", "返修", "去AI", "AI味", "输出"],
+      limit: 12,
+      requireKindOrKeyword: true,
+    })
   }
-
-  return fallback.slice(0, limit)
+  return selectByProfile(skills, mode, userMessage, {
+    preferredNames: STANDARD_WRITING_SKILL_NAMES,
+    kinds: ["planning", "structure", "review", "output", "style", "rewrite"],
+    stages: ["planning", "drafting", "review", "rewrite", "output"],
+    keywords: ["章节", "正文", "剧情", "人物", "动机", "冲突", "审稿", "返修", "去AI", "AI味", "输出", "承接", "计划"],
+    limit: 8,
+    requireKindOrKeyword: true,
+  })
 }
 
-function selectByShape(
+function selectByProfile(
   skills: UserSkill[],
   mode: AiWorkflowMode,
-  options: { kinds: SkillKind[]; stages: SkillStage[]; limit: number },
+  userMessage: string,
+  profile: SkillSelectionProfile,
 ): UserSkill[] {
   return skills
-    .filter((skill) =>
-      skill.kind.some((kind) => options.kinds.includes(kind))
-      || skill.stages.some((stage) => options.stages.includes(stage)),
-    )
-    .sort((a, b) => scoreSkill(b, mode, options) - scoreSkill(a, mode, options))
-    .slice(0, options.limit)
-}
-
-function isWritingSkill(skill: UserSkill): boolean {
-  return skill.kind.some((kind) => kind === "planning" || kind === "structure" || kind === "review" || kind === "output" || kind === "style")
-    || skill.stages.some((stage) => stage === "planning" || stage === "drafting" || stage === "review" || stage === "output" || stage === "rewrite")
+    .map((skill, index) => ({
+      skill,
+      index,
+      score: scoreSkill(skill, mode, userMessage, profile),
+    }))
+    .filter((item) => item.score > 0)
+    .filter((item) => !profile.fastHighImpactOnly || isFastHighImpactSkill(item.skill))
+    .sort((a, b) => b.score - a.score || a.index - b.index)
+    .slice(0, profile.limit)
+    .map((item) => item.skill)
 }
 
 function scoreSkill(
   skill: UserSkill,
   mode: AiWorkflowMode,
-  options: { kinds: SkillKind[]; stages: SkillStage[] },
+  userMessage: string,
+  profile: SkillSelectionProfile,
 ): number {
+  const preferredScore = scorePreferredName(skill, profile.preferredNames ?? [])
+  const kindMatches = skill.kind.filter((kind) => profile.kinds.includes(kind)).length
+  const stageMatches = skill.stages.filter((stage) => profile.stages.includes(stage)).length
+  const keywordHits = countKeywordHits(skill, [...profile.keywords, ...extractMessageKeywords(userMessage)])
+  const relevant = preferredScore > 0
+    || kindMatches > 0
+    || keywordHits > 0
+    || (!profile.requireKindOrKeyword && stageMatches > 0)
+  if (!relevant) return 0
+
   let score = 0
-  score += skill.kind.filter((kind) => options.kinds.includes(kind)).length * 3
-  score += skill.stages.filter((stage) => options.stages.includes(stage)).length * 2
-  if (skill.modes.includes(mode)) score += 1
-  if (skill.source === "built-in") score += 0.5
+  score += preferredScore
+  score += kindMatches * 30
+  score += stageMatches * 20
+  score += keywordHits * 6
+  if (skill.modes.includes(mode)) score += 5
+  if (skill.source === "uploaded" || skill.source === "project") score += 12
+  if (skill.source === "built-in") score += 2
   return score
 }
 
+function scorePreferredName(skill: UserSkill, preferredNames: string[]): number {
+  for (let index = 0; index < preferredNames.length; index += 1) {
+    const preferredName = preferredNames[index]
+    if (skill.name === preferredName) return 10000 - index * 100
+    if (skill.name.includes(preferredName)) return 9000 - index * 100
+  }
+  return 0
+}
+
+function countKeywordHits(skill: UserSkill, keywords: string[]): number {
+  const text = `${skill.name}\n${skill.description}\n${skill.content}`.toLocaleLowerCase()
+  const uniqueKeywords = [...new Set(keywords.map((keyword) => keyword.trim()).filter(Boolean))]
+  return uniqueKeywords.filter((keyword) => text.includes(keyword.toLocaleLowerCase())).length
+}
+
+function extractMessageKeywords(userMessage: string): string[] {
+  return userMessage
+    .split(/[\s,,。!?!?、::;;()()《》「」"']+/)
+    .map((keyword) => keyword.trim())
+    .filter((keyword) => keyword.length >= 2)
+}
+
+function isFastHighImpactSkill(skill: UserSkill): boolean {
+  return skill.kind.some((kind) => kind === "output" || kind === "style" || kind === "rewrite")
+    || skill.stages.some((stage) => stage === "output" || stage === "rewrite")
+}
+
 export function buildSelectedSkillsPrompt(skills: UserSkill[] | undefined): string {
   if (!skills || skills.length === 0) return ""
 

+ 30 - 0
src/lib/novel/skill-seed.ts

@@ -91,6 +91,36 @@ export const DEFAULT_BUILTIN_WRITING_SKILLS: UserSkill[] = [
     content: ["正文生成后逐一检查:","1. 剧情是否自然承接上一章","2. 主线是否推进","3. 人物动机是否合理","4. 本章冲突是否足够","5. 伏笔是否推进","6. 节奏是否合适","7. 是否符合 soul.md 风格"].join("\\n"),
     source: "built-in",
   }),
+  normalizeUserSkill({
+    id: "builtin:basic-de-ai",
+    name: "基础去AI味",
+    description: "降低正文里的解释腔、总结腔、模板句和协作口吻。",
+    kind: ["style", "rewrite"],
+    stages: ["rewrite", "output"],
+    modes: ["fast", "standard", "strict"],
+    content: ["输出前进行基础去AI味:","1. 删除“这说明、这意味着、由此可见”等替读者总结的句子","2. 减少段尾升华、机械排比和模板化转折","3. 对话不要替作者解释设定或心理","4. 保留原剧情、人设、视角和信息密度","5. 最终只输出正文,不说明已去AI味"].join("\\n"),
+    source: "built-in",
+  }),
+  normalizeUserSkill({
+    id: "builtin:review-revision",
+    name: "审稿返修",
+    description: "根据审稿发现的问题执行最小必要返修。",
+    kind: ["review", "rewrite"],
+    stages: ["review", "rewrite"],
+    modes: ["standard", "strict"],
+    content: ["返修时按以下顺序处理:","1. 先定位审稿指出的具体问题,不扩大到无关段落","2. 优先修复承接断裂、人物动机缺口、冲突不足和节奏塌陷","3. 保留已成立的剧情、设定、语气和伏笔","4. 每处修改都要服务审稿问题,不做额外润色","5. 返修后输出修正后的正文或修正片段"].join("\\n"),
+    source: "built-in",
+  }),
+  normalizeUserSkill({
+    id: "builtin:post-revision-review",
+    name: "返修后复审",
+    description: "返修完成后检查问题是否闭环,避免新增回退。",
+    kind: ["review", "output"],
+    stages: ["review", "output"],
+    modes: ["standard", "strict"],
+    content: ["返修后复审检查:","1. 审稿指出的问题是否已经修到文本里","2. 修复是否引入新的人设、设定、时间线或伏笔冲突","3. 返修段落与前后文语气是否一致","4. 是否仍有解释腔、模板句或过程说明残留","5. 若复审通过,最终只输出正文,不输出复审清单"].join("\\n"),
+    source: "built-in",
+  }),
   normalizeUserSkill({
     id: "builtin:output-protocol",
     name: "正文输出协议",

+ 53 - 0
src/lib/novel/user-skill-store.spec.ts

@@ -146,6 +146,59 @@ it("ensureBuiltinSkills adds missing built-in skills to empty config", () => {
   expect(names).toContain("结尾钩子")
   expect(names).toContain("剧情自检")
   expect(names).toContain("正文输出协议")
+  expect(names).toContain("基础去AI味")
+  expect(names).toContain("审稿返修")
+  expect(names).toContain("返修后复审")
+})
+
+it("keeps existing built-in IDs stable while adding new writing skills", () => {
+  const result = ensureBuiltinSkills(normalizeUserSkillConfig(null))
+  const builtinIds = result.skills.filter((s) => s.source === "built-in").map((s) => s.id)
+
+  expect(builtinIds).toEqual(expect.arrayContaining([
+    "builtin:chapter-connection",
+    "builtin:next-chapter-plan",
+    "builtin:mainline-check",
+    "builtin:character-motivation",
+    "builtin:conflict-escalation",
+    "builtin:foreshadowing-management",
+    "builtin:rhythm-check",
+    "builtin:ending-hook",
+    "builtin:plot-self-check",
+    "builtin:output-protocol",
+    "builtin:basic-de-ai",
+    "builtin:review-revision",
+    "builtin:post-revision-review",
+  ]))
+})
+
+it("ensureBuiltinSkills preserves uploaded skills when inserting new built-ins", () => {
+  const result = ensureBuiltinSkills(normalizeUserSkillConfig({
+    selectedSkillId: "skill:uploaded",
+    disabledSkillIds: [],
+    skills: [{
+      id: "skill:uploaded",
+      name: "项目专用节奏",
+      description: "只服务当前项目。",
+      kind: ["structure"],
+      stages: ["planning", "drafting"],
+      modes: ["standard", "strict"],
+      content: "保留项目自定义节奏规则。",
+      source: "uploaded",
+    }],
+  }))
+
+  expect(result.selectedSkillId).toBe("skill:uploaded")
+  expect(result.skills.find((s) => s.id === "skill:uploaded")).toMatchObject({
+    name: "项目专用节奏",
+    source: "uploaded",
+  })
+  expect(result.skills.map((s) => s.name)).toEqual(expect.arrayContaining([
+    "基础去AI味",
+    "审稿返修",
+    "返修后复审",
+    "项目专用节奏",
+  ]))
 })
 
 it("ensureBuiltinSkills does not add duplicates if built-in skills already exist", () => {