Преглед изворни кода

Merge branch 'wanshanxiezuoskill' into main

Mochocyang пре 2 месеци
родитељ
комит
08d2fee225

+ 2 - 6
src/components/chat/chat-panel.mount.spec.tsx

@@ -40,24 +40,20 @@ describe("ChatPanel mount 基础设施", () => {
     await view.unmount()
   })
 
-  it("输入工具栏显示快速、标准、严格三档模式,不再显示深度模式按钮", async () => {
+  it("输入工具栏显示模式下拉按钮,不再显示深度模式按钮", async () => {
     const view = await renderChatPanel({ activeConversation: true })
 
-    expect(view.container.textContent).toContain("快速")
     expect(view.container.textContent).toContain("标准")
-    expect(view.container.textContent).toContain("严格")
     expect(view.container.querySelector('[aria-label="开启深度模式"]')).toBeNull()
     expect(view.container.querySelector('[aria-label="关闭深度模式"]')).toBeNull()
 
     await view.unmount()
   })
 
-  it("输入工具栏单独显示计划执行开关,可与三档模式并列使用", async () => {
+  it("输入工具栏单独显示计划执行开关,可与模式下拉按钮并列使用", async () => {
     const view = await renderChatPanel({ activeConversation: true })
 
-    expect(view.container.textContent).toContain("快速")
     expect(view.container.textContent).toContain("标准")
-    expect(view.container.textContent).toContain("严格")
     expect(view.container.textContent).toContain("计划执行")
     expect(view.container.querySelector('[aria-label="开启计划执行模式"]')).not.toBeNull()
 

+ 271 - 21
src/components/chat/chat-panel.tsx

@@ -1,6 +1,7 @@
 import { useRef, useEffect, useCallback, useState, useMemo } from "react"
+import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks } from "lucide-react"
+import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, Sparkles, ChevronDown, Check } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -431,6 +432,19 @@ export function ChatPanel() {
     ? conversations.find((conversation) => conversation.id === activeConversationId) ?? null
     : null
 
+  const lastSelectedSkills = useMemo(() => {
+    const assistantMessages = activeMessages.filter(
+      (m) => m.role === "assistant" && m.contextTrace?.contextInfo?.selectedSkills,
+    )
+    if (assistantMessages.length === 0) return []
+    const lastMsg = assistantMessages[assistantMessages.length - 1]
+    return lastMsg.contextTrace?.contextInfo?.selectedSkills ?? []
+  }, [activeMessages])
+
+  const [showSkillsPanel, setShowSkillsPanel] = useState(false)
+  const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null)
+  const skillsPanelRef = useRef<HTMLDivElement | null>(null)
+
   // 当前活跃会话的流式内容
   const streamingContent = activeConversationId ? streamingContents[activeConversationId] ?? "" : ""
   // 当前活跃会话是否正在流式生成
@@ -461,6 +475,9 @@ export function ChatPanel() {
   const [deAiSkillWarningMessage, setDeAiSkillWarningMessage] = useState<string>("")
   const aiWorkflowMode = useWikiStore((s) => s.aiWorkflowMode)
   const setAiWorkflowMode = useWikiStore((s) => s.setAiWorkflowMode)
+  const [workflowModeDropdownOpen, setWorkflowModeDropdownOpen] = useState(false)
+  const workflowModeTriggerRef = useRef<HTMLButtonElement>(null)
+  const [workflowModeDropdownStyle, setWorkflowModeDropdownStyle] = useState<{ left: number; top: number; width: number } | null>(null)
   const planExecuteEnabled = useWikiStore((s) => s.planExecuteEnabled)
   const setPlanExecuteEnabled = useWikiStore((s) => s.setPlanExecuteEnabled)
   const [isSavingChapter, setIsSavingChapter] = useState(false)
@@ -514,6 +531,68 @@ export function ChatPanel() {
     })
   }, [consumePendingReferenceTokens, createConversation, pendingReferenceTokens])
 
+  useEffect(() => {
+    if (!showSkillsPanel) {
+      setExpandedSkillId(null)
+      return
+    }
+    const handleMouseDown = (event: MouseEvent) => {
+      if (!skillsPanelRef.current?.contains(event.target as Node)) {
+        setShowSkillsPanel(false)
+      }
+    }
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") setShowSkillsPanel(false)
+    }
+    document.addEventListener("mousedown", handleMouseDown)
+    document.addEventListener("keydown", handleKeyDown)
+    return () => {
+      document.removeEventListener("mousedown", handleMouseDown)
+      document.removeEventListener("keydown", handleKeyDown)
+    }
+  }, [showSkillsPanel])
+
+  useEffect(() => {
+    if (!workflowModeDropdownOpen) {
+      setWorkflowModeDropdownStyle(null)
+      return
+    }
+    const updatePosition = () => {
+      const rect = workflowModeTriggerRef.current?.getBoundingClientRect()
+      if (!rect) return
+      const width = Math.max(rect.width, 100)
+      const top = rect.bottom + 6
+      setWorkflowModeDropdownStyle({
+        left: Math.min(rect.left, window.innerWidth - width - 4),
+        top,
+        width,
+      })
+    }
+    const raf = requestAnimationFrame(updatePosition)
+    window.addEventListener("resize", updatePosition)
+    return () => {
+      cancelAnimationFrame(raf)
+      window.removeEventListener("resize", updatePosition)
+    }
+  }, [workflowModeDropdownOpen])
+
+  useEffect(() => {
+    if (!workflowModeDropdownOpen) return
+    const handleMouseDown = (event: MouseEvent) => {
+      if (workflowModeTriggerRef.current?.contains(event.target as Node)) return
+      setWorkflowModeDropdownOpen(false)
+    }
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") setWorkflowModeDropdownOpen(false)
+    }
+    document.addEventListener("mousedown", handleMouseDown)
+    document.addEventListener("keydown", handleKeyDown)
+    return () => {
+      document.removeEventListener("mousedown", handleMouseDown)
+      document.removeEventListener("keydown", handleKeyDown)
+    }
+  }, [workflowModeDropdownOpen])
+
   const agentSystemPrompt = useMemo(
     () =>
       buildChatAgentSystemPrompt({
@@ -878,6 +957,7 @@ export function ChatPanel() {
               ? [...existingReferences, ...agentReferences]
               : message.references
           })(),
+          contextTrace: contextTrace || message.contextTrace,
           isAgentRunning: false,
         }))
       }
@@ -890,6 +970,7 @@ export function ChatPanel() {
             ? `${message.content}\n\n出错:${error.message}`
             : `出错:${error.message}`,
           agentStages: settleRunningAgentStages(message.agentStages, "error"),
+          contextTrace: contextTrace || message.contextTrace,
           isAgentRunning: false,
         }))
       }
@@ -1440,28 +1521,197 @@ export function ChatPanel() {
                       if (convId) setConversationDeAiSkillId(convId, skillId)
                     }}
                   />
+                  {novelMode && (
+                    <div ref={skillsPanelRef} className="relative">
+                      <Tooltip>
+                        <TooltipTrigger
+                          render={(
+                            <button
+                              type="button"
+                              onClick={() => setShowSkillsPanel(!showSkillsPanel)}
+                              className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
+                              title="当前启用的 Skill"
+                              aria-label="当前启用的 Skill"
+                            >
+                              <Sparkles className="h-4 w-4" />
+                            </button>
+                          )}
+                        >
+                          <TooltipContent side="top" className="leading-5">
+                            {lastSelectedSkills.length > 0
+                              ? `上次生成启用了 ${lastSelectedSkills.length} 个 Skill`
+                              : "暂无启用的 Skill 记录"}
+                          </TooltipContent>
+                        </TooltipTrigger>
+                      </Tooltip>
+                      {showSkillsPanel && (
+                        <div className="fixed left-0 z-50 w-72 rounded-md border bg-popover p-3 text-sm text-popover-foreground shadow-lg"
+                          style={{
+                            left: skillsPanelRef.current?.getBoundingClientRect().left ?? 8,
+                            top: (skillsPanelRef.current?.getBoundingClientRect().bottom ?? 0) + 8,
+                          }}
+                        >
+                          <div className="mb-2 flex items-center gap-2">
+                            <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-600 dark:bg-violet-950/40 dark:text-violet-400">
+                              <Sparkles className="h-3.5 w-3.5" />
+                            </div>
+                            <div className="text-sm font-medium">本次启用的 Skill</div>
+                            {lastSelectedSkills.length > 0 && (
+                              <span className="rounded-full bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
+                                {lastSelectedSkills.length}
+                              </span>
+                            )}
+                          </div>
+                          {lastSelectedSkills.length === 0 ? (
+                            <div className="py-3 text-center text-xs text-muted-foreground">
+                              暂无启用记录
+                              <div className="mt-1 text-[11px] opacity-70">
+                                发送消息后将显示本次启用的 Skill
+                              </div>
+                            </div>
+                          ) : (
+                            <div className="max-h-96 space-y-1.5 overflow-y-auto">
+                              {lastSelectedSkills.map((skill) => {
+                                const isExpanded = expandedSkillId === skill.id
+                                return (
+                                  <div
+                                    key={skill.id}
+                                    className={`rounded-md border transition-colors ${
+                                      isExpanded
+                                        ? "border-violet-200 bg-violet-50/50 dark:border-violet-800/50 dark:bg-violet-950/20"
+                                        : "bg-background"
+                                    }`}
+                                  >
+                                    <button
+                                      type="button"
+                                      className="flex w-full items-start gap-2 px-2 py-1.5 text-left"
+                                      onClick={() =>
+                                        setExpandedSkillId(isExpanded ? null : skill.id)
+                                      }
+                                    >
+                                      <ChevronDown
+                                        className={`mt-0.5 h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-200 ${
+                                          isExpanded ? "rotate-180" : ""
+                                        }`}
+                                      />
+                                      <div className="flex-1 min-w-0">
+                                        <div className="mb-1 text-xs font-medium text-foreground">
+                                          {skill.name}
+                                        </div>
+                                        <div className="flex flex-wrap gap-1">
+                                          {[...skill.kind, ...skill.stages, skill.source]
+                                            .filter(Boolean)
+                                            .map((tag, index) => (
+                                              <span
+                                                key={`${skill.id}-${tag}-${index}`}
+                                                className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                                              >
+                                                {tag}
+                                              </span>
+                                            ))}
+                                        </div>
+                                      </div>
+                                    </button>
+                                    <div
+                                      className="grid overflow-hidden transition-all duration-200 ease-in-out"
+                                      style={{
+                                        gridTemplateRows: isExpanded ? "1fr" : "0fr",
+                                      }}
+                                    >
+                                      <div className="min-h-0 overflow-hidden">
+                                        <div className="border-t border-border/50 px-2 py-2">
+                                          {skill.description && (
+                                            <div className="mb-2">
+                                              <div className="mb-1 text-[11px] font-medium text-muted-foreground">
+                                                描述
+                                              </div>
+                                              <div className="text-xs leading-relaxed text-foreground/80">
+                                                {skill.description}
+                                              </div>
+                                            </div>
+                                          )}
+                                          {skill.content && (
+                                            <div>
+                                              <div className="mb-1 text-[11px] font-medium text-muted-foreground">
+                                                正文
+                                              </div>
+                                              <div className="max-h-[200px] overflow-y-auto rounded-md bg-background/60 p-2 text-[11px] leading-relaxed text-foreground/70 whitespace-pre-wrap">
+                                                {skill.content}
+                                              </div>
+                                            </div>
+                                          )}
+                                        </div>
+                                      </div>
+                                    </div>
+                                  </div>
+                                )
+                              })}
+                            </div>
+                          )}
+                        </div>
+                      )}
+                    </div>
+                  )}
                   {novelMode && (
                     <>
-                      <div
-                        className="flex h-8 shrink-0 items-center rounded-full border bg-background p-0.5"
-                        role="group"
-                        aria-label={aiSessionWorkflowModeLabel}
-                      >
-                        {aiWorkflowModeOptions.map(({ mode, label }) => (
-                          <Button
-                            key={mode}
-                            type="button"
-                            variant="ghost"
-                            size="sm"
-                            aria-pressed={aiWorkflowMode === mode}
-                            className={`h-7 min-w-10 rounded-full px-2 text-xs ${getWorkflowModeButtonClass(aiWorkflowMode === mode)}`}
-                            onClick={() => setAiWorkflowMode(mode)}
-                            title={`切换到${label}模式`}
-                            aria-label={`切换到${label}模式`}
-                          >
-                            {label}
-                          </Button>
-                        ))}
+                      <div className="relative">
+                        <Button
+                          ref={workflowModeTriggerRef}
+                          type="button"
+                          variant="outline"
+                          size="sm"
+                          aria-haspopup="listbox"
+                          aria-expanded={workflowModeDropdownOpen}
+                          aria-label={aiSessionWorkflowModeLabel}
+                          className="h-8 shrink-0 rounded-full border px-2.5 text-xs"
+                          onClick={() => setWorkflowModeDropdownOpen(!workflowModeDropdownOpen)}
+                        >
+                          <span className="mr-1">
+                            {aiWorkflowModeOptions.find((o) => o.mode === aiWorkflowMode)?.label ?? "标准"}
+                          </span>
+                          <ChevronDown className={`h-3.5 w-3.5 opacity-50 transition-transform ${workflowModeDropdownOpen ? "rotate-180" : ""}`} />
+                        </Button>
+                        {workflowModeDropdownOpen && workflowModeDropdownStyle && createPortal(
+                          <>
+                            <div
+                              className="fixed inset-0"
+                              style={{ zIndex: 9998 }}
+                              onClick={() => setWorkflowModeDropdownOpen(false)}
+                            />
+                            <div
+                              role="listbox"
+                              className="fixed rounded-md border bg-popover p-1 shadow-md"
+                              style={{
+                                left: workflowModeDropdownStyle.left,
+                                top: workflowModeDropdownStyle.top,
+                                width: workflowModeDropdownStyle.width,
+                                zIndex: 9999,
+                              }}
+                            >
+                              {aiWorkflowModeOptions.map(({ mode, label }) => (
+                                <button
+                                  key={mode}
+                                  type="button"
+                                  role="option"
+                                  aria-selected={aiWorkflowMode === mode}
+                                  className="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm hover:bg-accent"
+                                  onClick={() => {
+                                    setAiWorkflowMode(mode)
+                                    setWorkflowModeDropdownOpen(false)
+                                  }}
+                                >
+                                  <Check
+                                    className={`h-4 w-4 shrink-0 ${
+                                      aiWorkflowMode === mode ? "opacity-100" : "opacity-0"
+                                    }`}
+                                  />
+                                  <span className="flex-1">{label}</span>
+                                </button>
+                              ))}
+                            </div>
+                          </>,
+                          document.body,
+                        )}
                       </div>
                       <Tooltip>
                         <TooltipTrigger

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

@@ -59,9 +59,11 @@ describe("ContextTracePanel selected skills", () => {
           {
             id: "three-four",
             name: "三翻四抖",
+            description: "",
             kind: ["structure", "planning"],
             stages: ["planning", "drafting"],
             modes: ["standard", "strict"],
+            content: "",
             source: "project",
           },
         ],

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

@@ -1,159 +1,34 @@
-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 { SkillLibraryView } from "./skill-library-view"
-import {
-  buildUnifiedSkillEntries,
-  filterUnifiedSkillEntries,
-  type UnifiedSkillEntry,
-  type UnifiedSkillFilter,
-} from "./unified-skill-model"
-import { WritingSkillLibraryView } from "./writing-skill-library-view"
+import { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view"
+import { WritingSkillLibrarySidebarPanel, WritingSkillLibraryView } from "./writing-skill-library-view"
 
-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: "知识" } },
+const skillLibraryTabs = [
+  { view: "skillLibrary" as const, label: "去AI味技能" },
+  { view: "writingSkillLibrary" as const, label: "写作 Skill" },
 ]
 
-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 }) {
+function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
   const activeView = useWikiStore((s) => s.activeView)
-  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
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
 
   return (
-    <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"
-        }`}
+    <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"
+          }`}
         >
-          {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>
+          {tab.label}
+        </button>
+      ))}
     </div>
   )
 }
@@ -164,6 +39,7 @@ 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>
@@ -172,72 +48,13 @@ export function UnifiedSkillLibraryView() {
 }
 
 export function UnifiedSkillLibrarySidebarPanel() {
-  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])
+  const activeView = useWikiStore((s) => s.activeView)
+  const showWritingSkill = activeView === "writingSkillLibrary"
 
   return (
     <div data-testid="unified-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">统一管理写作 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 className="min-h-0 flex-1 overflow-hidden">
+        {showWritingSkill ? <WritingSkillLibrarySidebarPanel /> : <SkillLibrarySidebarPanel />}
       </div>
     </div>
   )

+ 737 - 44
src/components/skill-library/writing-skill-library-view.tsx

@@ -1,11 +1,26 @@
 import { useEffect, useMemo, useState } from "react"
+import { open, save } from "@tauri-apps/plugin-dialog"
+import { readFile, writeFile } from "@/commands/fs"
 import {
   createBlankWritingSkill,
+  createSkillCategory,
   deleteWritingSkill,
+  deleteSkillCategory,
+  exportSkillToJson,
+  importLinkedSkill,
+  importSkillFromJson,
+  importWritingSkill,
+  loadAllLinkedSkillsContent,
+  loadLinkedSkillContent,
   loadUserSkillConfig,
+  moveSkillToCategory,
+  normalizeUserSkillConfig,
+  renameSkillCategory,
+  reorderSkillCategories,
   resolveEnabledWritingSkills,
   saveUserSkillConfig,
   setWritingSkillEnabled,
+  touchSkillUsage,
   updateWritingSkill,
   WRITING_SKILL_KIND_OPTIONS,
   WRITING_SKILL_MODE_OPTIONS,
@@ -16,12 +31,28 @@ import {
   SKILL_KIND_LABELS,
   SKILL_MODE_LABELS,
   SKILL_STAGE_LABELS,
+  type SkillCategory,
   type SkillKind,
   type SkillMode,
   type SkillStage,
   type UserSkill,
 } from "@/lib/novel/skill-library"
 import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
+import { GripVertical, Pencil, Trash2 } from "lucide-react"
+import {
+  DndContext,
+  closestCenter,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from "@dnd-kit/core"
+import {
+  SortableContext,
+  useSortable,
+  verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
 
 function resolveInitialSkillId(config: UserSkillConfig, requested: string | null): string | null {
   if (requested && config.skills.some((skill) => skill.id === requested)) return requested
@@ -36,6 +67,9 @@ function hasDraftChanged(
   kind: SkillKind[],
   stages: SkillStage[],
   modes: SkillMode[],
+  priority: number,
+  tags: string[],
+  categoryId: string,
 ): boolean {
   return name.trim() !== skill.name
     || description.trim() !== skill.description
@@ -43,6 +77,9 @@ function hasDraftChanged(
     || kind.join("|") !== skill.kind.join("|")
     || stages.join("|") !== skill.stages.join("|")
     || modes.join("|") !== skill.modes.join("|")
+    || priority !== skill.priority
+    || tags.join("|") !== skill.tags.join("|")
+    || categoryId !== skill.categoryId
 }
 
 function toggleValue<T extends string>(values: T[], value: T): T[] {
@@ -89,9 +126,18 @@ function useWritingSkillConfig() {
     setConfig(null)
     setLoadError("")
     loadUserSkillConfig(projectPath)
-      .then((loaded) => {
+      .then(async (loaded) => {
         if (cancelled) return
-        setConfig(loaded)
+        try {
+          const withLinkedContent = await loadAllLinkedSkillsContent(loaded)
+          if (!cancelled) {
+            setConfig(withLinkedContent)
+          }
+        } catch {
+          if (!cancelled) {
+            setConfig(loaded)
+          }
+        }
         setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
       })
       .catch(() => {
@@ -107,6 +153,125 @@ function useWritingSkillConfig() {
   return { config, setConfig, loadError }
 }
 
+interface SortableCategoryItemProps {
+  category: SkillCategory
+  count: number
+  isSelected: boolean
+  isEditing: boolean
+  isHovered: boolean
+  editingCategoryName: string
+  onSelect: () => void
+  onStartEdit: () => void
+  onRename: () => void
+  onCancelEdit: () => void
+  onEditingNameChange: (name: string) => void
+  onDelete: () => void
+  onMouseEnter: () => void
+  onMouseLeave: () => void
+}
+
+function SortableCategoryItem({
+  category,
+  count,
+  isSelected,
+  isEditing,
+  isHovered,
+  editingCategoryName,
+  onSelect,
+  onStartEdit,
+  onRename,
+  onCancelEdit,
+  onEditingNameChange,
+  onDelete,
+  onMouseEnter,
+  onMouseLeave,
+}: SortableCategoryItemProps) {
+  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: category.id })
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      className={`group relative ${isDragging ? "z-10 opacity-80 shadow-md" : ""}`}
+    >
+      {isEditing ? (
+        <div className="flex items-center gap-1 rounded-md px-2 py-1">
+          <input
+            autoFocus
+            type="text"
+            value={editingCategoryName}
+            onChange={(e) => onEditingNameChange(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter") {
+                e.preventDefault()
+                onRename()
+              } else if (e.key === "Escape") {
+                onCancelEdit()
+              }
+            }}
+            onBlur={onRename}
+            className="flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-ring"
+          />
+        </div>
+      ) : (
+        <div
+          role="button"
+          tabIndex={0}
+          onClick={onSelect}
+          onKeyDown={(e) => {
+            if (e.key === "Enter" || e.key === " ") {
+              e.preventDefault()
+              onSelect()
+            }
+          }}
+          className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
+            isSelected ? "bg-accent/60" : ""
+          }`}
+        >
+          <button
+            type="button"
+            {...attributes}
+            {...listeners}
+            className="cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing"
+            onClick={(e) => e.stopPropagation()}
+            title="拖拽排序"
+          >
+            <GripVertical className="h-4 w-4" />
+          </button>
+          <span className="flex-1 truncate">{category.name}</span>
+          <span className="text-xs text-muted-foreground">{count}</span>
+          {(isHovered || isSelected) && (
+            <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+              <button
+                type="button"
+                onClick={onStartEdit}
+                className="rounded p-0.5 text-muted-foreground hover:text-foreground"
+                title="重命名"
+              >
+                <Pencil className="h-3.5 w-3.5" />
+              </button>
+              <button
+                type="button"
+                onClick={onDelete}
+                className="rounded p-0.5 text-muted-foreground hover:text-destructive"
+                title="删除"
+              >
+                <Trash2 className="h-3.5 w-3.5" />
+              </button>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
 export function WritingSkillLibrarySidebarPanel() {
   const project = useWikiStore((s) => s.project)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
@@ -117,9 +282,52 @@ export function WritingSkillLibrarySidebarPanel() {
   const { config, setConfig, loadError } = useWritingSkillConfig()
   const [message, setMessage] = useState("")
   const [saving, setSaving] = useState(false)
+  const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all")
+  const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
+  const [newCategoryName, setNewCategoryName] = useState("")
+  const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
+  const [editingCategoryName, setEditingCategoryName] = useState("")
+  const [hoveredCategoryId, setHoveredCategoryId] = useState<string | null>(null)
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 5 },
+    }),
+  )
+
+  const categoryIds = useMemo(
+    () => config?.categories.map((cat) => cat.id) ?? [],
+    [config?.categories],
+  )
 
   const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
 
+  const recentSkills = useMemo(() => {
+    if (!config) return []
+    return config.skills
+      .filter((s) => s.source !== "built-in")
+      .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
+      .slice(0, 5)
+  }, [config])
+
+  const filteredSkills = useMemo(() => {
+    if (!config) return []
+    if (selectedCategoryId === "all") return config.skills
+    if (selectedCategoryId === "uncategorized") return config.skills.filter((s) => !s.categoryId)
+    if (selectedCategoryId === "recent") return recentSkills
+    return config.skills.filter((s) => s.categoryId === selectedCategoryId)
+  }, [config, selectedCategoryId, recentSkills])
+
+  async function handleDragEnd(event: DragEndEvent) {
+    const { active, over } = event
+    if (!over || active.id === over.id || !config || !project || saving) return
+    const oldIndex = config.categories.findIndex((cat) => cat.id === active.id)
+    const newIndex = config.categories.findIndex((cat) => cat.id === over.id)
+    if (oldIndex === -1 || newIndex === -1) return
+    const next = reorderSkillCategories(config, oldIndex, newIndex)
+    await persist(next, selectedSkillId)
+  }
+
   async function persist(nextConfig: UserSkillConfig, nextSelectedSkillId: string | null) {
     if (!project) {
       setMessage("请先打开项目")
@@ -139,12 +347,104 @@ export function WritingSkillLibrarySidebarPanel() {
     }
   }
 
+  async function handleSelectSkill(skillId: string) {
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const next = touchSkillUsage(config, skillId)
+    await persist(next, skillId)
+  }
+
   async function handleCreateSkill() {
     if (!config || !project || saving) return
     if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
     if (draftDirty) setDraftDirty(false)
     const next = createBlankWritingSkill(config)
-    await persist(next, next.selectedSkillId)
+    if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
+      const withCategory = moveSkillToCategory(next, next.selectedSkillId!, selectedCategoryId)
+      await persist(withCategory, withCategory.selectedSkillId)
+    } else {
+      await persist(next, next.selectedSkillId)
+    }
+  }
+
+  async function handleImportSkill() {
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    try {
+      const selected = await open({
+        multiple: false,
+        filters: [
+          {
+            name: "Skill 文件",
+            extensions: ["json", "md", "txt"],
+          },
+        ],
+      })
+      if (!selected || typeof selected !== "string") return
+      const content = await readFile(selected)
+      const fileName = selected.split(/[\\/]/).pop() || "未命名 Skill"
+      const isJson = /\.json$/i.test(fileName)
+      let next: UserSkillConfig
+      if (isJson) {
+        const imported = importSkillFromJson(content)
+        if (!imported) {
+          setMessage("JSON 文件格式不正确,导入失败")
+          return
+        }
+        next = normalizeUserSkillConfig({
+          ...config,
+          selectedSkillId: imported.id,
+          skills: [imported, ...config.skills],
+        })
+      } else {
+        const nameWithoutExt = fileName.replace(/\.(md|txt)$/i, "")
+        next = importWritingSkill(config, { name: nameWithoutExt, content })
+      }
+      if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
+        next = moveSkillToCategory(next, next.selectedSkillId!, selectedCategoryId)
+      }
+      await persist(next, next.selectedSkillId)
+    } catch {
+      setMessage("导入 Skill 失败")
+    }
+  }
+
+  async function handleImportFolder() {
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    try {
+      const selected = await open({
+        multiple: false,
+        directory: true,
+      })
+      if (!selected || typeof selected !== "string") return
+      let next = await importLinkedSkill(config, selected)
+      const newSkillId = next.selectedSkillId
+      if (!newSkillId) {
+        setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
+        return
+      }
+      if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
+        next = moveSkillToCategory(next, newSkillId, selectedCategoryId)
+      }
+      const newSkill = next.skills.find((s) => s.id === newSkillId)
+      if (newSkill && newSkill.source === "linked") {
+        try {
+          const content = await loadLinkedSkillContent(newSkill)
+          const updatedSkills = next.skills.map((s) =>
+            s.id === newSkillId ? { ...s, content } : s
+          )
+          next = { ...next, skills: updatedSkills }
+        } catch {
+        }
+      }
+      await persist(next, newSkillId)
+    } catch {
+      setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
+    }
   }
 
   async function handleToggleSkill(skill: UserSkill, enabled: boolean) {
@@ -155,6 +455,48 @@ export function WritingSkillLibrarySidebarPanel() {
     await persist(next, selectedSkillId ?? next.selectedSkillId)
   }
 
+  async function handleCreateCategory() {
+    if (!config || !project || saving) return
+    const trimmed = newCategoryName.trim()
+    if (!trimmed) {
+      setShowNewCategoryInput(false)
+      return
+    }
+    const next = createSkillCategory(config, trimmed)
+    setShowNewCategoryInput(false)
+    setNewCategoryName("")
+    await persist(next, selectedSkillId)
+  }
+
+  async function handleRenameCategory(categoryId: string) {
+    if (!config || !project || saving) return
+    const trimmed = editingCategoryName.trim()
+    if (!trimmed) {
+      setEditingCategoryId(null)
+      return
+    }
+    const next = renameSkillCategory(config, categoryId, trimmed)
+    setEditingCategoryId(null)
+    setEditingCategoryName("")
+    await persist(next, selectedSkillId)
+  }
+
+  async function handleDeleteCategory(categoryId: string, categoryName: string) {
+    if (!config || !project || saving) return
+    const confirmed = window.confirm(`确定删除分类「${categoryName}」吗?删除分类后,分类下的Skill将变为未分类。`)
+    if (!confirmed) return
+    const next = deleteSkillCategory(config, categoryId)
+    if (selectedCategoryId === categoryId) {
+      setSelectedCategoryId("all")
+    }
+    await persist(next, selectedSkillId)
+  }
+
+  function startEditCategory(category: SkillCategory) {
+    setEditingCategoryId(category.id)
+    setEditingCategoryName(category.name)
+  }
+
   return (
     <div data-testid="writing-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
       <div className="shrink-0 border-b px-3 py-2">
@@ -162,38 +504,182 @@ export function WritingSkillLibrarySidebarPanel() {
         <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">写作 Skill</div>
-        <button
-          type="button"
-          onClick={() => void handleCreateSkill()}
-          disabled={!config || !project || saving}
-          className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+        <div className="text-sm font-medium">分类</div>
+      </div>
+      <div className="shrink-0 border-b px-2 py-1">
+        <div
+          role="button"
+          tabIndex={0}
+          onClick={() => setSelectedCategoryId("all")}
+          onKeyDown={(e) => {
+            if (e.key === "Enter" || e.key === " ") {
+              e.preventDefault()
+              setSelectedCategoryId("all")
+            }
+          }}
+          className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
+            selectedCategoryId === "all" ? "bg-accent/60" : ""
+          }`}
+        >
+          <span className="flex-1 truncate">全部</span>
+          <span className="text-xs text-muted-foreground">{config?.skills.length ?? 0}</span>
+        </div>
+        {recentSkills.length > 0 ? (
+          <div
+            role="button"
+            tabIndex={0}
+            onClick={() => setSelectedCategoryId("recent")}
+            onKeyDown={(e) => {
+              if (e.key === "Enter" || e.key === " ") {
+                e.preventDefault()
+                setSelectedCategoryId("recent")
+              }
+            }}
+            className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
+              selectedCategoryId === "recent" ? "bg-accent/60" : ""
+            }`}
+          >
+            <span className="flex-1 truncate">最近使用</span>
+            <span className="text-xs text-muted-foreground">{recentSkills.length}</span>
+          </div>
+        ) : null}
+        <div
+          role="button"
+          tabIndex={0}
+          onClick={() => setSelectedCategoryId("uncategorized")}
+          onKeyDown={(e) => {
+            if (e.key === "Enter" || e.key === " ") {
+              e.preventDefault()
+              setSelectedCategoryId("uncategorized")
+            }
+          }}
+          className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
+            selectedCategoryId === "uncategorized" ? "bg-accent/60" : ""
+          }`}
+        >
+          <span className="flex-1 truncate">未分类</span>
+          <span className="text-xs text-muted-foreground">
+            {config?.skills.filter((s) => !s.categoryId).length ?? 0}
+          </span>
+        </div>
+        <DndContext
+          sensors={sensors}
+          collisionDetection={closestCenter}
+          onDragEnd={handleDragEnd}
         >
-          新建 Skill
-        </button>
+          <SortableContext items={categoryIds} strategy={verticalListSortingStrategy}>
+            {config?.categories.map((category) => {
+              const count = config.skills.filter((s) => s.categoryId === category.id).length
+              const isEditing = editingCategoryId === category.id
+              const isHovered = hoveredCategoryId === category.id
+              return (
+                <SortableCategoryItem
+                  key={category.id}
+                  category={category}
+                  count={count}
+                  isSelected={selectedCategoryId === category.id}
+                  isEditing={isEditing}
+                  isHovered={isHovered}
+                  editingCategoryName={editingCategoryName}
+                  onSelect={() => setSelectedCategoryId(category.id)}
+                  onStartEdit={() => startEditCategory(category)}
+                  onRename={() => void handleRenameCategory(category.id)}
+                  onCancelEdit={() => setEditingCategoryId(null)}
+                  onEditingNameChange={(name) => setEditingCategoryName(name)}
+                  onDelete={() => void handleDeleteCategory(category.id, category.name)}
+                  onMouseEnter={() => setHoveredCategoryId(category.id)}
+                  onMouseLeave={() => setHoveredCategoryId(null)}
+                />
+              )
+            })}
+          </SortableContext>
+        </DndContext>
+        {showNewCategoryInput ? (
+          <div className="mt-1 flex items-center gap-1 rounded-md px-2 py-1">
+            <input
+              autoFocus
+              type="text"
+              value={newCategoryName}
+              onChange={(e) => setNewCategoryName(e.target.value)}
+              onKeyDown={(e) => {
+                if (e.key === "Enter") {
+                  e.preventDefault()
+                  void handleCreateCategory()
+                } else if (e.key === "Escape") {
+                  setShowNewCategoryInput(false)
+                  setNewCategoryName("")
+                }
+              }}
+              onBlur={() => void handleCreateCategory()}
+              placeholder="输入分类名称"
+              className="flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-ring"
+            />
+          </div>
+        ) : (
+          <button
+            type="button"
+            onClick={() => {
+              setShowNewCategoryInput(true)
+              setNewCategoryName("")
+            }}
+            className="mt-1 w-full rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+          >
+            + 新建分类
+          </button>
+        )}
+      </div>
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-medium">写作 Skill</div>
+        <div className="flex gap-2">
+          <button
+            type="button"
+            onClick={() => void handleImportSkill()}
+            disabled={!config || !project || saving}
+            className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+          >
+            导入文件
+          </button>
+          <button
+            type="button"
+            onClick={() => void handleImportFolder()}
+            disabled={!config || !project || saving}
+            className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+          >
+            导入文件夹
+          </button>
+          <button
+            type="button"
+            onClick={() => void handleCreateSkill()}
+            disabled={!config || !project || saving}
+            className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+          >
+            新建 Skill
+          </button>
+        </div>
       </div>
       {loadError || message ? (
         <div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError || message}</div>
       ) : null}
       <div className="min-h-0 flex-1 overflow-y-auto p-2">
-        {config && config.skills.length === 0 ? (
+        {config && filteredSkills.length === 0 ? (
           <div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
             还没有写作 Skill。可以新建“三翻四抖”“章节计划”“伏笔检查”等规则。
           </div>
         ) : null}
-        {config?.skills.map((skill) => {
+        {filteredSkills.map((skill) => {
           const active = skill.id === selectedSkillId
           const enabled = !disabledSkillIds.has(skill.id)
+          const isLinked = skill.source === "linked"
           return (
             <div
               key={skill.id}
               role="button"
               tabIndex={0}
-              onClick={() => setSelectedSkillId(skill.id)}
+              onClick={() => void handleSelectSkill(skill.id)}
               onKeyDown={(event) => {
                 if (event.key === "Enter" || event.key === " ") {
                   event.preventDefault()
-                  setSelectedSkillId(skill.id)
+                  void handleSelectSkill(skill.id)
                 }
               }}
               className={`mb-2 rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
@@ -203,6 +689,9 @@ export function WritingSkillLibrarySidebarPanel() {
               <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">写作</span>
+                {isLinked ? (
+                  <span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">引用</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">
@@ -238,6 +727,10 @@ export function WritingSkillLibraryView() {
   const [draftKind, setDraftKind] = useState<SkillKind[]>([])
   const [draftStages, setDraftStages] = useState<SkillStage[]>([])
   const [draftModes, setDraftModes] = useState<SkillMode[]>([])
+  const [draftPriority, setDraftPriority] = useState(50)
+  const [draftTags, setDraftTags] = useState<string[]>([])
+  const [draftCategoryId, setDraftCategoryId] = useState("")
+  const [tagInput, setTagInput] = useState("")
   const [message, setMessage] = useState("")
   const [saving, setSaving] = useState(false)
 
@@ -250,18 +743,32 @@ export function WritingSkillLibraryView() {
     selectedSkillId: null,
     disabledSkillIds: [],
     skills: [],
+    categories: [],
   }).map((skill) => skill.id))
   const selectedEnabled = selectedSkill ? enabledSkillIds.has(selectedSkill.id) : false
+  const isLinkedSkill = selectedSkill?.source === "linked"
   const draftChanged = Boolean(
-    selectedSkill && hasDraftChanged(
-      selectedSkill,
-      draftName,
-      draftDescription,
-      draftContent,
-      draftKind,
-      draftStages,
-      draftModes,
-    ),
+    selectedSkill && (isLinkedSkill
+      ? (draftName.trim() !== selectedSkill.name
+        || draftDescription.trim() !== selectedSkill.description
+        || draftKind.join("|") !== selectedSkill.kind.join("|")
+        || draftStages.join("|") !== selectedSkill.stages.join("|")
+        || draftModes.join("|") !== selectedSkill.modes.join("|")
+        || draftPriority !== selectedSkill.priority
+        || draftTags.join("|") !== selectedSkill.tags.join("|")
+        || draftCategoryId !== selectedSkill.categoryId)
+      : hasDraftChanged(
+        selectedSkill,
+        draftName,
+        draftDescription,
+        draftContent,
+        draftKind,
+        draftStages,
+        draftModes,
+        draftPriority,
+        draftTags,
+        draftCategoryId,
+      )),
   )
   const canSaveDraft = Boolean(project && config && selectedSkill && draftChanged && !saving)
 
@@ -273,6 +780,9 @@ export function WritingSkillLibraryView() {
       setDraftKind([])
       setDraftStages([])
       setDraftModes([])
+      setDraftPriority(50)
+      setDraftTags([])
+      setDraftCategoryId("")
       setDraftDirty(false)
       return
     }
@@ -282,6 +792,9 @@ export function WritingSkillLibraryView() {
     setDraftKind(selectedSkill.kind)
     setDraftStages(selectedSkill.stages)
     setDraftModes(selectedSkill.modes)
+    setDraftPriority(selectedSkill.priority)
+    setDraftTags(selectedSkill.tags)
+    setDraftCategoryId(selectedSkill.categoryId)
     setDraftDirty(false)
     setMessage("")
   }, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
@@ -293,9 +806,12 @@ export function WritingSkillLibraryView() {
     kind = draftKind,
     stages = draftStages,
     modes = draftModes,
+    priority = draftPriority,
+    tags = draftTags,
+    categoryId = draftCategoryId,
   ) {
     setDraftDirty(selectedSkill
-      ? hasDraftChanged(selectedSkill, name, description, content, kind, stages, modes)
+      ? hasDraftChanged(selectedSkill, name, description, content, kind, stages, modes, priority, tags, categoryId)
       : false)
   }
 
@@ -322,23 +838,31 @@ export function WritingSkillLibraryView() {
   async function handleSaveSkill() {
     if (!config || !selectedSkill || !canSaveDraft) return
     const name = draftName.trim()
-    const content = draftContent.trim()
     if (!name) {
       setMessage("Skill 名称不能为空")
       return
     }
-    if (!content) {
-      setMessage("规则正文不能为空")
-      return
+    if (!isLinkedSkill) {
+      const content = draftContent.trim()
+      if (!content) {
+        setMessage("规则正文不能为空")
+        return
+      }
     }
-    await persist(updateWritingSkill(config, selectedSkill.id, {
+    const patch: Partial<Pick<UserSkill, "name" | "description" | "kind" | "stages" | "modes" | "content" | "priority" | "tags" | "categoryId">> = {
       name,
       description: draftDescription.trim(),
-      content,
       kind: draftKind,
       stages: draftStages,
       modes: draftModes,
-    }))
+      priority: draftPriority,
+      tags: draftTags,
+      categoryId: draftCategoryId,
+    }
+    if (!isLinkedSkill) {
+      patch.content = draftContent.trim()
+    }
+    await persist(updateWritingSkill(config, selectedSkill.id, patch))
   }
 
   async function handleToggleEnabled(enabled: boolean) {
@@ -352,12 +876,75 @@ export function WritingSkillLibraryView() {
     if (!config || !selectedSkill || !project || saving) return
     if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
     if (draftDirty) setDraftDirty(false)
-    const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
+    const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?删除后无法恢复。`)
     if (!confirmed) return
     const next = deleteWritingSkill(config, selectedSkill.id)
     await persist(next, next.selectedSkillId)
   }
 
+  async function handleExportSkill() {
+    if (!selectedSkill) return
+    try {
+      const jsonStr = exportSkillToJson(selectedSkill)
+      const filePath = await save({
+        defaultPath: `${selectedSkill.name}.json`,
+        filters: [
+          {
+            name: "JSON 文件",
+            extensions: ["json"],
+          },
+        ],
+      })
+      if (!filePath) return
+      await writeFile(filePath, jsonStr)
+      setMessage("导出成功")
+    } catch {
+      setMessage("导出 Skill 失败")
+    }
+  }
+
+  async function handleReloadLinkedContent() {
+    if (!selectedSkill || selectedSkill.source !== "linked" || !config) return
+    try {
+      const content = await loadLinkedSkillContent(selectedSkill)
+      const updatedSkill = { ...selectedSkill, content }
+      const updatedSkills = config.skills.map((s) =>
+        s.id === selectedSkill.id ? updatedSkill : s
+      )
+      setConfig({ ...config, skills: updatedSkills })
+      setDraftContent(content)
+      setMessage("已重新读取内容")
+    } catch {
+      setMessage("读取内容失败")
+    }
+  }
+
+  function handleAddTag() {
+    const trimmed = tagInput.trim()
+    if (!trimmed) return
+    if (draftTags.includes(trimmed)) {
+      setTagInput("")
+      return
+    }
+    const next = [...draftTags, trimmed]
+    setDraftTags(next)
+    setTagInput("")
+    updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, next)
+  }
+
+  function handleRemoveTag(tag: string) {
+    const next = draftTags.filter((t) => t !== tag)
+    setDraftTags(next)
+    updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, next)
+  }
+
+  function handleTagKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
+    if (event.key === "Enter" || event.key === ",") {
+      event.preventDefault()
+      handleAddTag()
+    }
+  }
+
   return (
     <div data-testid="writing-skill-library-view" className="flex h-full flex-col overflow-hidden">
       <div className="shrink-0 border-b px-5 py-4">
@@ -377,9 +964,14 @@ export function WritingSkillLibraryView() {
         ) : (
           <div className="mx-auto flex max-w-5xl flex-col gap-4">
             <div className="flex flex-wrap items-center justify-between gap-2">
-              <div>
-                <div className="text-sm text-muted-foreground">项目写作 Skill</div>
-                <h2 className="text-xl font-semibold">{selectedSkill.name}</h2>
+              <div className="flex items-center gap-2">
+                <div>
+                  <div className="text-sm text-muted-foreground">项目写作 Skill</div>
+                  <h2 className="text-xl font-semibold">{selectedSkill.name}</h2>
+                </div>
+                {isLinkedSkill ? (
+                  <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">引用</span>
+                ) : null}
               </div>
               <div className="flex flex-wrap items-center gap-3">
                 <label className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -393,6 +985,13 @@ export function WritingSkillLibraryView() {
                   />
                   参与 AI 会话
                 </label>
+                <button
+                  type="button"
+                  onClick={() => void handleExportSkill()}
+                  className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:opacity-60"
+                >
+                  导出
+                </button>
                 <button
                   data-testid="writing-skill-delete-button"
                   type="button"
@@ -433,6 +1032,24 @@ export function WritingSkillLibraryView() {
               />
             </label>
 
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">分类</span>
+              <select
+                value={draftCategoryId}
+                onChange={(event) => {
+                  const next = event.target.value
+                  setDraftCategoryId(next)
+                  updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, draftTags, next)
+                }}
+                className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
+              >
+                <option value="">未分类</option>
+                {config?.categories.map((cat) => (
+                  <option key={cat.id} value={cat.id}>{cat.name}</option>
+                ))}
+              </select>
+            </label>
+
             <div className="grid gap-2 text-sm">
               <span className="font-medium">类型</span>
               <div className="flex flex-wrap gap-2">
@@ -491,19 +1108,95 @@ export function WritingSkillLibraryView() {
             </div>
 
             <label className="grid gap-1.5 text-sm">
-              <span className="font-medium">规则正文</span>
-              <textarea
-                data-testid="writing-skill-content-input"
-                value={draftContent}
+              <span className="font-medium">优先级(1-100,越小越优先)</span>
+              <input
+                type="number"
+                min={1}
+                max={100}
+                value={draftPriority}
                 onChange={(event) => {
-                  const next = event.target.value
-                  setDraftContent(next)
-                  updateDraftDirty(draftName, draftDescription, next)
+                  const value = parseInt(event.target.value, 10)
+                  const next = isNaN(value) ? 50 : Math.max(1, Math.min(100, value))
+                  setDraftPriority(next)
+                  updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, next)
                 }}
-                className="min-h-[420px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring"
+                className="w-32 rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
               />
             </label>
 
+            <div className="grid gap-2 text-sm">
+              <span className="font-medium">标签</span>
+              <div className="flex flex-wrap items-center gap-2">
+                {draftTags.map((tag) => (
+                  <span
+                    key={tag}
+                    className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs"
+                  >
+                    {tag}
+                    <button
+                      type="button"
+                      onClick={() => handleRemoveTag(tag)}
+                      className="text-muted-foreground hover:text-foreground"
+                    >
+                      ×
+                    </button>
+                  </span>
+                ))}
+                <input
+                  type="text"
+                  value={tagInput}
+                  onChange={(event) => setTagInput(event.target.value)}
+                  onKeyDown={handleTagKeyDown}
+                  onBlur={handleAddTag}
+                  placeholder="输入标签后按回车添加"
+                  className="flex-1 min-w-[150px] rounded-md border bg-background px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
+                />
+              </div>
+            </div>
+
+            {isLinkedSkill ? (
+              <div className="grid gap-1.5 text-sm">
+                <div className="flex items-center justify-between">
+                  <span className="font-medium">规则正文</span>
+                  <button
+                    type="button"
+                    onClick={() => void handleReloadLinkedContent()}
+                    className="rounded-md border px-2 py-1 text-xs hover:bg-accent"
+                  >
+                    重新读取
+                  </button>
+                </div>
+                <div className="rounded-md border bg-blue-50 px-3 py-2 text-xs text-blue-700">
+                  此 Skill 为外部引用,内容实时读取
+                </div>
+                <textarea
+                  readOnly
+                  value={draftContent}
+                  className="min-h-[420px] resize-y rounded-md border bg-muted px-3 py-2 font-mono text-xs leading-5 outline-none"
+                />
+                {selectedSkill.linkedPath ? (
+                  <div className="flex items-center gap-2 text-xs text-muted-foreground">
+                    <span>引用路径:</span>
+                    <code className="truncate rounded bg-muted px-1.5 py-0.5">{selectedSkill.linkedPath}</code>
+                  </div>
+                ) : null}
+              </div>
+            ) : (
+              <label className="grid gap-1.5 text-sm">
+                <span className="font-medium">规则正文</span>
+                <textarea
+                  data-testid="writing-skill-content-input"
+                  value={draftContent}
+                  onChange={(event) => {
+                    const next = event.target.value
+                    setDraftContent(next)
+                    updateDraftDirty(draftName, draftDescription, next)
+                  }}
+                  className="min-h-[420px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring"
+                />
+              </label>
+            )}
+
             <div className="flex items-center gap-3">
               <button
                 data-testid="writing-skill-save-button"

+ 7 - 0
src/hooks/use-agent-config.spec.ts

@@ -314,6 +314,7 @@ describe("useAgentConfig", () => {
         version: 1,
         selectedSkillId: "skill:three",
         disabledSkillIds: ["skill:hidden"],
+        categories: [],
         skills: [
           {
             id: "skill:three",
@@ -324,6 +325,9 @@ describe("useAgentConfig", () => {
             modes: ["standard", "strict"],
             content: "每章设置三次局势变化和四次信息冲击。",
             source: "uploaded",
+            priority: 50,
+            tags: [],
+            categoryId: "",
           },
           {
             id: "skill:hidden",
@@ -334,6 +338,9 @@ describe("useAgentConfig", () => {
             modes: ["strict"],
             content: "不要进入 AI 会话。",
             source: "uploaded",
+            priority: 50,
+            tags: [],
+            categoryId: "",
           },
         ],
       },

+ 2 - 1
src/hooks/use-agent-config.ts

@@ -3,7 +3,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
-import { loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
+import { loadAllLinkedSkillsContent, loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import { resolveModelConfig } from "@/lib/novel/model-resolver"
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
@@ -59,6 +59,7 @@ export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
     Promise.all([
       loadDeAiSkillConfig(projectPath).catch(() => null),
       loadUserSkillConfig(projectPath)
+        .then((config) => loadAllLinkedSkillsContent(config))
         .then(resolveEnabledWritingSkills)
         .catch(() => [] as UserSkill[]),
     ])

+ 1 - 1
src/lib/agent/capabilities/types.ts

@@ -11,7 +11,7 @@ export type CapabilityPermission = "auto" | "confirm"
 
 export type CapabilityIntent = NovelTaskIntent | "external_search" | "general"
 
-export type CapabilitySource = "built-in" | "project" | "uploaded" | "mcp"
+export type CapabilitySource = "built-in" | "project" | "uploaded" | "mcp" | "linked"
 
 export interface AiCapability {
   id: string

+ 3 - 2
src/lib/agent/context-trace-builders.spec.ts

@@ -59,7 +59,7 @@ describe("context trace builders", () => {
     expect(info.workflowMode).toBe("strict")
   })
 
-  it("carries selected skill metadata into initial trace context without skill content", () => {
+  it("carries selected skill metadata into initial trace context", () => {
     const info = buildInitialContextTraceInfo(
       {
         intent: "write_chapter",
@@ -86,13 +86,14 @@ describe("context trace builders", () => {
       {
         id: "three-four",
         name: "三翻四抖",
+        description: "结构技能",
         kind: ["structure", "planning"],
         stages: ["planning", "drafting"],
         modes: ["standard", "strict"],
+        content: "三次转折,四次震惊。",
         source: "project",
       },
     ])
-    expect(JSON.stringify(info.selectedSkills)).not.toContain("三次转折")
   })
   it("carries selected capability summaries into initial trace context without sensitive content", () => {
     const info = buildInitialContextTraceInfo(

+ 2 - 0
src/lib/agent/context-trace-builders.ts

@@ -21,9 +21,11 @@ export function buildInitialContextTraceInfo(
     selectedSkills: prePluginResult?.selectedSkills?.map((skill) => ({
       id: skill.id,
       name: skill.name,
+      description: skill.description,
       kind: skill.kind,
       stages: skill.stages,
       modes: skill.modes,
+      content: skill.content,
       source: skill.source,
     })),
     selectedCapabilities: prePluginResult?.selectedCapabilities?.map((capability) => ({

+ 3 - 1
src/lib/agent/context-trace.ts

@@ -34,10 +34,12 @@ export interface TraceContextBudget {
 export interface TraceSelectedSkill {
   id: string
   name: string
+  description: string
   kind: SkillKind[]
   stages: SkillStage[]
   modes: SkillMode[]
-  source: "built-in" | "project" | "uploaded"
+  content: string
+  source: "built-in" | "project" | "uploaded" | "linked"
 }
 
 export interface TraceWebSearch {

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

@@ -51,7 +51,7 @@ describe("SelectSkillsPlugin", () => {
       "冲突升级",
       "剧情自检",
       "正文输出协议",
-      "去AI味",
+      "世界观资料",
     ])
   })
 
@@ -74,6 +74,7 @@ describe("SelectSkillsPlugin", () => {
           modes: ["standard", "strict"],
           content: "三次转折,四次震惊。",
           source: "uploaded",
+          priority: 25,
         }),
       ],
       taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
@@ -91,20 +92,14 @@ describe("SelectSkillsPlugin", () => {
       agentConfig: {} as any,
       novelMode: true,
       aiWorkflowMode: "fast",
-      availableSkills: [
-        ...availableSkills,
-        skill({ id: "fast-structure", name: "快速结构扩写", kind: ["structure"], stages: ["drafting"], modes: ["fast"] }),
-        skill({ id: "fast-review", name: "快速审稿", kind: ["review"], stages: ["review"], modes: ["fast"] }),
-      ],
+      availableSkills,
       taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
     })
 
-    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)
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual([
+      "正文输出协议",
+      "去AI味",
+    ])
   })
 
   it("selects strict review and structure skills for key chapter writing", async () => {
@@ -127,51 +122,14 @@ describe("SelectSkillsPlugin", () => {
       "冲突升级",
       "剧情自检",
       "正文输出协议",
-      "去AI味",
       "主线检查",
       "伏笔管理",
       "节奏检查",
       "结尾钩子",
+      "世界观资料",
     ])
   })
 
-  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()
 

+ 57 - 106
src/lib/agent/plugins/select-skills-plugin.ts

@@ -26,9 +26,6 @@ const STANDARD_WRITING_SKILL_NAMES = [
   "冲突升级",
   "剧情自检",
   "正文输出协议",
-  "去AI味",
-  "基础去AI味",
-  "审稿返修",
 ]
 
 const STRICT_WRITING_SKILL_NAMES = [
@@ -41,15 +38,7 @@ 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
-}
+const EXCLUDED_FROM_FALLBACK = ["去AI味"]
 
 export function createSelectSkillsPlugin(): PrePlugin {
   return {
@@ -66,7 +55,7 @@ export function createSelectSkillsPlugin(): PrePlugin {
 
       const mode = input.aiWorkflowMode ?? "standard"
       return {
-        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage),
+        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode),
       }
     },
   }
@@ -76,38 +65,34 @@ 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, userMessage)
+    return selectWritingSkills(modeSkills, mode)
   }
 
   if (intent === "generate_outline") {
-    return selectByProfile(modeSkills, mode, userMessage, {
+    return selectByShape(modeSkills, mode, {
       kinds: ["planning", "structure", "output"],
       stages: ["planning", "output"],
-      keywords: ["大纲", "主线", "世界观", "人物", "动机", "冲突", "伏笔", "章节", "计划"],
       limit: mode === "strict" ? 8 : 5,
     })
   }
 
   if (REVIEW_INTENTS.has(intent)) {
-    return selectByProfile(modeSkills, mode, userMessage, {
+    return selectByShape(modeSkills, mode, {
       kinds: ["review", "knowledge", "output"],
       stages: ["review", "output"],
-      keywords: ["审稿", "检查", "问题", "修改", "返修", "节奏", "逻辑", "人物", "伏笔", "去AI"],
       limit: mode === "strict" ? 8 : 5,
     })
   }
 
   if (QUERY_INTENTS.has(intent)) {
-    return selectByProfile(modeSkills, mode, userMessage, {
+    return selectByShape(modeSkills, mode, {
       kinds: ["knowledge", "review", "output"],
       stages: ["planning", "review", "output"],
-      keywords: ["查询", "检索", "资料", "世界观", "人物", "伏笔", "时间线", "设定"],
       limit: mode === "strict" ? 6 : 3,
     })
   }
@@ -115,111 +100,77 @@ export function selectSkillsForRoute(
   return []
 }
 
-function selectWritingSkills(skills: UserSkill[], mode: AiWorkflowMode, userMessage: string): UserSkill[] {
+function selectWritingSkills(skills: UserSkill[], mode: AiWorkflowMode): UserSkill[] {
   if (mode === "fast") {
-    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,
-    })
+    return selectPreferredNames(skills, FAST_WRITING_SKILL_NAMES, 3, false)
   }
   if (mode === "strict") {
-    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 selectPreferredNames(skills, STRICT_WRITING_SKILL_NAMES, 12)
   }
-  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,
-  })
+  return selectPreferredNames(skills, STANDARD_WRITING_SKILL_NAMES, 8)
 }
 
-function selectByProfile(
+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))
+    .filter((skill) => !EXCLUDED_FROM_FALLBACK.some((name) => skill.name.includes(name)))
+    .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
+
+  if (selected.length > 0) {
+    if (!fillWithRelevant) return selected.slice(0, limit)
+    for (const skill of fallback) {
+      if (selected.length >= limit) break
+      if (!selected.some((item) => item.id === skill.id)) {
+        selected.push(skill)
+      }
+    }
+    return selected.slice(0, limit)
+  }
+
+  return fallback.slice(0, limit)
+}
+
+function selectByShape(
   skills: UserSkill[],
   mode: AiWorkflowMode,
-  userMessage: string,
-  profile: SkillSelectionProfile,
+  options: { kinds: SkillKind[]; stages: SkillStage[]; limit: number },
 ): UserSkill[] {
   return skills
-    .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)
+    .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")
 }
 
 function scoreSkill(
   skill: UserSkill,
   mode: AiWorkflowMode,
-  userMessage: string,
-  profile: SkillSelectionProfile,
+  options: { kinds: SkillKind[]; stages: SkillStage[] },
 ): 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 += 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
+  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 += (100 - (skill.priority ?? 50)) * 0.1
   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 ""
 

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

@@ -1,6 +1,6 @@
 import { readFile, writeFile, writeFileAtomic } from "@/commands/fs"
 import { join } from "@tauri-apps/api/path"
-import type { UserSkill } from "@/lib/novel/skill-library"
+import { DEFAULT_SKILL_PRIORITY, type UserSkill } from "@/lib/novel/skill-library"
 
 export type DeAiSkillSource = "built-in" | "project" | "legacy"
 
@@ -598,6 +598,9 @@ export function deAiSkillToUserSkill(skill: DeAiSkill): UserSkill {
     modes: ["fast", "standard", "strict"],
     content: skill.content,
     source: skill.source === "built-in" ? "built-in" : "project",
+    priority: DEFAULT_SKILL_PRIORITY,
+    tags: [],
+    categoryId: "",
     createdAt: skill.createdAt,
     updatedAt: skill.updatedAt,
   }

+ 29 - 2
src/lib/novel/skill-library.ts

@@ -18,6 +18,13 @@ export type SkillStage =
 
 export type SkillMode = AiWorkflowMode
 
+export interface SkillCategory {
+  id: string
+  name: string
+  createdAt?: number
+  updatedAt?: number
+}
+
 export interface UserSkill {
   id: string
   name: string
@@ -26,11 +33,17 @@ export interface UserSkill {
   stages: SkillStage[]
   modes: SkillMode[]
   content: string
-  source: "built-in" | "project" | "uploaded"
+  source: "built-in" | "project" | "uploaded" | "linked"
+  linkedPath?: string
+  priority: number
+  tags: string[]
+  categoryId: string
   createdAt?: number
   updatedAt?: number
 }
 
+export const DEFAULT_SKILL_PRIORITY = 50
+
 export interface SkillFilter {
   mode?: SkillMode
   stage?: SkillStage
@@ -93,7 +106,17 @@ function uniqueValid<T extends string>(values: unknown, valid: Set<T>, fallback:
 }
 
 export function normalizeUserSkill(value: Partial<UserSkill>): UserSkill {
-  const source = value.source === "built-in" || value.source === "uploaded" ? value.source : "project"
+  const source = value.source === "built-in" || value.source === "uploaded" || value.source === "linked" ? value.source : "project"
+  const rawPriority = typeof value.priority === "number" ? value.priority : DEFAULT_SKILL_PRIORITY
+  const priority = Math.max(1, Math.min(100, Math.round(rawPriority)))
+  const rawTags = Array.isArray(value.tags) ? value.tags : []
+  const tags: string[] = []
+  for (const tag of rawTags) {
+    if (typeof tag === "string") {
+      const trimmed = tag.trim()
+      if (trimmed && !tags.includes(trimmed)) tags.push(trimmed)
+    }
+  }
   return {
     id: typeof value.id === "string" && value.id.trim() ? value.id.trim() : `project:${Date.now()}`,
     name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : "未命名 Skill",
@@ -103,6 +126,10 @@ export function normalizeUserSkill(value: Partial<UserSkill>): UserSkill {
     modes: uniqueValid(value.modes, VALID_MODES, ["standard", "strict"]),
     content: typeof value.content === "string" ? value.content.trim() : "",
     source,
+    linkedPath: source === "linked" && typeof value.linkedPath === "string" ? value.linkedPath : undefined,
+    priority,
+    tags,
+    categoryId: typeof value.categoryId === "string" ? value.categoryId : "",
     createdAt: typeof value.createdAt === "number" ? value.createdAt : undefined,
     updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : undefined,
   }

Разлика између датотеке није приказан због своје велике величине
+ 1632 - 19
src/lib/novel/skill-seed.ts


+ 365 - 9
src/lib/novel/user-skill-store.ts

@@ -1,7 +1,8 @@
-import { readFile, writeFileAtomic } from "@/commands/fs"
-import { join } from "@tauri-apps/api/path"
+import { readFile, writeFileAtomic, listDirectory } from "@/commands/fs"
+import { join, basename, extname } from "@tauri-apps/api/path"
 import {
   normalizeUserSkill,
+  type SkillCategory,
   type SkillKind,
   type SkillMode,
   type SkillStage,
@@ -16,6 +17,7 @@ export interface UserSkillConfig {
   selectedSkillId: string | null
   disabledSkillIds: string[]
   skills: UserSkill[]
+  categories: SkillCategory[]
 }
 
 function uniqueStrings(values: unknown): string[] {
@@ -31,14 +33,31 @@ function uniqueStrings(values: unknown): string[] {
   return result
 }
 
+function normalizeSkillCategory(value: unknown): SkillCategory | null {
+  if (!value || typeof value !== "object") return null
+  const raw = value as Partial<SkillCategory>
+  if (typeof raw.id !== "string" || !raw.id.trim()) return null
+  if (typeof raw.name !== "string" || !raw.name.trim()) return null
+  return {
+    id: raw.id.trim(),
+    name: raw.name.trim(),
+    createdAt: typeof raw.createdAt === "number" ? raw.createdAt : undefined,
+    updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : undefined,
+  }
+}
+
 function normalizeWritingSkill(value: unknown): UserSkill | null {
   if (!value || typeof value !== "object") return null
   const raw = value as Partial<UserSkill>
   if (typeof raw.name !== "string" || !raw.name.trim()) return null
-  if (typeof raw.content !== "string" || !raw.content.trim()) return null
+  const isLinked = raw.source === "linked"
+  if (!isLinked) {
+    if (typeof raw.content !== "string" || !raw.content.trim()) return null
+  }
   return normalizeUserSkill({
     ...raw,
-    source: raw.source === "built-in" ? "built-in" : "uploaded",
+    source: raw.source === "built-in" ? "built-in" : raw.source === "linked" ? "linked" : "uploaded",
+    content: typeof raw.content === "string" ? raw.content : "",
   })
 }
 
@@ -50,15 +69,27 @@ export function normalizeUserSkillConfig(value: unknown): UserSkillConfig {
       .filter((skill): skill is UserSkill => Boolean(skill))
       .filter((skill, index, all) => all.findIndex((item) => item.id === skill.id) === index)
     : []
-  const skillIds = new Set(skills.map((skill) => skill.id))
+  const categories = Array.isArray(raw.categories)
+    ? raw.categories
+      .map(normalizeSkillCategory)
+      .filter((cat): cat is SkillCategory => Boolean(cat))
+      .filter((cat, index, all) => all.findIndex((item) => item.id === cat.id) === index)
+    : []
+  const categoryIds = new Set(categories.map((cat) => cat.id))
+  const normalizedSkills = skills.map((skill) => ({
+    ...skill,
+    categoryId: skill.categoryId && categoryIds.has(skill.categoryId) ? skill.categoryId : "",
+  }))
+  const skillIds = new Set(normalizedSkills.map((skill) => skill.id))
   const selectedSkillId = typeof raw.selectedSkillId === "string" && skillIds.has(raw.selectedSkillId)
     ? raw.selectedSkillId
-    : skills[0]?.id ?? null
+    : normalizedSkills[0]?.id ?? null
   return {
     version: 1,
     selectedSkillId,
     disabledSkillIds: uniqueStrings(raw.disabledSkillIds),
-    skills,
+    skills: normalizedSkills,
+    categories,
   }
 }
 
@@ -96,10 +127,193 @@ export function createBlankWritingSkill(config: UserSkillConfig, now = Date.now(
   })
 }
 
+export function importWritingSkill(
+  config: UserSkillConfig,
+  params: { name: string; content: string; description?: string },
+  now = Date.now(),
+): UserSkillConfig {
+  const { name, content, description } = params
+  const trimmedName = name.trim()
+  const trimmedContent = content.trim()
+  if (!trimmedName || !trimmedContent) return config
+
+  const skill = normalizeUserSkill({
+    id: `skill:${now}`,
+    name: trimmedName,
+    description: description?.trim() ?? "",
+    kind: ["style", "structure"],
+    stages: ["planning", "drafting"],
+    modes: ["standard", "strict"],
+    content: trimmedContent,
+    source: "uploaded",
+    createdAt: now,
+    updatedAt: now,
+  })
+  return normalizeUserSkillConfig({
+    ...config,
+    selectedSkillId: skill.id,
+    skills: [skill, ...config.skills],
+  })
+}
+
+function isFileByPath(path: string): boolean {
+  const lower = path.toLowerCase()
+  return lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".json")
+}
+
+function parseFrontmatter(content: string): { name?: string; description?: string; body: string } {
+  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
+  if (!match) {
+    return { body: content }
+  }
+  const yamlBlock = match[1]
+  const nameMatch = yamlBlock.match(/^name:\s*(.+?)\s*$/m)
+  const descMatch = yamlBlock.match(/^description:\s*(.+?)\s*$/m)
+  return {
+    name: nameMatch ? nameMatch[1].trim() : undefined,
+    description: descMatch ? descMatch[1].trim() : undefined,
+    body: content.slice(match[0].length),
+  }
+}
+
+export async function importLinkedSkill(
+  config: UserSkillConfig,
+  folderOrFilePath: string,
+): Promise<UserSkillConfig> {
+  const now = Date.now()
+  const isFile = isFileByPath(folderOrFilePath)
+  let name = ""
+  let description = ""
+
+  if (isFile) {
+    const fileName = await basename(folderOrFilePath)
+    const ext = await extname(folderOrFilePath)
+    name = fileName.slice(0, fileName.length - ext.length)
+    try {
+      const content = await readFile(folderOrFilePath)
+      const parsed = parseFrontmatter(content)
+      if (parsed.name) {
+        name = parsed.name
+      }
+      if (parsed.description) {
+        description = parsed.description
+      }
+    } catch {
+    }
+  } else {
+    const folderName = await basename(folderOrFilePath)
+    name = folderName
+    try {
+      const skillMdPath = await join(folderOrFilePath, "SKILL.md")
+      const content = await readFile(skillMdPath)
+      const parsed = parseFrontmatter(content)
+      if (parsed.name) {
+        name = parsed.name
+      }
+      if (parsed.description) {
+        description = parsed.description
+      }
+    } catch {
+    }
+  }
+
+  const skill = normalizeUserSkill({
+    id: `skill:${now}`,
+    name: name || "未命名 Skill",
+    description,
+    content: "",
+    source: "linked",
+    linkedPath: folderOrFilePath,
+    priority: 50,
+    tags: [],
+    categoryId: "",
+    createdAt: now,
+    updatedAt: now,
+  })
+
+  return normalizeUserSkillConfig({
+    ...config,
+    selectedSkillId: skill.id,
+    skills: [skill, ...config.skills],
+  })
+}
+
+export async function loadLinkedSkillContent(skill: UserSkill): Promise<string> {
+  if (skill.source !== "linked" || !skill.linkedPath) {
+    return skill.content
+  }
+
+  const linkedPath = skill.linkedPath
+  const isFile = isFileByPath(linkedPath)
+
+  try {
+    if (isFile) {
+      return await readFile(linkedPath)
+    }
+
+    const skillMdPath = await join(linkedPath, "SKILL.md")
+    let content = await readFile(skillMdPath)
+
+    try {
+      const docsPath = await join(linkedPath, "docs")
+      const files = await listDirectory(docsPath)
+      const mdFiles = files.filter((f) => f.name.toLowerCase().endsWith(".md") && !f.is_dir)
+      if (mdFiles.length > 0) {
+        const docContents: string[] = []
+        for (const file of mdFiles) {
+          const filePath = await join(docsPath, file.name)
+          try {
+            const docContent = await readFile(filePath)
+            docContents.push(docContent)
+          } catch {
+          }
+        }
+        if (docContents.length > 0) {
+          content += `\n---\n# 附加文档\n---\n\n${docContents.join("\n\n---\n\n")}`
+        }
+      }
+    } catch {
+    }
+
+    return content
+  } catch {
+    return ""
+  }
+}
+
+export async function loadAllLinkedSkillsContent(config: UserSkillConfig): Promise<UserSkillConfig> {
+  const linkedSkills = config.skills.filter((s) => s.source === "linked")
+  if (linkedSkills.length === 0) {
+    return config
+  }
+
+  const contents = await Promise.all(
+    linkedSkills.map((skill) => loadLinkedSkillContent(skill))
+  )
+
+  const contentMap = new Map<string, string>()
+  linkedSkills.forEach((skill, index) => {
+    contentMap.set(skill.id, contents[index])
+  })
+
+  const updatedSkills = config.skills.map((skill) => {
+    const content = contentMap.get(skill.id)
+    if (content !== undefined) {
+      return { ...skill, content }
+    }
+    return skill
+  })
+
+  return {
+    ...config,
+    skills: updatedSkills,
+  }
+}
+
 export function updateWritingSkill(
   config: UserSkillConfig,
   skillId: string,
-  patch: Partial<Pick<UserSkill, "name" | "description" | "kind" | "stages" | "modes" | "content">>,
+  patch: Partial<Pick<UserSkill, "name" | "description" | "kind" | "stages" | "modes" | "content" | "priority" | "tags" | "categoryId">>,
   now = Date.now(),
 ): UserSkillConfig {
   return normalizeUserSkillConfig({
@@ -110,7 +324,7 @@ export function updateWritingSkill(
           ...skill,
           ...patch,
           id: skill.id,
-          source: "uploaded",
+          source: skill.source,
           updatedAt: now,
         })
         : skill,
@@ -118,6 +332,23 @@ export function updateWritingSkill(
   })
 }
 
+export function touchSkillUsage(
+  config: UserSkillConfig,
+  skillId: string,
+  now = Date.now(),
+): UserSkillConfig {
+  const skill = config.skills.find((s) => s.id === skillId)
+  if (!skill || skill.source === "built-in") return config
+  return normalizeUserSkillConfig({
+    ...config,
+    skills: config.skills.map((s) =>
+      s.id === skillId
+        ? normalizeUserSkill({ ...s, updatedAt: now })
+        : s,
+    ),
+  })
+}
+
 export function setWritingSkillEnabled(
   config: UserSkillConfig,
   skillId: string,
@@ -141,6 +372,88 @@ export function deleteWritingSkill(config: UserSkillConfig, skillId: string): Us
   })
 }
 
+export function createSkillCategory(
+  config: UserSkillConfig,
+  name: string,
+  now = Date.now(),
+): UserSkillConfig {
+  const trimmedName = name.trim()
+  if (!trimmedName) return config
+  const category: SkillCategory = {
+    id: `cat:${now}`,
+    name: trimmedName,
+    createdAt: now,
+    updatedAt: now,
+  }
+  return normalizeUserSkillConfig({
+    ...config,
+    categories: [...config.categories, category],
+  })
+}
+
+export function renameSkillCategory(
+  config: UserSkillConfig,
+  categoryId: string,
+  newName: string,
+  now = Date.now(),
+): UserSkillConfig {
+  const trimmedName = newName.trim()
+  if (!trimmedName) return config
+  return normalizeUserSkillConfig({
+    ...config,
+    categories: config.categories.map((cat) =>
+      cat.id === categoryId
+        ? { ...cat, name: trimmedName, updatedAt: now }
+        : cat,
+    ),
+  })
+}
+
+export function deleteSkillCategory(
+  config: UserSkillConfig,
+  categoryId: string,
+): UserSkillConfig {
+  const skills = config.skills.map((skill) =>
+    skill.categoryId === categoryId
+      ? { ...skill, categoryId: "" }
+      : skill,
+  )
+  return normalizeUserSkillConfig({
+    ...config,
+    skills,
+    categories: config.categories.filter((cat) => cat.id !== categoryId),
+  })
+}
+
+export function reorderSkillCategories(
+  config: UserSkillConfig,
+  fromIndex: number,
+  toIndex: number,
+): UserSkillConfig {
+  const categories = [...config.categories]
+  const [moved] = categories.splice(fromIndex, 1)
+  categories.splice(toIndex, 0, moved)
+  return normalizeUserSkillConfig({
+    ...config,
+    categories,
+  })
+}
+
+export function moveSkillToCategory(
+  config: UserSkillConfig,
+  skillId: string,
+  categoryId: string,
+): UserSkillConfig {
+  return normalizeUserSkillConfig({
+    ...config,
+    skills: config.skills.map((skill) =>
+      skill.id === skillId
+        ? { ...skill, categoryId }
+        : skill,
+    ),
+  })
+}
+
 export function resolveEnabledWritingSkills(config: UserSkillConfig): UserSkill[] {
   const disabled = new Set(config.disabledSkillIds)
   return config.skills.filter((skill) => !disabled.has(skill.id))
@@ -177,6 +490,49 @@ export function ensureBuiltinSkills(config: UserSkillConfig): UserSkillConfig {
   });
 }
 
+export function exportSkillToJson(skill: UserSkill): string {
+  const data = {
+    "qmai-skill": true,
+    version: 1,
+    name: skill.name,
+    description: skill.description,
+    kind: skill.kind,
+    stages: skill.stages,
+    modes: skill.modes,
+    content: skill.content,
+    priority: skill.priority,
+    tags: skill.tags,
+  }
+  return JSON.stringify(data, null, 2)
+}
+
+export function importSkillFromJson(jsonStr: string): UserSkill | null {
+  try {
+    const parsed = JSON.parse(jsonStr)
+    if (!parsed || typeof parsed !== "object") return null
+    const raw = parsed as Record<string, unknown>
+    if (typeof raw.name !== "string" || !raw.name.trim()) return null
+    if (typeof raw.content !== "string" || !raw.content.trim()) return null
+    const now = Date.now()
+    return normalizeUserSkill({
+      id: `skill:${now}`,
+      name: raw.name,
+      description: typeof raw.description === "string" ? raw.description : "",
+      kind: Array.isArray(raw.kind) ? raw.kind : undefined,
+      stages: Array.isArray(raw.stages) ? raw.stages : undefined,
+      modes: Array.isArray(raw.modes) ? raw.modes : undefined,
+      content: raw.content,
+      source: "uploaded",
+      priority: typeof raw.priority === "number" ? raw.priority : undefined,
+      tags: Array.isArray(raw.tags) ? raw.tags : undefined,
+      createdAt: now,
+      updatedAt: now,
+    })
+  } catch {
+    return null
+  }
+}
+
 export const WRITING_SKILL_KIND_OPTIONS: SkillKind[] = [
   "style",
   "structure",

Неке датотеке нису приказане због велике количине промена