Просмотр исходного кода

feat(skill-library): 完善写作Skill库功能

1. 新增8个细分内置写作Skill(悬念设计、人物出场、世界观植入、场景描写、对话设计、转场过渡、情绪渲染、金句设计)
2. Skill元数据增强:增加priority(优先级)和tags(标签)字段
3. Skill分类管理:支持新建、重命名、删除分类,Skill可移动到指定分类,分类支持拖拽排序
4. 最近使用Skill分组:自动记录并显示最近使用的5个用户自建Skill
5. Skill JSON导入导出:支持完整元数据的JSON格式导入导出
6. AI会话查看启用Skill:聊天面板底部新增✨按钮,点击查看本次会话启用的Skill列表,支持展开查看详情
7. Linked Skill文件夹引用:支持导入文件夹/文件作为外部引用Skill,动态读取内容,不复制到项目中
8. UI优化:移除Skill库左侧重复标签、分类emoji图标替换为SVG、工作模式三按钮合并为下拉按钮

相关文件:
- src/lib/novel/skill-seed.ts
- src/lib/novel/skill-library.ts
- src/lib/novel/user-skill-store.ts
- src/lib/novel/de-ai-skill-library.ts
- src/components/skill-library/writing-skill-library-view.tsx
- src/components/skill-library/unified-skill-library-view.tsx
- src/components/chat/chat-panel.tsx
- src/hooks/use-agent-config.ts
- src/lib/agent/plugins/select-skills-plugin.ts
- src/lib/agent/capabilities/types.ts
- src/lib/agent/context-trace.ts
Mochocyang 2 месяцев назад
Родитель
Сommit
00b438f99f

+ 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",
           },
         ],

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

@@ -53,7 +53,6 @@ export function UnifiedSkillLibrarySidebarPanel() {
 
   return (
     <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
-      <SkillLibraryTabs compact />
       <div className="min-h-0 flex-1 overflow-hidden">
         {showWritingSkill ? <WritingSkillLibrarySidebarPanel /> : <SkillLibrarySidebarPanel />}
       </div>

+ 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 {

+ 3 - 0
src/lib/agent/plugins/select-skills-plugin.spec.ts

@@ -51,6 +51,7 @@ describe("SelectSkillsPlugin", () => {
       "冲突升级",
       "剧情自检",
       "正文输出协议",
+      "世界观资料",
     ])
   })
 
@@ -73,6 +74,7 @@ describe("SelectSkillsPlugin", () => {
           modes: ["standard", "strict"],
           content: "三次转折,四次震惊。",
           source: "uploaded",
+          priority: 25,
         }),
       ],
       taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
@@ -124,6 +126,7 @@ describe("SelectSkillsPlugin", () => {
       "伏笔管理",
       "节奏检查",
       "结尾钩子",
+      "世界观资料",
     ])
   })
 

+ 6 - 1
src/lib/agent/plugins/select-skills-plugin.ts

@@ -38,6 +38,8 @@ const STRICT_WRITING_SKILL_NAMES = [
 
 const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "去AI味"]
 
+const EXCLUDED_FROM_FALLBACK = ["去AI味"]
+
 export function createSelectSkillsPlugin(): PrePlugin {
   return {
     name: "select_skills",
@@ -119,10 +121,12 @@ function selectPreferredNames(skills: UserSkill[], names: string[], limit: numbe
 
   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.filter((item) => item.source === "uploaded")) {
+    for (const skill of fallback) {
       if (selected.length >= limit) break
       if (!selected.some((item) => item.id === skill.id)) {
         selected.push(skill)
@@ -163,6 +167,7 @@ function scoreSkill(
   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
 }
 

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

+ 1733 - 22
src/lib/novel/skill-seed.ts

@@ -1,108 +1,1819 @@
-import { normalizeUserSkill, type UserSkill } from "./skill-library"
+import { normalizeUserSkill, type UserSkill } from "./skill-library"
+
+const chapterConnectionContent = `# 章节承接
+
+## 核心目标
+
+确保本章与上一章在人物、情节、情绪、设定四个维度上无缝衔接,避免出现"两章之间像断了一截"的阅读体验。
+
+## 适用场景
+
+- 每次开始写新一章正文前
+- 上一章结尾有强悬念、强情绪或关键事件时
+- 章节间时间跳跃或场景切换后
+
+## 承接清单
+
+逐项检查与上一章的衔接状态:
+
+1. **人物位置**:上一章结束时每个人物在哪里?本章开头是否合理延续?
+2. **人物情绪**:上一章结尾人物处于什么情绪状态?本章开头是否保持了情绪连续性?(情绪不会凭空消失,只能被新事件覆盖或转移)
+3. **未解决事件**:上一章是否留下了未完成的对话、未做出的决定、未到达的目的地?本章是否该处理?
+4. **道具/线索**:上一章出现的重要物品或线索,是否需要在开头提及或推进?
+5. **时间线**:本章与上一章的时间间隔是多少?如果跳过了时间,需要交代跳过了什么。
+6. **伏笔状态**:上一章埋了哪些伏笔?本章是否需要推进或暗示?
+7. **章节钩子**:上一章结尾的钩子(悬念/反转/危机)是否被本章开头承接?
+
+## 承接强度分级
+
+- **强承接**:紧接上一章结尾,时间连续、场景连续。适用于高潮后的余波、关键对话未完、危机尚未解除。
+- **中承接**:时间跳跃数小时到一天,通过简短回忆或状态描述自然过渡。适用于日常章节之间的切换。
+- **弱承接**:时间跳跃数天以上,或场景大幅切换。需要用一个小的过渡段落交代"这段时间发生了什么",但不要写成流水账。
+
+## 弱承接补强方法
+
+当无法强承接时,用以下方式补强衔接感:
+
+1. **锚点回扣**:用一个细节(同一件物品、同一句话的余音、同一个身体感受)回扣上一章。
+2. **情绪余波**:即使场景变了,人物的情绪应延续上一章的余波,再被新事件覆盖。
+3. **因果链**:明确写出"因为上一章发生了X,所以本章出现Y",哪怕是隐式的。
+4. **钩子回收**:如果上一章结尾有钩子,本章必须在某个位置(不必是开头)回收。
+
+## 禁止做法
+
+- 上一章结尾人物在激烈冲突中,本章开头直接跳到平静日常,中间没有过渡
+- 无视上一章埋下的伏笔和钩子,直接开新剧情
+- 人物位置和情绪出现"跳跃",上一章在A地愤怒,本章在B地平静,没有交代原因`
+
+const nextChapterPlanContent = `# 下一章计划
+
+## 核心目标
+
+在本章正文动笔之前,生成一份结构化、可执行的章节蓝图,明确本章要写什么、为什么写、怎么写。这不仅是提纲,更是对"这一章存在的理由"的确认。
+
+## 适用场景
+
+- 每次开始写新章节正文前
+- 上一章写完后,需要规划下一章时
+- 长篇小说中需要定期检查章节方向时
+
+## 输入依赖
+
+制定计划前,需要先读取以下信息:
+
+1. 上一章结尾状态(人物、情绪、事件、钩子)
+2. 当前故事主线进度
+3. 已埋设的伏笔清单
+4. 灵魂文档(soul.md)中的风格要求
+
+## 生成步骤
+
+按以下顺序逐步生成计划:
+
+### 步骤1:确定本章目标
+本章要达成的核心结果是什么?选一个最核心的:
+- 推进主线(主角离目标更近/更远)
+- 揭示信息(读者/角色知道了什么新东西)
+- 人物成长(某个角色发生了什么变化)
+- 制造转折(故事方向发生改变)
+- 铺垫伏笔(为后续剧情打基础)
+
+### 步骤2:确定核心冲突
+本章的核心矛盾是什么?冲突必须具体、可感知:
+- 人与人之间的冲突(利益、价值观、情感)
+- 人与环境的冲突(规则、压力、资源限制)
+- 人与自我的冲突(选择、信念、恐惧)
+
+### 步骤3:列出出场人物
+每个出场人物明确三件事:
+- 他/她本章想要什么?
+- 他/她做了什么来得到它?
+- 他/她的行动如何影响主角或主线?
+
+### 步骤4:安排关键事件顺序
+按时间顺序列出本章的关键事件,每件事包含:
+- 发生了什么(行为)
+- 为什么发生(动因)
+- 导致什么结果(后果,推动下一事件)
+
+### 步骤5:设计情绪曲线
+本章的情绪走向:
+- 开头情绪基调是什么?
+- 中间情绪如何变化?(上升/下降/波动)
+- 结尾情绪落点在哪里?(给读者的感受)
+
+### 步骤6:推进伏笔
+- 本章需要推进哪些已有伏笔?
+- 是否需要在暗中埋设新伏笔?
+- 是否有伏笔到了该回收的时机?
+
+### 步骤7:设计结尾钩子
+本章结尾用什么方式让读者想继续看下一章?
+- 选择钩子类型(悬念/反转/危机/情绪/关系变化)
+- 钩子放在最后一句还是最后一段?
+
+## 质量标准
+
+好的章节计划应该满足:
+- 每个事件都有明确的因果驱动,不是"发生了A然后发生了B",而是"因为A所以B"
+- 每个出场人物都推动了剧情或展现了性格,没有工具人
+- 情绪曲线有起伏,不是从头平到尾
+- 结尾有钩子,让读者有"下一章必须看"的冲动
+- 本章内容服务于主线或重要支线,不是"水字数"
+
+## 常见陷阱
+
+- 计划太满:塞了太多事件,每个都写不深。一章只聚焦1-2个核心事件
+- 计划太虚:只有"冲突升级""情绪推进"等抽象描述,没有具体内容
+- 忽略节奏:一味推进事件,没有给人物反应、环境描写、心理活动留空间
+- 结尾无力:事件写完了就结束,没有设计钩子`
+
+const mainlineCheckContent = `# 主线检查
+
+## 核心目标
+
+诊断本章内容是否服务于故事主线,识别"写偏了"的风险,并给出具体纠正建议。主线检查不是审稿,不做文笔评价,只判断"这章对主线有没有贡献"。
+
+## 适用场景
+
+- 章节计划完成后、正文动笔前
+- 正文初稿完成后、精修前
+- 写到中段感觉"好像跑偏了"时
+- 支线剧情较多时,定期检查
+
+## 诊断维度
+
+从以下四个维度逐一检查本章内容:
+
+### 维度1:主角目标推进
+- 本章是否让主角离核心目标更近或更远?
+- 如果主角没有出现,本章推进的是哪个关键角色的目标?
+- 本章内容是否可以删除而不影响主角的主线?如果可以,那就是偏航。
+
+### 维度2:主要冲突进展
+- 本章是否涉及故事的核心冲突(主要对手/主要障碍/主要问题)?
+- 冲突是否有实质进展(升级/转折/揭示新信息/改变力量对比)?
+- 如果冲突维持原样没有推进,至少是否揭示了新的层面?
+
+### 维度3:反派/压力源活跃度
+- 反派或压力来源(可以是人、组织、环境、命运)本章是否在行动?
+- 反派是否让主角的处境变得更难?如果不是,反派是否在被铺垫?
+- 章内完全没有反派痕迹时,需要标记:是否在铺垫后续对抗?
+
+### 维度4:阶段性转折
+- 本章是否对故事走向产生了影响?
+- 是否改变了读者对某个角色、某个事件、某个设定的认知?
+- 是否产生了新的问题或收束了旧的悬念?
+
+## 判断标准
+
+### 主线服务度评分
+- **高服务**:本章至少推进了上述4个维度中的2个,且有实质进展
+- **中服务**:本章推进了至少1个维度,其余维度有铺垫
+- **低服务**:本章只完成了场景过渡、气氛渲染、日常互动,对主线没有实质推进
+- **无服务**:本章内容与主线无关,属于脱离主线的支线或"水章节"
+
+### 偏航类型识别
+- **支线膨胀**:支线情节占据了整章,主线被完全搁置
+- **日常堆砌**:大量日常互动、场景描写,没有推进任何剧情
+- **信息重复**:本章在重复读者已知的信息,没有新进展
+- **铺垫过度**:为后续剧情铺垫太多,反而忘了本章本身该有内容
+- **视角游离**:频繁切换视角,但没有一个视角在推进主线
+
+## 纠正建议
+
+针对不同偏航类型给出具体建议:
+
+- **支线膨胀**:支线应该服务于主线。如果支线独立于主线,将其压缩为半章或更短,或推迟到主线需要时再写
+- **日常堆砌**:日常互动只有在揭示人物关系变化或世界观时才有效。无效日常直接删除
+- **信息重复**:确认本章是否有新的信息增量。如果没有,这章需要重写
+- **铺垫过度**:铺垫应该是"边推进边铺垫",不应单独占用一章。将铺垫融入主线事件中
+- **视角游离**:选择与主线最相关的1-2个视角,其余视角的观察可以合并或后置
+
+## 输出要求
+
+检查结果以简洁的结论呈现:
+- 如果不偏航:标注"主线服务度:高/中"
+- 如果偏航:标注偏航类型 + 具体表现 + 纠正建议
+- 不要输出长篇分析,只给关键判断`
+
+const characterMotivationContent = `# 人物动机
+
+## 核心目标
+
+确保每个出场人物的行为都有内在驱动力,而非"因为剧情需要"。人物动机检查帮助作者发现"工具人行为"和"OOC(角色崩坏)"风险,让人物的每个选择都经得起推敲。
+
+## 适用场景
+
+- 章节计划阶段,安排人物行动前
+- 正文完成后,检查人物行为是否合理
+- 人物做出重大决定或转折时
+- 多个角色互动时,确保各自有独立动机
+
+## 动机三角模型
+
+每个角色的每个行为,都应能从以下三个维度中找到至少一个驱动力:
+
+### 1. 想要(Want)
+人物当前最直接的目标是什么?
+- 短期目标:本章内他想得到什么?
+- 具体可感:不是抽象的"成功",而是"拿到那封信""让某人相信自己"
+- 可验证:读者能判断他是否达成了目标
+
+### 2. 害怕(Fear)
+人物害怕失去什么?害怕发生什么?
+- 核心恐惧:他最深层的恐惧是什么?(背叛、孤独、失控、被遗忘、失去某人)
+- 当前触发:本章的什么事件触发了这个恐惧?
+- 防御行为:他为了不让自己害怕的事发生,会做什么?
+
+### 3. 信念(Belief)
+人物相信什么?即使这个信念可能是错的。
+- 关于自己:他认为自己是什么样的人?("我必须保护所有人""我不配被爱")
+- 关于世界:他认为世界是什么样的?("弱肉强食""好人会有好报")
+- 关于他人:他对关键人物的判断是什么?("他不可信""她比我强")
+
+## 行为-动机匹配检验
+
+对本章每个人物的每个重要行为,做以下检验:
+
+1. **行为是什么?** —— 具体描述人物的行动
+2. **为什么这么做?** —— 从想要/害怕/信念中找到驱动源
+3. **有没有更好的选择?** —— 在当前情境下,人物是否有其他合理选择?如果有,为什么选择了这个?
+4. **与前文一致吗?** —— 这个行为是否符合已建立的人物性格?如果不符合,是否有充分的事件触发变化?
+
+## 动机冲突处理
+
+当多个人物互动时,动机冲突是戏剧张力的核心来源:
+
+1. **目标冲突**:A想要X,B也想要X,但X只有一个 → 直接对抗
+2. **方式冲突**:A和B目标一致,但对"怎么达成"有分歧 → 合作中的摩擦
+3. **信息不对称**:A知道B不知道的信息,导致两人做出不同判断 → 误解与揭示
+4. **价值冲突**:A认为X是对的,B认为X是错的 → 深层立场对立
+
+## 常见问题及处理
+
+- **工具人行为**:人物的行为只服务于剧情需要,没有自身动机。→ 给人物补充一个独立的"想要"
+- **动机过强**:一个小事件引发了过度的情绪反应。→ 确认是否有积累的触发,如果没有,降低反应强度
+- **动机突变**:人物的目标和行为突然改变,没有过渡。→ 加入触发事件和内心挣扎
+- **动机单一**:人物只有一个驱动力,行为扁平。→ 引入"想要"和"害怕"的冲突,让人物面临两难
+- **动机模糊**:读者不知道人物为什么这么做。→ 不一定要明说,但作者心里必须有答案
+
+## 输出要求
+
+- 不输出长篇心理分析报告
+- 如果发现动机问题,直接指出:哪个角色、哪个行为、缺失什么动机、建议补什么
+- 如果没有问题,不强制输出检查结果`
+
+const conflictEscalationContent = `# 冲突升级
+
+## 核心目标
+
+确保本章有足够的矛盾和张力,让读者感到"有事正在发生",而非平淡推进。冲突升级不是让每章都打起来,而是让矛盾在任何层面(关系、利益、心理、环境)都在持续加压。
+
+## 适用场景
+
+- 章节计划阶段,设计本章的冲突结构
+- 发现章节"太平""太水"时,补充冲突元素
+- 连载小说中防止"中段疲软"
+
+## 冲突的五个层次
+
+从轻到重,选择适合本章的冲突层次:
+
+### 层次1:内心冲突
+人物内心在两种选择、两种信念、两种情感之间摇摆。
+- 示例:想救人但可能暴露自己 / 想相信他但过去的经验说不
+- 关键:内心冲突需要通过行为、犹豫、微表情来外化,不要只靠内心独白
+
+### 层次2:人际冲突
+人物之间因目标、价值观、信息差产生摩擦。
+- 示例:队友隐瞒了关键信息 / 两人对下一步行动的分歧
+- 关键:人际冲突要有"升级空间",从轻微的摩擦到严重的对立
+
+### 层次3:群体冲突
+人物与一个群体(家族、组织、社会)产生对抗。
+- 示例:被族人排斥 / 挑战组织的规则
+- 关键:群体冲突要有"具体代言人",不要让主角对着一个抽象概念
+
+### 层次4:规则冲突
+人物与世界的规则、法则、禁忌产生冲突。
+- 示例:修炼体系限制 / 社会阶级固化 / 某种不可违抗的律法
+- 关键:规则冲突要有"代价",违反规则要付出实质代价
+
+### 层次5:生存冲突
+人物面临生命威胁、存在危机。
+- 示例:生死战斗 / 绝境求生 / 被世界规则抹杀
+- 关键:生存冲突要真实可感,不要让读者觉得"反正主角不会死"
+
+## 升级路径
+
+冲突不是一步到位的,好的冲突有递进:
+
+1. **触发**:一个事件打破现有的平衡(A发现B隐瞒了信息)
+2. **对峙**:双方直接碰撞(A质问B,B辩解)
+3. **升级**:冲突扩展到更大的范围(A和B的裂痕影响到团队决策)
+4. **代价**:冲突产生不可逆的后果(A做出了伤害B的选择)
+5. **收束或继续**:冲突暂时解决或进入下一个阶段
+
+## 冲突节奏控制
+
+- **一章内**:不要从头紧张到尾,读者需要喘息。紧张-释放-再紧张,交替进行
+- **连续几章**:冲突持续升级时,中间插入一章相对缓和的"整理章",让读者消化信息
+- **冲突强度与字数**:高强度冲突(战斗、对峙)适合短章节;低强度冲突(心理、关系)可以写长一些
+
+## 冲突的检验标准
+
+完成本章后检查:
+1. 本章是否有至少一个冲突在推进?(内心/人际/群体/规则/生存,至少一个)
+2. 冲突是否产生了实质后果?(不只是"闹了一下又和好了")
+3. 冲突是否揭示了新信息?(人物隐藏的一面 / 世界观的新层面 / 关系的真相)
+4. 冲突是否让读者产生了情绪?(紧张、愤怒、担忧、期待)
+
+## 避坑指南
+
+- **假冲突**:人物吵了一架但不影响任何事,下一章恢复原状
+- **冲突过载**:一章塞了太多冲突,每个都没写透
+- **冲突扁平**:只有一种冲突类型反复出现(比如每章都在打架)
+- **代价缺失**:冲突没有真正的代价,人物不需要为选择付出什么
+- **冲突与主线脱节**:冲突很精彩但跟主线无关,读者看完不知道这章有什么意义`
+
+const foreshadowingManagementContent = `# 伏笔管理
+
+## 核心目标
+
+系统化管理故事中的伏笔,确保每个伏笔都有完整的生命周期:埋设 → 推进 → 提示 → 回收。避免出现"挖坑不填"或"伏笔遗忘"的问题。
+
+## 适用场景
+
+- 章节计划阶段,决定本章需要推进或回收哪些伏笔
+- 正文完成后,检查本章是否遗漏了该处理的伏笔
+- 长篇小说写到中期,定期盘点伏笔状态
+
+## 伏笔生命周期
+
+每个伏笔在故事中经历以下阶段:
+
+### 阶段1:埋设
+在读者不注意的地方留下线索。
+- 要求:自然、不刻意,让读者事后回想时能发现
+- 方式:对话中的一句闲话 / 环境描写中的一个细节 / 人物反常的一个小动作
+- 数量控制:一章埋设1-2个新伏笔即可,过多会让读者混乱
+
+### 阶段2:推进
+在后续章节中,在不揭示真相的前提下,让伏笔"发酵"。
+- 方式:重复出现但每次换个角度 / 相关角色多了一个奇怪的反应 / 环境出现更多线索
+- 频率:每个伏笔每隔3-5章至少推进一次,避免读者遗忘
+
+### 阶段3:提示
+在回收前,给读者一个"即将揭晓"的信号。
+- 方式:线索突然汇聚 / 人物开始接近真相 / 读者开始能拼出部分答案
+- 节奏:提示不要一次性给完,分2-3次逐步释放
+
+### 阶段4:回收
+揭示伏笔的真相。
+- 要求:回收必须有"原来如此"的冲击力,不能是读者早就猜到的
+- 回收后:伏笔回收后,其影响应持续,不能"回收了就没了"
+- 时机:伏笔的回收时机应该在最能产生戏剧效果的节点
+
+## 伏笔密度控制
+
+- **活跃伏笔**:当前正在推进的伏笔,建议不超过5-8个
+- **休眠伏笔**:已埋设但暂不推进的伏笔,定期检查不要遗忘
+- **已回收伏笔**:记录回收时间和回收后的影响
+
+## 伏笔分类
+
+- **信息型伏笔**:隐藏的设定、世界观、人物背景信息
+- **事件型伏笔**:为后续重大事件做的铺垫
+- **人物型伏笔**:隐藏的人物关系、身份、动机
+- **道具型伏笔**:看似普通的物品,后续有关键作用
+
+## 伏笔遗忘处理
+
+如果发现某个伏笔很久没有推进:
+1. 确认是否还有必要保留?如果故事方向变了,可以放弃(但要在伏笔列表注明)
+2. 如果仍需保留,在最近3章内找一个自然的机会推进
+3. 如果该伏笔的回收时机已过,考虑修改回收方式,不要强行回收
+
+## 输出要求
+
+对本章的伏笔处理给出简洁判断:
+- 本章需要推进的伏笔:列出并说明如何推进
+- 本章需要回收的伏笔:列出并说明回收方式
+- 被遗忘的伏笔提醒:列出太久未推进的伏笔
+- 不要输出完整的伏笔清单,只给与本章相关的判断`
+
+const rhythmCheckContent = `# 节奏检查
+
+## 核心目标
+
+诊断章节的叙事节奏,识别"拖沓""平淡""塌陷"等问题,确保章节有张有弛、快慢有致。节奏检查关注的是"读者阅读体验的时间感",而非章节本身的字数。
+
+## 适用场景
+
+- 正文初稿完成后
+- 读者反馈"节奏太慢"或"节奏太快"时
+- 章节字数正常但读起来"累"或"空"时
+
+## 节奏曲线模型
+
+理想的一章应该有类似"波浪"的节奏曲线:
+
+- **开头(10%-15%)**:快速建立场景,给出本章的"钩子",让读者知道这章要发生什么
+- **发展(40%-50%)**:事件逐步推进,节奏可以稍慢,但每个段落都必须有信息增量
+- **高点(15%-20%)**:本章的情绪或事件高潮,节奏加快,句子更短,张力最强
+- **收尾(10%-15%)**:高潮后的余波,处理后果,设置下一章的钩子
+
+## 节奏问题诊断
+
+### 问题1:开头太慢(慢热)
+症状:读者看了好几段还不知道这章要发生什么。
+原因:开头堆了大量环境描写、心理活动、无关对话。
+治疗:把章内最核心的冲突或悬念提前到开头,用"发生了什么"直接开局。
+
+### 问题2:中段塌陷
+症状:中间部分读起来"空",事件推进慢,描写重复。
+原因:中段在"等"高潮,但等待期间没有有效内容。
+治疗:要么加速推进到高潮,要么在中段插入新的冲突或信息揭示。
+
+### 问题3:场景堆砌
+症状:连续多个场景切换,每个都浅尝辄止。
+原因:想写的东西太多,但没有在一个场景中写深。
+治疗:减少场景数量,每个场景至少做到"推进了剧情 + 展现了人物 + 传递了信息"中的两项。
+
+### 问题4:高潮乏力
+症状:应该紧张的段落读起来平淡。
+原因:高潮前铺垫不足 / 冲突太容易解决 / 缺少代价。
+治疗:在高潮前增加"不可逆的选择"或"必须付出的代价"。
+
+### 问题5:结尾仓促
+症状:高潮结束后直接结束,没有处理后果。
+原因:写作时精力耗尽,匆匆收尾。
+治疗:高潮后至少留15%的篇幅处理"然后呢?"——人物的反应、事件的后果、下一章的钩子。
+
+## 快慢节奏分布建议
+
+- **快节奏段**:冲突、动作、对话、揭示信息。句子短,段落短,切换快。
+- **中节奏段**:心理活动、关系互动、环境描写。句子适中,有一定深度。
+- **慢节奏段**:氛围渲染、人物独白、世界观展开。句子可以长,但要有信息密度。
+
+一章内的快慢比例建议 3:5:2(快:中:慢),避免全章匀速。
+
+## 输出要求
+
+- 如果节奏正常:标注"节奏正常"
+- 如果发现问题:指出具体问题 + 所在位置 + 调整建议
+- 不要输出完整的节奏分析,只给关键判断`
+
+const endingHookContent = `# 结尾钩子
+
+## 核心目标
+
+为本章设计一个让读者"必须看下一章"的结尾。结尾钩子不是附加品,而是章节的有机组成部分——它既是对本章的收束,也是对下一章的召唤。
+
+## 适用场景
+
+- 章节计划阶段,确定本章结尾策略
+- 正文写到后半段时,开始设计具体的结尾钩子
+- 发现本章结尾太平淡,需要加强
+
+## 钩子的七种类型
+
+### 类型1:悬念钩子
+在结尾抛出一个读者不知道答案的问题。
+- 做法:揭示一个信息,但这个信息带来的问题比答案更多
+- 示例:门后露出一张不该出现的人的脸 / 信上写着"我知道你做了什么"
+- 强度:★★★★★
+
+### 类型2:反转钩子
+在结尾推翻读者之前的认知。
+- 做法:在结尾揭示一个真相,让读者重新审视整章内容
+- 示例:一直帮助主角的人其实是卧底 / 所有人都以为死了的人出现了
+- 强度:★★★★★
+
+### 类型3:危机钩子
+在结尾将主角置于危险之中。
+- 做法:在结尾让主角面临一个无法回避的威胁
+- 示例:主角听到身后传来脚步声 / 发现被人跟踪 / 被逼到绝路
+- 强度:★★★★☆
+
+### 类型4:决定钩子
+在结尾让主角面临一个重大选择。
+- 做法:在结尾清晰呈现两个(或更多)选项,每个都有代价
+- 示例:救A还是救B / 说出真相还是继续隐瞒 / 离开还是留下
+- 强度:★★★★☆
+
+### 类型5:情绪钩子
+在结尾让读者产生强烈的情绪共鸣。
+- 做法:在结尾放一个充满情感的画面、对话或独白
+- 示例:母亲终于见到失散多年的孩子 / 主角独自站在风中,想起过去的自己
+- 强度:★★★☆☆
+
+### 类型6:关系钩子
+在结尾改变人物之间的关系状态。
+- 做法:在结尾让一段关系发生质变
+- 示例:告白被拒绝 / 朋友反目 / 对手变成盟友
+- 强度:★★★☆☆
+
+### 类型7:预告钩子
+在结尾暗示即将到来的重大事件。
+- 做法:在结尾给一个"风暴即将来临"的信号
+- 示例:远处传来号角声 / 某人收到了一封密信 / 天空出现异象
+- 强度:★★☆☆☆
+
+## 钩子强度分级
+
+- **强钩子**:读者必须立刻看下一章才能缓解焦虑。适用于高潮章、转折章。
+- **中钩子**:读者好奇下一章会发生什么,但不焦虑。适用于推进章、日常章。
+- **弱钩子**:读者可以自然停下来,但愿意继续看。适用于整理章、过渡章。
+
+## 不同类型章节的钩子选择
+
+- **高潮章**:强钩子(悬念/反转/危机),在最高潮处戛然而止
+- **转折章**:强钩子(反转/决定),在揭示真相或做出选择处结束
+- **推进章**:中钩子(危机/关系/情绪),在事件有进展但未完成处结束
+- **日常章**:中钩子(情绪/关系/预告),在人物关系有微妙变化处结束
+- **整理章**:弱钩子(预告/情绪),在暗示"接下来要开始了"处结束
+
+## 钩子设计原则
+
+1. **钩子服务于主线**:钩子应该是主线的延伸,不是独立的噱头
+2. **钩子不宜过多**:一章一个钩子,不要堆叠
+3. **钩子必须有回收**:本章结尾的钩子,下一章或下几章必须回收
+4. **钩子不能是"假钩子"**:不要用"突然!"但下一章什么事都没有
+5. **钩子放在最后一句**:最强的钩子放在真正的最后一句,而非倒数第二段
+
+## 输出要求
+
+- 给本章选择1-2个合适的钩子类型
+- 给出具体的钩子写法建议(一句话/一段话)
+- 说明这个钩子如何与本章内容衔接`
+
+const plotSelfCheckContent = `# 剧情自检
+
+## 核心目标
+
+在正文初稿完成后,对本章进行全面的质量检查。本章是"端到端"综合检查,覆盖承接、主线、动机、冲突、伏笔、节奏、风格七个维度,确保本章质量达到发布标准。
+
+## 适用场景
+
+- 正文初稿完成后,精修之前
+- 多章连续写完后,逐章追溯检查
+- 准备发布前
+
+## 七项检查及评分标准
+
+### 检查1:章节承接 ✓
+检查本章与上一章的衔接。
+- 通过:人物位置、情绪、未解决事件、道具、钩子全部合理承接
+- 需改进:有1-2个承接点断裂,但读者能自行脑补
+- 不合格:多个承接点断裂,读者会明显感到"断档"
+
+### 检查2:主线推进 ✓
+检查本章是否推进了主线。
+- 通过:至少推进了主线的一个维度(主角目标/核心冲突/反派/转折)
+- 需改进:主线没有推进,但铺垫了后续的重要剧情
+- 不合格:本章与主线完全无关,删掉不影响故事
+
+### 检查3:人物动机 ✓
+检查本章人物的行为是否有内在驱动力。
+- 通过:每个重要行为都有明确的动机支撑
+- 需改进:主要人物行为合理,次要人物有工具人嫌疑
+- 不合格:核心人物出现OOC行为,或行为动机缺失
+
+### 检查4:冲突强度 ✓
+检查本章是否有足够的矛盾和张力。
+- 通过:至少有一个冲突在推进,且产生了实质后果
+- 需改进:有冲突但不够深入,或冲突没有产生后果
+- 不合格:全章没有冲突,或者冲突是"假冲突"
+
+### 检查5:伏笔处理 ✓
+检查本章的伏笔推进和回收。
+- 通过:推进了该推进的伏笔,没有遗忘重要伏笔
+- 需改进:伏笔推进了但不够自然,或有轻微遗忘
+- 不合格:完全忽略了伏笔系统,或者反复埋设同类型伏笔不回收
+
+### 检查6:叙事节奏 ✓
+检查本章的节奏是否合理。
+- 通过:有张有弛,快慢交替,没有明显的拖沓或仓促
+- 需改进:有1-2处节奏问题,但不影响整体阅读体验
+- 不合格:节奏严重失衡,会影响读者的阅读体验
+
+### 检查7:风格一致性 ✓
+检查本章是否与soul.md的风格要求一致。
+- 通过:叙述视角、语言风格、人物声线与soul.md设定一致
+- 需改进:有局部偏离,但不影响整体风格
+- 不合格:风格出现明显偏离,读者会感到"这章像别人写的"
+
+## 问题优先级
+
+- **P0 - 必须修改**:不合格项。直接影响读者体验,必须重写或大幅修改
+- **P1 - 建议修改**:需改进项。影响阅读体验,建议修改
+- **P2 - 可选优化**:无大问题但有优化空间,视时间决定
+
+## 常见问题修正思路
+
+- **承接断裂**:在开头加1-2句过渡,回扣上一章的重要信息
+- **主线偏离**:找到本章中与主线最接近的内容,将其放大,删除无关内容
+- **动机缺失**:给人物补充一个"想要"或"害怕",用行为或对话暗示
+- **冲突不足**:在现有场景中增加"阻力"——让人物达成目标变得更难
+- **伏笔遗漏**:在当前场景中找一个自然的机会,插入伏笔的推进或提示
+- **节奏失衡**:拖沓处删减描写,仓促处增加细节或心理活动
+- **风格偏离**:对照soul.md,调整偏离处的用词、句式、叙述视角
+
+## 输出要求
+
+以简洁清单形式输出检查结果:
+- 每项标注:通过 / 需改进 / 不合格
+- 不合格项标注P0并给出具体修改建议
+- 需改进项标注P1并给出调整方向
+- 不要输出长篇分析`
+
+const outputProtocolContent = `# 正文输出协议
+
+## 核心目标
+
+确保最终输出给用户的回复只包含纯净的章节正文,不混入任何过程信息、分析说明、Skill提示、检查结果或元信息。这个协议是"防火墙",它保护的是读者的阅读体验。
+
+## 适用范围
+
+本协议在所有写作模式下生效(快速/标准/严格),在所有章节写作场景下生效(新写/续写/改写/润色)。
+
+## 强制要求
+
+### 必须包含的内容
+1. **章节标题**:以"第X章"或用户指定的标题格式开头,独占一行
+2. **章节正文**:标题之后,纯正文内容,段落之间用空行分隔
+3. **正文中的对话**:保持对话格式,使用引号或其他用户指定的格式
+
+### 必须排除的内容(禁止清单)
+1. ❌ 分析过程:"首先,我分析了上一章的内容……"
+2. ❌ Skill使用说明:"根据章节承接Skill,我检查了……"
+3. ❌ 检查结果:"剧情自检通过,以下7项全部合格……"
+4. ❌ 元信息:"本章字数:约3500字 / 本章耗时:……"
+5. ❌ 协作口吻:"以下是为您生成的章节内容……"
+6. ❌ 代码块包装:不要用\`\`\`包裹正文
+7. ❌ 总结或说明:"本章主要讲述了……"
+8. ❌ 建议或提示:"下一章建议……"
+9. ❌ 任何Markdown格式标记(除非正文本身需要)
+10. ❌ 任何形式的"任务完成"通知
+
+## 正向输出模板
+
+正确的输出应该严格遵循以下格式:
+
+	(第一行)第X章 章节标题
+	(空一行)
+	正文第一段内容……
+	(空一行)
+	正文第二段内容……
+	(正文继续,段落之间空一行)
+
+## 边界情况处理
+
+### 情况1:用户要求看到分析过程
+如果用户明确说"写出你的分析过程",则输出分析过程,但分析过程与正文之间必须用明确的分隔标记区分(如"---"分隔线),并在正文之前标注"以下是正文:"。
+
+### 情况2:需要输出多条内容
+如果用户同时要求章节正文和其他内容,先输出正文,再输出其他内容,中间用分隔线。
+
+### 情况3:正文需要特殊格式
+如果正文本身包含列表、引用、信件等特殊格式,可以使用Markdown格式,但仅限于正文内容。
+
+## 自检清单
+
+在输出最终回复前,逐条确认:
+1. 回复的第一行是章节标题吗?
+2. 标题之后直接是正文,没有过渡语吗?
+3. 正文中没有出现"分析""检查""Skill""建议"等词吗?
+4. 回复的最后一行是正文内容,不是"完成"或"请查收"吗?
+5. 正文没有被包裹在代码块或其他标记中吗?
+6. 如果用户要求了分析,分析是否与正文明确分隔?
+
+如果以上任何一条不通过,修改回复直到全部通过。`
+
+const suspenseDesignContent = `# 悬念设计
+
+## 核心目标
+
+通过有策略地隐藏和释放信息,让读者持续产生"想知道接下来会发生什么"的迫切感,从而保持阅读动力。悬念设计不是故弄玄虚,而是通过精准的信息控制,让读者的好奇心与故事节奏同步起伏。
+
+## 适用场景
+
+- 章节计划阶段,设计本章的悬念结构
+- 正文写作中,需要制造紧张感或好奇心时
+- 发现章节"太平淡",需要增加钩子时
+- 长篇小说的中段,防止读者疲劳
+
+## 悬念的三个层级
+
+### 层级1:表层疑问
+读者一眼就能看到的问题,最直接的好奇心来源。
+- 示例:门后面是谁?那封信里写了什么?他为什么要这么做?
+- 特点:清晰、明确、有具体答案
+- 作用:快速抓住读者注意力,让读者想继续读下去找答案
+
+### 层级2:中层冲突
+表面问题背后的矛盾和张力,比表层疑问更持久。
+- 示例:A和B看似是朋友,但似乎各怀鬼胎 / 任务看起来简单,但处处透着不对劲
+- 特点:答案不唯一,可能有多种可能性
+- 作用:让读者开始猜测和推理,投入情感和智力
+
+### 层级3:深层秘密
+贯穿整个故事的核心谜团,最有力量的悬念。
+- 示例:主角的真实身份 / 世界的真相 / 某件过去事件的完整经过
+- 特点:揭示后会改变读者对整个故事的认知
+- 作用:支撑长篇故事的整体吸引力,让读者追更到底
+
+## 信息释放节奏
+
+### 藏三露一原则
+每揭示一个答案的同时,要埋下至少三个新的疑问。
+- 正确做法:回答了"A是谁"的同时,引出"A为什么来""A和B什么关系""A的目的是什么"
+- 错误做法:把所有答案一次性说完,读者没有继续读的动力
+- 关键:新疑问必须和刚揭示的答案相关,不能凭空出现
+
+### 悬念的加压与减压
+- 加压:不断抛出新线索,让谜团越来越复杂
+- 减压:适时给出部分答案,让读者获得满足感
+- 节奏:加压-减压-再加压-再减压,循环往复,整体趋势向上
+- 注意:减压不能减太多,否则悬念感消失;加压不能加太多,否则读者会放弃
+
+## 误导与红鲱鱼
+
+### 红鲱鱼的作用
+故意放出虚假线索,引导读者往错误方向猜测,从而让真相揭示时更有冲击力。
+- 示例:反复暗示A是凶手,最后发现是B
+- 关键:红鲱鱼必须看起来合理,不能太刻意
+
+### 误导的三个层次
+1. **角色误导**:让故事中的角色也被误导,读者自然跟着角色走
+2. **叙事误导**:通过叙述视角的局限性,选择性展示信息
+3. **结构误导**:通过章节安排、时间线跳跃等结构手段制造错觉
+
+### 误导的边界
+- 不能欺骗读者:读者回看时应该能发现线索,而不是觉得被耍了
+- 红鲱鱼要有解释:虚假线索最后要给出合理的解释
+- 数量控制:一条主线悬念配1-2条红鲱鱼即可,太多会混乱
+
+## 悬念回收时机
+
+### 小悬念回收
+- 表层疑问:1-3章内必须回收
+- 回收方式:在合适的时机自然揭示答案
+- 注意:如果小悬念拖太久,读者会忘记或失去兴趣
+
+### 中悬念回收
+- 中层冲突:5-10章内回收
+- 回收方式:通过一个小高潮或转折事件揭示
+- 注意:回收时要有一定的冲击力,不能太平淡
+
+### 大悬念回收
+- 深层秘密:贯穿大半本书,在关键节点部分揭示
+- 回收方式:分多次逐步揭示,每次揭示一部分,最后完整揭晓
+- 注意:大悬念不能一直吊着,每隔一段时间要给一点进展
+
+## 常见陷阱
+
+- **悬念过多**:一章塞了太多悬念,读者不知道该关心哪个。→ 一章聚焦1-2个核心悬念
+- **悬念过虚**:悬念太抽象,读者不知道到底在问什么。→ 悬念要具体,有明确的问题指向
+- **只埋不收**:只挖坑不填,读者觉得被骗。→ 每个悬念都要有回收计划
+- **回收太快**:悬念刚抛出来就解答,读者还没来得及好奇。→ 至少给读者几页的猜测时间
+- **回收太平**:悬念的答案很无聊,读者觉得"就这?"。→ 答案本身要有冲击力或引出新问题
+- **假悬念**:用"突然!""没想到!"制造的虚假紧张,下一页什么事都没有。→ 悬念必须有实质内容`
+
+const characterIntroContent = `# 人物出场
+
+## 核心目标
+
+让读者在人物第一次出场时就形成鲜明、准确的第一印象,同时为后续的人物发展留出空间。好的人物出场不是"介绍"人物,而是让人物在读者心中"活"起来。
+
+## 适用场景
+
+- 重要人物第一次出场时
+- 章节计划阶段,安排人物出场顺序和方式时
+- 感觉人物出场太平淡、读者记不住时
+- 需要重塑某个角色的形象时
+
+## 首次出场的信息密度
+
+### 不要一次说完
+人物第一次出场时,只展示最核心、最有辨识度的3-5个特征,其他信息留到后续慢慢揭示。
+- 第一次出场:外貌最显著的特征 + 一个标志性行为 + 一个性格侧面
+- 后续出场:逐步补充背景、动机、秘密、成长
+- 原因:信息太多读者记不住,慢慢揭示才有探索感
+
+### 第一印象三要素
+
+#### 要素1:外貌特征
+不要写完整的容貌描写,只写最有辨识度的1-2个特征。
+- 好的描写:"他左手总是插在口袋里,说话时右眉会微微挑起"
+- 坏的描写:"他身高一米八五,剑眉星目,鼻梁高挺,嘴唇薄厚适中"
+- 关键:特征要能反映性格,而不是单纯的容貌描述
+
+#### 要素2:行为方式
+人物出场时正在做什么?这个行为比外貌更能说明他是谁。
+- 示例:在嘈杂环境中安静看书 / 一进场就和所有人打招呼 / 躲在角落观察别人
+- 关键:行为要有独特性,能体现人物的核心特质
+
+#### 要素3:他人反应
+通过其他人对这个人物的态度,侧面塑造人物形象。
+- 示例:他一进来,原本热闹的房间安静了 / 所有人都主动和他打招呼 / 有人看到他就紧张
+- 关键:他人反应比直接描写更有说服力,因为读者会自己推导
+
+## 出场方式分类
+
+### 类型1:动作出场
+人物一出场就在做某件事,通过动作展现性格。
+- 适用:行动派、有特殊技能的人物
+- 优势:直观、有冲击力,快速建立人物印象
+- 注意:动作要和人物身份、性格一致
+
+### 类型2:对话出场
+人物还没露面,先听到他的声音或对话。
+- 适用:语言有特色、地位特殊的人物
+- 优势:制造期待感,让读者先好奇"这是谁"
+- 注意:对话要能体现人物的说话风格和身份
+
+### 类型3:侧面描写
+通过其他人物的谈论、回忆、反应来介绍这个人物。
+- 适用:神秘感强、地位重要的人物
+- 优势:制造悬念,让读者对人物产生好奇
+- 注意:侧面描写后要尽快让人物正式出场,不能吊太久
+
+### 类型4:环境烘托
+先描写人物所处的环境或拥有的物品,再引出人物本身。
+- 适用:环境能反映人物特质的角色
+- 优势:有氛围感,让读者通过环境推测人物
+- 注意:环境描写要服务于人物塑造,不能为了写景而写景
+
+## 重要人物的二次补全
+
+第一次出场只给核心印象,在后续的2-3次出场中逐步补全人物。
+
+### 补全内容
+- 背景信息:他的过去、他的身份、他和其他人物的关系
+- 内在动机:他想要什么?他害怕什么?他相信什么?
+- 反差面:展示他与第一印象不同的一面,让人物更立体
+
+### 补全节奏
+- 第二次出场:补充一个背景信息 + 一个性格侧面
+- 第三次出场:揭示一个动机或秘密
+- 注意:补全要自然融入剧情,不能像填表格一样
+
+## 常见错误
+
+- **信息倾倒**:第一次出场就把人物的年龄、身高、体重、家世、性格、爱好全说了。→ 只说最核心的,其他留到后面
+- **脸谱化**:好人就是"英俊潇洒",坏人就是"贼眉鼠眼"。→ 给人物一些反套路的特征
+- **没有记忆点**:人物出场后读者很快就忘了。→ 找一个独特的标志(动作、口头禅、习惯)
+- **出场太平**:人物随随便便就出场了,没有任何存在感。→ 给重要人物的出场设计一个"亮相时刻"
+- **全靠旁白说**:"他是一个很勇敢的人"。→ 用行为和事件来证明,不要直接告诉读者
+- **所有人出场方式一样**:每个新人物都是"XX走了进来"。→ 不同性格的人物用不同的出场方式`
+
+const worldbuildingImplantContent = `# 世界观植入
+
+## 核心目标
+
+将世界观设定自然地融入故事中,让读者在不知不觉中理解并接受这个世界的规则,同时不影响叙事节奏。好的世界观植入不是"介绍设定",而是让读者"体验设定"。
+
+## 适用场景
+
+- 世界观设定比较复杂的作品(奇幻、科幻、玄幻等)
+- 章节计划阶段,安排设定展示的时机和方式
+- 发现有"信息倾倒"问题时
+- 读者反馈"看不懂设定"或"设定太复杂"时
+
+## 设定展示的三个原则
+
+### 原则1:服务剧情
+设定的展示必须服务于当前的剧情,而不是反过来。
+- 正确:剧情需要用到某个设定时,才解释这个设定
+- 错误:为了展示设定而专门安排一段剧情
+- 检验:如果删掉这段设定介绍,剧情还成立吗?如果成立,就删掉或推后
+
+### 原则2:自然融入
+设定要通过角色的行为、对话、环境自然地展现出来,而不是由作者直接讲解。
+- 正确:角色遇到一个问题,用世界观的规则去解决
+- 错误:作者跳出来说"在这个世界里,规则是这样的……"
+- 关键:读者不需要知道全部设定,只需要知道当前剧情需要的部分
+
+### 原则3:按需释放
+读者需要知道什么,就释放什么;什么时候需要,什么时候释放。
+- 前期:只释放理解当前剧情所必需的设定
+- 中期:随着剧情展开,逐步增加设定深度
+- 后期:揭示核心设定,改变读者对世界的认知
+
+## 世界观植入的五种方式
+
+### 方式1:对话提及
+角色在对话中自然地提到某些设定。
+- 示例:"你忘了吗?在这里,夜晚是不能出门的。"
+- 优势:自然,不像在上课
+- 注意:对话要符合人物身份和情境,不能像教科书问答
+
+### 方式2:环境描写
+通过描写角色所处的环境,展示世界观的特色。
+- 示例:街道两旁悬浮着发光的晶石,行人脚下踩着无声的滑板
+- 优势:有画面感,读者可以自己推导
+- 注意:不要堆太多陌生名词,一次1-2个新概念即可
+
+### 方式3:角色行为
+通过角色的日常行为、习惯、仪式,展现世界观的规则。
+- 示例:进门之前要先摸一下门框上的符印 / 吃饭前要默念一段祷词
+- 优势:直观,有生活气息
+- 注意:行为要有意义,不是为了炫设定而做
+
+### 方式4:冲突触发
+因为某个设定的存在,引发了冲突或问题。
+- 示例:因为修炼体系的限制,主角无法使用某种力量
+- 优势:设定和剧情紧密结合,读者印象深刻
+- 注意:冲突要和设定有因果关系,不是硬凑
+
+### 方式5:背景故事
+通过角色的回忆、传说、历史事件,展示世界观的深层设定。
+- 示例:老一辈人讲起百年前的那场大战
+- 优势:有故事性,不枯燥
+- 注意:背景故事要和当前剧情有关联,不能凭空插入
+
+## 避免信息倾倒的方法
+
+### 什么是信息倾倒
+大段大段地解释世界观设定,让故事停下来,读者像在读百科全书。
+
+### 避免方法
+1. **切片法**:把大设定切成小块,分散到不同章节、不同场景中
+2. **冲突法**:让设定和冲突绑定,遇到冲突时才解释相关设定
+3. **人物法**:让一个不懂设定的角色(如外乡人、穿越者)提问,另一个角色解答
+4. **对比法**:通过与读者熟悉的事物对比,快速建立概念
+5. **展示法**:不要说"这个世界有魔法",而是让角色直接使用魔法
+
+## 设定密度控制
+
+### 密度标准
+- 开篇前3章:每章最多引入2-3个新设定概念
+- 中期:每章最多引入1-2个新设定概念
+- 高潮期:尽量不引入新设定,专注于使用已有的设定
+
+### 过载信号
+如果出现以下情况,说明设定密度太高了:
+- 读者需要频繁翻回去看之前的设定
+- 读者记不住各种名词和规则
+- 评论区有人问"这是什么意思"
+- 你自己都需要记笔记才能理清
+
+## 常见陷阱
+
+- **开篇大设定**:第一章就用好几页讲世界历史、地理、种族分布。→ 开篇只给最必要的设定,其他慢慢加
+- **名词轰炸**:一段里出现七八个陌生的专有名词。→ 一次只给1-2个,并且给足上下文让读者能猜意思
+- **设定大于故事**:为了展示酷炫的设定而牺牲剧情和人物。→ 设定永远是背景板,故事和人物才是核心
+- **前后矛盾**:前面说设定是A,后面又变成了B。→ 建立设定文档,保持一致性
+- **解释过度**:读者已经能看懂了,还在反复解释。→ 相信读者的理解能力,点到为止
+- **设定无用**:花了很多笔墨介绍的设定,对剧情完全没用。→ 没用的设定就删掉,或留到需要时再介绍`
+
+const sceneDescriptionContent = `# 场景描写
+
+## 核心目标
+
+通过文字构建出有画面感、有氛围、有功能的场景,让读者仿佛身临其境,同时服务于剧情推进和人物塑造。好的场景描写不是"写景",而是用场景来说话。
+
+## 适用场景
+
+- 进入一个新场景时
+- 章节中需要转换氛围时
+- 感觉场景描写太单薄或太冗余时
+- 需要通过环境烘托情绪时
+
+## 场景的功能定位
+
+写场景之前,先想清楚:这个场景存在的意义是什么?至少要满足以下功能中的1-2个。
+
+### 功能1:推进剧情
+场景中的某个元素会触发或影响剧情发展。
+- 示例:破败的寺庙里藏着线索 / 狭窄的小巷让追逐战更惊险
+- 检验:如果换一个场景,这段剧情还成立吗?如果成立,说明场景没起到作用
+
+### 功能2:塑造人物
+场景反映了人物的身份、性格、状态。
+- 示例:整洁到强迫症的房间 / 乱中有序的工作台 / 墙上贴满照片的调查员房间
+- 关键:场景细节要能体现人物特质,不是随便堆东西
+
+### 功能3:营造氛围
+场景的色调、光线、声音决定了读者的情绪基调。
+- 示例:阴雨天的老宅 = 悬疑恐怖 / 阳光下的草坪 = 轻松愉快
+- 注意:氛围要和剧情的情绪走向匹配
+
+### 功能4:传递信息
+场景中的细节隐含了世界观、背景故事或伏笔。
+- 示例:墙上褪色的老照片暗示过去的辉煌 / 桌上的药瓶暗示人物的健康问题
+- 关键:信息要藏在细节里,不是直白地说出来
+
+## 感官层次描写法
+
+不要只写眼睛看到的,调动读者的全部感官。
+
+### 视觉:画面的骨架
+- 光线:明亮/昏暗/柔和/刺眼
+- 色彩:主色调是什么?有没有突出的颜色?
+- 空间:开阔/狭窄/高耸/压抑
+- 动态:有没有在动的东西?(风、人影、光影变化)
+- 注意:不要面面俱到,只写最有特点的3-5个视觉元素
+
+### 听觉:画面的呼吸
+- 环境音:风声、雨声、远处的人声
+- 人物的声音:脚步声、呼吸声、衣服摩擦声
+- 寂静:有时候"没有声音"比声音更有张力
+- 注意:听觉描写要和视觉配合,增强沉浸感
+
+### 嗅觉:画面的温度
+- 自然气味:花香、泥土、海水
+- 生活气味:饭菜香、烟草味、书的油墨味
+- 特殊气味:血腥味、消毒水、腐朽的气息
+- 注意:嗅觉最容易触发情绪,但不要用太多,偶尔用效果最好
+
+### 触觉:画面的质感
+- 温度:冷、暖、热、凉
+- 质感:光滑、粗糙、柔软、坚硬
+- 体感:风吹在脸上、脚下的地面触感
+- 注意:触觉描写通常和人物动作结合,不要单独写
+
+### 味觉:画面的余韵
+- 适用场景:吃饭、喝水、尝到什么东西的时候
+- 作用:增强真实感,或通过味道联想其他事物
+- 注意:味觉用得最少,只有在需要的时候才用
+
+## 场景描写的节奏控制
+
+### 开篇场景:快切入
+场景刚出现时,先用1-2句话抓住最核心的特征,让读者快速建立印象。然后再慢慢补充细节。
+- 错误:一上来就写三百字的场景描写,读者还没进入状态
+- 正确:先给一个整体印象,再随着剧情推进逐步展示细节
+
+### 剧情中场景:点到为止
+剧情发展过程中,场景描写要精简,不要打断叙事节奏。
+- 用动作带场景:人物走动时自然看到周围的环境
+- 用对话带场景:人物谈论到环境中的某个东西
+- 原则:场景描写不能让剧情停下来
+
+### 高潮场景:强化感官
+冲突最激烈的时候,可以适当增加场景描写的密度,用环境的变化烘托紧张感。
+- 示例:战斗中环境被破坏、天气变化、光线变化
+- 作用:让高潮更有画面感和冲击力
+
+## 与人物视角的统一
+
+场景描写必须从当前视角人物的眼睛里看出去,带上他的主观感受。
+
+### 视角影响场景
+- 心情好的人:看什么都觉得美好
+- 紧张的人:对声音和动静特别敏感
+- 熟悉环境的人:注意到细微的变化
+- 陌生环境的人:注意到最显眼的东西
+
+### 避免上帝视角
+不要写视角人物不可能知道或看到的东西。
+- 错误:"他不知道,墙后面正有人盯着他"(如果是第三人称有限视角)
+- 正确:通过一些迹象让人物(和读者)感觉到不对劲
+
+## 常见陷阱
+
+- **流水账式写景**:从门口写到窗户,从左边写到右边,像在报家具清单。→ 只写最有特点、和剧情/人物相关的细节
+- **场景与人物脱节**:场景写得很美,但和人物、剧情完全没关系。→ 场景要服务于人物和剧情
+- **形容词堆砌**:"美丽的""漂亮的""壮观的""宏伟的"一堆形容词。→ 用具体的细节代替抽象的形容词
+- **全知视角混乱**:一会儿写人物看到的,一会儿写读者知道但人物不知道的。→ 保持视角统一
+- **描写过长**:一大段全是场景描写,剧情完全停住。→ 把长描写拆成小段,穿插在动作和对话中
+- **所有场景一个样**:每个房间都是"一张桌子、几把椅子"。→ 给每个场景一个独特的标志或氛围`
+
+const dialogueDesignContent = `# 对话设计
+
+## 核心目标
+
+让对话既符合人物身份和性格,又能推动剧情、传递信息、制造张力,同时具有潜台词和节奏感。好的对话不是"说话",而是人物在语言中博弈。
+
+## 适用场景
+
+- 章节计划阶段,设计重要对话场景时
+- 写对话的时候,感觉对话太平淡、太直白时
+- 人物对话听起来都像一个人时
+- 需要通过对话制造冲突或悬念时
+
+## 对话的四个功能
+
+好的对话应该同时满足以下功能中的至少2个。
+
+### 功能1:推进剧情
+对话导致了某些事情的发生或改变。
+- 示例:达成一个协议 / 透露一个关键信息 / 做出一个决定
+- 检验:如果删掉这段对话,剧情还能继续吗?如果能,说明这段对话没用
+
+### 功能2:展现人物
+对话能体现人物的性格、身份、背景和情绪。
+- 示例:说话的用词、语气、节奏、习惯用语
+- 关键:不同的人要说不同的话,不能所有人都是一个腔调
+
+### 功能3:制造冲突
+对话中的分歧、矛盾、交锋产生张力。
+- 示例:争论 / 谈判 / 试探 / 隐瞒
+- 注意:有冲突的对话才好看,一团和气的对话最无聊
+
+### 功能4:传递信息
+通过对话告诉读者一些他们需要知道的事情。
+- 示例:背景信息 / 设定解释 / 人物关系
+- 注意:信息传递要自然,不能像在念说明书
+
+## 潜台词设计
+
+潜台词是人物没说出来的话,是对话的灵魂。
+
+### 层次1:说出来的
+字面上的意思,最表层的信息。
+- 示例:"这杯茶有点凉了。"
+- 作用:建立对话的表面内容
+
+### 层次2:没说的
+人物想说但没有直接说出来的话。
+- 示例:(我不想再待在这里了 / 你该走了)
+- 作用:让读者去猜,增加对话的深度
+- 关键:通过语气、停顿、动作、上下文暗示
+
+### 层次3:反着说的
+嘴上说的和心里想的完全相反。
+- 示例:"我没事。"(我现在很不好)
+- 作用:增加人物的复杂度和真实感
+- 注意:反话要有足够的线索让读者能察觉,不然读者就真信了
+
+### 潜台词的使用技巧
+1. **话不说满**:不要让人物把心里想的全说出来
+2. **答非所问**:不正面回答问题,转移话题,说明有隐情
+3. **重复强调**:反复说某件事,可能是在掩饰相反的事
+4. **语气变化**:同样的话,不同的语气有不同的意思
+5. **动作配合**:说话时的小动作、表情,补充潜台词
+
+## 对话节奏
+
+对话的节奏由句子长度、停顿、打断等元素控制。
+
+### 快节奏对话
+- 句子短,每句只有几个字
+- 你一言我一语,交替很快
+- 几乎没有动作和神态描写
+- 适用场景:争吵、对峙、紧急情况
+
+### 中节奏对话
+- 句子长度适中
+- 有适当的停顿和反应
+- 穿插少量动作和神态描写
+- 适用场景:日常交流、讨论问题
+
+### 慢节奏对话
+- 句子长,甚至有大段独白
+- 有很多停顿、犹豫、思考
+- 大量动作、神态、心理描写
+- 适用场景:告白、坦白、重要决定
+
+### 节奏控制技巧
+- **打断**:一个人没说完另一个人就插嘴,制造紧张感
+- **停顿**:沉默、犹豫,增加重量感
+- **抢话**:同时说话,表现混乱或激烈
+- **沉默**:有时候不说话比说话更有力量
+- **语速变化**:越说越快 = 越来越激动;越说越慢 = 越来越沉重
+
+## 信息差与信息释放
+
+对话中的信息不对称是张力的重要来源。
+
+### 信息差的类型
+1. **说话人知道,听话人不知道**:一方在试探或隐瞒
+2. **听话人知道,说话人不知道**:读者为说话人着急
+3. **读者知道,当事人不知道**:戏剧性反讽
+4. **双方都不知道**:一起探索,共同发现
+
+### 信息释放节奏
+- 不要一次性把所有信息都说完
+- 每次对话释放一点信息,留下新的疑问
+- 重要信息放在对话的后半段,甚至最后一句
+- 关键信息要用特殊的方式强调(停顿、重复、打断)
+
+## 常见陷阱
+
+- **信息倾倒**:对话像在念说明书,大段大段讲设定或背景。→ 把信息拆开,分散到不同对话中,用冲突包裹
+- **所有人一个腔调**:不管什么身份、什么性格的人,说话都一样。→ 给每个人物设计独特的说话方式(用词、语气、口头禅)
+- **对话太礼貌**:你好我好大家好,没有任何冲突。→ 给对话加入分歧、试探、隐瞒
+- **潜台词过度**:每句话都有深层含义,读者看得很累。→ 大部分对话可以直白,关键对话才用潜台词
+- **缺少动作**:对话从头到尾都是你说我说,像两个木头人。→ 穿插动作、神态、环境描写
+- **废话太多**:打招呼、寒暄、日常客套占了大半篇幅。→ 跳过不重要的寒暄,直接进入正题
+- **现代语穿越**:古代背景的人物说"给力""神马"等现代词。→ 保持语言风格和时代背景一致`
+
+const sceneTransitionContent = `# 转场过渡
+
+## 核心目标
+
+让场景之间的切换自然流畅,既不打断读者的阅读节奏,又能清晰地传达时间、地点、视角的变化。好的转场不是"切换场景",而是带着读者一起移动。
+
+## 适用场景
+
+- 章节内需要切换场景时
+- 时间跳跃或地点变化时
+- 视角人物切换时
+- 感觉场景切换太生硬、太突兀时
+
+## 转场的五种类型
+
+### 类型1:时间转场
+从一个时间点跳到另一个时间点。
+- 短跳跃:几小时后、当天晚上、第二天一早
+- 长跳跃:一周后、一个月后、几年后
+- 技巧:用标志性事件(日出、日落、钟声、季节变化)暗示时间流逝
+- 注意:时间跳跃后,要简单交代这段时间发生了什么变化
+
+### 类型2:地点转场
+从一个地方换到另一个地方。
+- 近距离:从客厅到卧室、从街上到店里
+- 远距离:从一个城市到另一个城市、从人间到仙界
+- 技巧:用交通工具、行走过程、视线移动来过渡
+- 注意:到新场景后,先给一个整体印象,再进入具体情节
+
+### 类型3:视角转场
+从一个人物的视角切换到另一个人物的视角。
+- 同场景换视角:同一个场景,从A的视角换到B的视角
+- 不同场景换视角:A在做X,切到B在做Y
+- 技巧:找到两个视角的连接点(同一件事、同一个物品、同一句话)
+- 注意:视角切换不能太频繁,一章内尽量不超过2-3次
+
+### 类型4:情绪转场
+通过情绪的延续或对比来过渡场景。
+- 情绪延续:上一场景的情绪带到下一个场景
+- 情绪对比:上一场景很热闹,下一场景很安静,形成反差
+- 技巧:用一个有象征意义的画面或动作承接情绪
+- 注意:情绪转场要有内在逻辑,不能为了文艺而文艺
+
+### 类型5:蒙太奇转场
+快速切换多个场景,展示时间流逝或多条线索并行。
+- 适用:展示一段时间内发生的很多事、多条线索同时推进
+- 技巧:找到一个共同的线索(声音、动作、主题)把多个场景串起来
+- 注意:蒙太奇不能太长,3-5个镜头切换就够了,太多读者会晕
+
+## 自然衔接技巧
+
+### 技巧1:相似物衔接
+用两个场景中都有的相似物品或画面来过渡。
+- 示例:上一场景最后是桌上的一杯咖啡 → 下一场景开头是另一杯咖啡
+- 作用:视觉上的连续感,让切换不生硬
+
+### 技巧2:声音衔接
+用声音把两个场景连起来。
+- 示例:上一场景最后有人敲门 → 下一场景开头是门开了
+- 作用:听觉上的连续感,自然引导读者进入下一个场景
+
+### 技巧3:动作衔接
+用一个动作的延续来过渡。
+- 示例:上一场景最后人物站起身 → 下一场景开头人物走在路上
+- 作用:动作上的连续感,让读者感觉是跟着人物走的
+
+### 技巧4:对话衔接
+用一句话从一个场景带到另一个场景。
+- 示例:上一场景最后A说"我们去看看吧" → 下一场景开头两人已经到了目的地
+- 作用:语言上的连续感,直接进入下一个情节
+
+### 技巧5:主题衔接
+用共同的主题或意象来过渡。
+- 示例:上一场景在说"背叛" → 下一场景开头就是另一个背叛的场景
+- 作用:意义上的连续感,增强作品的整体性
+
+## 转场节奏控制
+
+### 快节奏转场:硬切
+直接切换,没有任何过渡。
+- 适用:紧张的追逐戏、平行剪辑、快节奏剧情
+- 效果:干脆利落,增加紧张感
+- 注意:不能硬切太多,否则读者会混乱
+
+### 中节奏转场:轻过渡
+用一句话或一个小细节过渡。
+- 适用:大多数日常场景切换
+- 效果:自然流畅,不突兀
+- 注意:过渡要短,不要写太长
+
+### 慢节奏转场:重过渡
+用一整个段落甚至更长的篇幅来过渡。
+- 适用:大的时间跳跃、重要的场景转换、情绪变化剧烈时
+- 效果:有仪式感,给读者心理准备
+- 注意:不要每一次转场都重过渡,会显得拖沓
+
+## 硬切与淡入淡出
+
+### 硬切
+- 特点:直接切换,没有过渡
+- 优点:节奏快,有冲击力
+- 缺点:太频繁会让读者混乱
+- 适用:动作戏、平行蒙太奇、场景切换频繁时
+
+### 淡入淡出
+- 特点:上一个场景慢慢结束,下一个场景慢慢开始
+- 优点:优雅、有氛围感
+- 缺点:节奏慢,用多了拖沓
+- 适用:大的时间跳跃、情绪转换、章节首尾
+
+### 混合使用
+- 大多数转场用轻过渡(介于硬切和淡入淡出之间)
+- 紧张时刻用硬切
+- 重要转折用淡入淡出
+- 有变化才有节奏感
+
+## 常见陷阱
+
+- **转场太生硬**:上一段还在A地对话,下一段直接跳到B地战斗,读者反应不过来。→ 加一句过渡,或用衔接技巧
+- **过渡太冗长**:切换一个场景写了好几百字的过渡,剧情完全停住。→ 过渡要短,点到为止
+- **视角混乱**:频繁切换视角,读者不知道现在在跟谁。→ 一章内视角切换不超过2-3次,每次切换都要明确
+- **时间线混乱**:转场后读者搞不清过了多久。→ 明确交代时间,或用环境变化暗示
+- **为了转场而转场**:没必要切换的场景也切来切去。→ 能在一个场景里说完的,就不要换场景
+- **所有转场一个样**:每次都是"第二天,……"。→ 变化转场方式,增加阅读趣味`
+
+const emotionRenderingContent = `# 情绪渲染
+
+## 核心目标
+
+通过文字调动读者的情绪,让读者与人物产生共情,从而更深地投入到故事中。好的情绪渲染不是"写情绪",而是让读者自己感受到情绪。
+
+## 适用场景
+
+- 人物经历强烈情绪时(喜悦、悲伤、愤怒、恐惧等)
+- 需要营造特定氛围时
+- 感觉情绪表达太单薄、太直白时
+- 读者反馈"没有代入感""看不懂人物在想什么"时
+
+## 情绪外化的四种方式
+
+不要直接写"他很开心""她很伤心",而是通过以下方式让情绪自然流露。
+
+### 方式1:动作外化
+情绪通过人物的行为、动作、肢体语言表现出来。
+- 开心:脚步轻快、不自觉地微笑、做什么都带劲
+- 悲伤:动作迟缓、不想动、对什么都没兴趣
+- 愤怒:握拳、咬牙、呼吸变粗、想砸东西
+- 紧张:坐立不安、手心出汗、不停地做某个小动作
+- 关键:动作要符合人物性格,内向的人和外向的人表达方式不同
+
+### 方式2:环境外化
+情绪投射到周围的环境上,用环境烘托情绪。
+- 开心:阳光明媚、鸟语花香、连风都是温柔的
+- 悲伤:阴雨连绵、灰暗的天空、空旷的房间
+- 紧张:安静得可怕、时钟滴答声格外清晰、影子拉得很长
+- 愤怒:电闪雷鸣、狂风暴雨、东西摔碎的声音
+- 注意:环境描写要从人物视角出发,带上主观色彩
+
+### 方式3:对话外化
+情绪通过人物的说话方式、语气、内容表现出来。
+- 开心:话变多、语速变快、喜欢开玩笑
+- 悲伤:话少、声音低沉、答非所问
+- 愤怒:声音变大、语速变快、说狠话、打断别人
+- 紧张:说话结巴、重复、声音发抖、转移话题
+- 关键:潜台词比直白的情绪表达更有力量
+
+### 方式4:内心独白外化
+通过人物的内心活动展现情绪,但不是直接说情绪。
+- 开心:脑子里在想各种美好的事情、对未来充满期待
+- 悲伤:反复回想某件事、自责、觉得一切都没有意义
+- 愤怒:在心里骂、脑补各种报复的画面
+- 紧张:胡思乱想、往最坏的地方想、担心各种可能出错的事
+- 注意:内心独白不能太长,点到为止,否则会拖沓
+
+## 情绪节奏曲线
+
+好的情绪表达不是一条直线,而是有起伏的曲线。
+
+### 情绪的积累
+情绪不是突然爆发的,要有一个积累的过程。
+- 一点点小事累加
+- 压力慢慢增加
+- 伏笔一点点埋下
+- 最后被一根稻草压垮
+
+### 情绪的爆发
+积累到一定程度后,情绪总爆发。
+- 爆发点要选在最有冲击力的时刻
+- 爆发的方式要符合人物性格
+- 爆发要有后果,不能爆发完就没事了
+
+### 情绪的回落
+爆发之后,情绪不会立刻消失,而是慢慢回落。
+- 余波:爆发后的疲惫、空虚、后悔
+- 反思:冷静下来后重新思考
+- 变化:经历这次情绪波动后,人物有什么变化
+
+### 节奏建议
+- 不要让人物一直处于高强度情绪中,读者会疲劳
+- 紧张之后要有放松,悲伤之后要有温暖
+- 情绪的起伏要和剧情的起伏配合
+- 一章内至少要有一次明显的情绪波动
+
+## 读者共情的触发点
+
+什么情况下读者最容易和人物共情?
+
+### 触发点1:脆弱时刻
+人物展现出脆弱的一面时,读者最容易心疼。
+- 再坚强的人也有软肋
+- 平时不哭的人落泪
+- 天不怕地不怕的人有恐惧的东西
+- 关键:脆弱不能太多,偶尔一次才珍贵
+
+### 触发点2:努力时刻
+人物在为了某个目标努力奋斗时,读者会为他加油。
+- 明明很艰难但还是在坚持
+- 明明很害怕但还是鼓起勇气
+- 明明受了伤但还是站起来
+- 关键:努力要有结果,或者虽然失败但有尊严
+
+### 触发点3:失去时刻
+人物失去重要的东西时,读者会跟着难过。
+- 失去亲人、朋友、爱人
+- 失去梦想、信念、希望
+- 失去一直以来守护的东西
+- 关键:失去的东西必须是读者也觉得珍贵的
+
+### 触发点4:成长时刻
+人物克服困难、获得成长时,读者会感到欣慰。
+- 从懦弱变勇敢
+- 从自私变无私
+- 从迷茫变坚定
+- 关键:成长要有过程,不能一下子就变了
+
+## 情绪节制与克制
+
+### 为什么要节制
+- 情绪太满会溢出来,反而没有力量
+- 哭天抢地不如一滴眼泪有冲击力
+- 说出来的悲伤不如没说出来的沉重
+- 留白比填满更有想象空间
+
+### 节制的技巧
+1. **声东击西**:写悲伤不写哭,写手在抖、茶凉了忘了喝
+2. **以乐衬悲**:用热闹的场景反衬人物的孤独
+3. **欲扬先抑**:情绪到了顶点反而平静下来
+4. **留白**:最强烈的情绪不写出来,让读者自己感受
+5. **细节代替**:用一个具体的细节代替大段的情绪描写
+
+### 常见的过度表达
+- "他感到无比的愤怒,心中燃起了熊熊烈火" → 太直白太夸张
+- "她伤心欲绝,哭得死去活来,天昏地暗" → 太用力反而假
+- "这是他一生中最幸福的时刻,他觉得全世界都在发光" → 太满了
+
+## 常见陷阱
+
+- **直接说情绪**:"他很悲伤""她非常开心"。→ 用动作、环境、对话、细节来展现
+- **情绪夸张**:一点小事就"崩溃""绝望""生不如死"。→ 情绪要有分量,不能随便用
+- **情绪不变**:人物从头到尾都是一个情绪。→ 情绪要有变化,有起伏
+- **情绪不合理**:刚发生了悲伤的事,下一秒就开心起来。→ 情绪要有延续性,不会突然消失
+- **情绪泛滥**:每一页都在哭、都在笑、都在生气。→ 物以稀为贵,少而精才有力量
+- **只有情绪没有行动**:大段大段写内心感受,人物什么都不做。→ 情绪要通过行动体现,不能光想不做
+- **读者共情失败**:作者写得很投入,但读者没感觉。→ 找到共通的情感体验,让读者能代入`
+
+const quotableLinesContent = `# 金句设计
+
+## 核心目标
+
+创作出生动、深刻、让人印象深刻的句子,成为作品的记忆点,甚至被读者记住和传播。好的金句不是"为了金句而金句",而是从故事和人物中自然生长出来的。
+
+## 适用场景
+
+- 章节的开头或结尾
+- 人物的重要时刻(告白、决裂、顿悟、牺牲)
+- 主题需要被点出来的时候
+- 希望给读者留下深刻印象的时候
+
+## 金句的作用
+
+### 作用1:点题
+用一句话点出本章或整部作品的主题。
+- 示例:"有些路,注定要一个人走完。"
+- 位置:章节末尾、故事结尾
+- 注意:点题要自然,不能像在写读后感
+
+### 作用2:塑造人物
+用一句话让人物形象瞬间立起来。
+- 示例:"我命由我不由天。"
+- 位置:人物的关键台词、内心独白
+- 注意:这句话必须符合人物性格,不能什么人都说一样的话
+
+### 作用3:制造记忆点
+让读者看完之后还能想起这句话。
+- 示例:"真相只有一个。"
+- 位置:关键情节处、反复出现的台词
+- 注意:不能太刻意,要和剧情结合
+
+### 作用4:升华主题
+把具体的故事提升到更普世的层面。
+- 示例:"真正的英雄主义,是在认清生活的真相后依然热爱生活。"
+- 位置:故事结尾、人物顿悟时刻
+- 注意:升华要合理,不能凭空拔高
+
+## 金句的四种类型
+
+### 类型1:哲理型
+蕴含某种道理或智慧的句子。
+- 特点:有思辨性,让人回味
+- 示例:"没有绝对的正义,只有不同的立场。"
+- 适用:思考、顿悟、讨论的场景
+- 注意:不要太说教,要从故事中自然产生
+
+### 类型2:人物型
+专属于某个角色的标志性台词。
+- 特点:和人物绑定,听到这句话就想到这个人
+- 示例:"我一定会回来的!"
+- 适用:人物的口头禅、关键时刻的宣言
+- 注意:要符合人物身份和性格,不能违和
+
+### 类型3:情节型
+和某个关键情节绑定的句子。
+- 特点:和剧情紧密相关,单独看可能没什么,但在特定情境下很有冲击力
+- 示例:"我到现在都还记得,那天的雨下得很大。"
+- 适用:重要事件的开头或结尾
+- 注意:要有上下文的铺垫,不能凭空出现
+
+### 类型4:主题型
+直接或间接地表达作品主题的句子。
+- 特点:概括性强,能代表整部作品的精神内核
+- 示例:"希望是个好东西,也许是最好的东西。"
+- 适用:故事开头、结尾、关键转折点
+- 注意:不要太直白,最好有隐喻或象征
+
+## 出现时机
+
+### 时机1:章节开头
+用一句有分量的话开启本章。
+- 作用:定调,引发读者好奇
+- 注意:不要太长,一句话就够了
+
+### 时机2:情节高潮
+在最紧张、最关键的时刻抛出金句。
+- 作用:增强冲击力,让读者印象深刻
+- 注意:金句要短,要有力,不能太长
+
+### 时机3:人物顿悟
+人物想通了某件事、发生了转变的时候。
+- 作用:标记人物的成长和变化
+- 注意:要符合人物的心路历程,不能太突兀
+
+### 时机4:章节结尾
+用一句有韵味的话结束本章。
+- 作用:余音绕梁,让读者回味
+- 注意:留有余味,不要说太满
+
+### 时机5:故事结尾
+用一句话总结整个故事。
+- 作用:升华主题,留下长久的印象
+- 注意:要能概括整个故事的精神,不能太片面
+
+## 节制原则:宁少勿滥
+
+### 为什么要节制
+- 金句太多就等于没有金句
+- 每一句都"很有道理",读者一句都记不住
+- 太刻意的金句会让读者觉得尴尬
+- 好的金句是锦上添花,不是喧宾夺主
+
+### 节制的标准
+- 一章之内,有意识设计的金句不超过2-3句
+- 不是每一章都必须有金句
+- 金句要配得上它出现的时刻
+- 如果删掉这句话,剧情还成立,人物还成立,那这句话可能就是多余的
+
+### 避免的问题
+- **为了金句而金句**:硬塞一些"很有道理"的话,和人物剧情没关系。→ 金句要从故事里长出来
+- **人人都是哲学家**:不管什么身份的人都能说出很深奥的话。→ 符合人物身份和文化水平
+- **金句太长**:一段话全是金句,读者不知道该记哪句。→ 短一点,有力一点
+- **重复使用**:同一句金句反复说,读者会烦。→ 关键时候说一次就够了
+- **太网络用语**:用太多网络流行语,很快就过时了。→ 追求更持久的表达
+
+## 常见陷阱
+
+- **说教味太重**:像在给读者上课,大道理一堆。→ 把道理藏在故事里,让读者自己体会
+- **不符合人物**:一个大字不识的角色说出很有文采的话。→ 什么人说什么话
+- **太矫情**:为了"有感觉"故意写得很文艺、很忧伤。→ 真诚比华丽更有力量
+- **太鸡汤**:都是"努力就会成功""加油你最棒"这种正确的废话。→ 要有具体的情境和质感
+- **太晦涩**:写得很深奥,但读者看不懂想说什么。→ 深刻不等于难懂
+- **脱离剧情**:金句和当前的情节、人物没什么关系。→ 金句要为剧情和人物服务
+- **强行升华**:故事本身撑不起来,硬要在结尾加一句"升华"的话。→ 先把故事写好,金句自然会来`
 
 export const DEFAULT_BUILTIN_WRITING_SKILLS: UserSkill[] = [
   normalizeUserSkill({
     id: "builtin:chapter-connection",
     name: "章节承接",
-    description: "检查上一章结尾、人物状态、未解决事件和必须承接的情绪。",
+    description: "检查上一章结尾、人物状态、未解决事件和必须承接的情绪,提供承接强度分级和补强方法。",
     kind: ["structure"],
     stages: ["planning"],
     modes: ["standard", "strict"],
-    content: ["检查上一章结尾状态、人物位置与情绪、未解决事件。","确保本章承接前文的情绪基调和故事张力。","若发现重大承接缺口,在计划中标注。"].join("\\n"),
+    content: chapterConnectionContent,
     source: "built-in",
+    priority: 25,
+    tags: ["衔接", "承接"],
   }),
   normalizeUserSkill({
     id: "builtin:next-chapter-plan",
     name: "下一章计划",
-    description: "生成本章目标、冲突、人物、关键事件、伏笔安排和结尾钩子。",
+    description: "生成包含目标、冲突、人物、事件、情绪、伏笔、钩子的完整章节蓝图。",
     kind: ["planning"],
     stages: ["planning"],
     modes: ["standard", "strict"],
-    content: ["生成包含以下要素的章节计划:","1. 本章目标","2. 核心冲突","3. 出场人物与动机","4. 关键事件顺序","5. 情绪曲线","6. 伏笔推进","7. 结尾钩子","8. 预计字数"].join("\\n"),
+    content: nextChapterPlanContent,
     source: "built-in",
+    priority: 20,
+    tags: ["计划", "大纲"],
   }),
   normalizeUserSkill({
     id: "builtin:mainline-check",
     name: "主线检查",
-    description: "确认本章是否服务于故事主线。",
+    description: "诊断本章是否偏离主线,识别偏航类型并给出纠正建议。",
     kind: ["structure", "review"],
     stages: ["review", "planning"],
     modes: ["strict"],
-    content: ["检查本章内容是否推进了以下至少一项:","1. 主角的核心目标","2. 主要冲突","3. 反派或压力来源","4. 阶段性转折","若完全不服务于主线,建议重新规划。"].join("\\n"),
+    content: mainlineCheckContent,
     source: "built-in",
+    priority: 30,
+    tags: ["主线", "检查"],
   }),
   normalizeUserSkill({
     id: "builtin:character-motivation",
     name: "人物动机",
-    description: "检查人物行为是否合理。",
+    description: "用动机三角模型检查人物行为的内在驱动力,防止OOC和工具人。",
     kind: ["structure"],
     stages: ["planning", "review"],
     modes: ["standard", "strict"],
-    content: ["对每个出场人物检查:","1. 他/她此刻想要什么?","2. 他/她害怕失去什么?","3. 行为是否符合前文设定?","4. 是否有独立于主角的行动逻辑?"].join("\\n"),
+    content: characterMotivationContent,
     source: "built-in",
+    priority: 30,
+    tags: ["人物", "动机", "OOC检查"],
   }),
   normalizeUserSkill({
     id: "builtin:conflict-escalation",
     name: "冲突升级",
-    description: "保证本章有足够的矛盾、选择、代价和压力。",
+    description: "从五个层次构建冲突,设计升级路径,控制冲突节奏,确保章节有足够张力。",
     kind: ["structure"],
     stages: ["planning", "drafting"],
     modes: ["standard", "strict"],
-    content: ["确保本章包含以下至少两项:","1. 人物之间的直接矛盾","2. 需要做出艰难选择","3. 承受代价或损失","4. 来自外部环境的压力升级","5. 内部心理斗争加剧"].join("\\n"),
+    content: conflictEscalationContent,
     source: "built-in",
+    priority: 25,
+    tags: ["冲突", "张力", "节奏"],
   }),
   normalizeUserSkill({
     id: "builtin:foreshadowing-management",
     name: "伏笔管理",
-    description: "判断哪些伏笔该推进、隐藏、回收或暂缓。",
+    description: "系统管理伏笔的埋设、推进、提示和回收全生命周期,防止挖坑不填。",
     kind: ["structure", "review"],
     stages: ["planning", "review"],
     modes: ["strict"],
-    content: ["对现有伏笔逐一判断:","1. 哪些本章适合推进","2. 哪些太久未处理","3. 哪些不应提前揭示","4. 哪些已到回收时机"].join("\\n"),
+    content: foreshadowingManagementContent,
     source: "built-in",
+    priority: 35,
+    tags: ["伏笔", "线索", "回收"],
   }),
   normalizeUserSkill({
     id: "builtin:rhythm-check",
     name: "节奏检查",
-    description: "避免章节过慢、过散或中段塌陷。",
+    description: "诊断章节节奏问题,识别拖沓、塌陷、仓促等常见节奏病并给出治疗方案。",
     kind: ["review"],
     stages: ["review"],
     modes: ["strict"],
-    content: ["检查本章节奏:","1. 开头是否太慢?","2. 是否有过多无关场景堆叠?","3. 中段是否塌陷或重复?","4. 高潮是否足够支撑?","5. 结尾是否有力?"].join("\\n"),
+    content: rhythmCheckContent,
     source: "built-in",
+    priority: 40,
+    tags: ["节奏", "检查"],
   }),
   normalizeUserSkill({
     id: "builtin:ending-hook",
     name: "结尾钩子",
-    description: "提供悬念、反转、危机升级等结尾方案。",
+    description: "提供七种钩子类型和强度分级,根据章节类型选择最合适的结尾策略。",
     kind: ["structure", "planning"],
     stages: ["planning", "drafting"],
     modes: ["strict"],
-    content: ["建议本章结尾选择以下至少一种方式:","1. 强悬念结尾","2. 反转结尾","3. 危机升级结尾","4. 情绪余韵结尾","5. 人物关系变化结尾"].join("\\n"),
+    content: endingHookContent,
     source: "built-in",
+    priority: 30,
+    tags: ["钩子", "结尾", "悬念"],
   }),
   normalizeUserSkill({
     id: "builtin:plot-self-check",
     name: "剧情自检",
-    description: "正文生成后检查剧情质量。",
+    description: "端到端综合检查:承接、主线、动机、冲突、伏笔、节奏、风格七项评分。",
     kind: ["review"],
     stages: ["review"],
     modes: ["standard", "strict"],
-    content: ["正文生成后逐一检查:","1. 剧情是否自然承接上一章","2. 主线是否推进","3. 人物动机是否合理","4. 本章冲突是否足够","5. 伏笔是否推进","6. 节奏是否合适","7. 是否符合 soul.md 风格"].join("\\n"),
+    content: plotSelfCheckContent,
     source: "built-in",
+    priority: 45,
+    tags: ["自检", "综合检查"],
   }),
   normalizeUserSkill({
     id: "builtin:output-protocol",
     name: "正文输出协议",
-    description: "保证回复只输出章节正文,不混入过程信息。",
+    description: "确保最终输出只包含纯净的章节正文,严格排除分析、Skill说明、元信息等过程内容。",
     kind: ["output"],
     stages: ["output"],
     modes: ["fast", "standard", "strict"],
-    content: ["最终输出要求:","1. 只输出章节标题和正文","2. 不输出分析过程","3. 不输出 Skill 使用说明","4. 不输出检查结果","5. 不输出任何元信息"].join("\\n"),
+    content: outputProtocolContent,
+    source: "built-in",
+    priority: 10,
+    tags: ["输出", "格式"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:suspense-design",
+    name: "悬念设计",
+    description: "通过三个层级的悬念结构和藏三露一原则,系统设计信息释放节奏,制造持续的阅读张力。",
+    kind: ["structure"],
+    stages: ["planning", "drafting"],
+    modes: ["strict"],
+    content: suspenseDesignContent,
+    source: "built-in",
+    priority: 35,
+    tags: ["悬念", "节奏", "钩子"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:character-intro",
+    name: "人物出场",
+    description: "通过四种出场方式和第一印象三要素,让人物首次出场就鲜明立体,同时为后续发展留足空间。",
+    kind: ["style", "structure"],
+    stages: ["planning", "drafting"],
+    modes: ["standard", "strict"],
+    content: characterIntroContent,
+    source: "built-in",
+    priority: 40,
+    tags: ["人物", "出场", "第一印象"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:worldbuilding-implant",
+    name: "世界观植入",
+    description: "用五种植入方式将设定自然融入剧情,避免信息倾倒,让读者在不知不觉中理解世界规则。",
+    kind: ["knowledge", "structure"],
+    stages: ["planning", "drafting"],
+    modes: ["standard", "strict"],
+    content: worldbuildingImplantContent,
+    source: "built-in",
+    priority: 45,
+    tags: ["世界观", "设定", "信息"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:scene-description",
+    name: "场景描写",
+    description: "从功能定位出发,运用五感层次描写法构建有画面感、有氛围、服务剧情的场景。",
+    kind: ["style"],
+    stages: ["drafting"],
+    modes: ["standard", "strict"],
+    content: sceneDescriptionContent,
+    source: "built-in",
+    priority: 45,
+    tags: ["场景", "描写", "氛围"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:dialogue-design",
+    name: "对话设计",
+    description: "通过潜台词三层结构和节奏控制技巧,让对话既符合人物又推动剧情还充满张力。",
+    kind: ["style", "structure"],
+    stages: ["planning", "drafting"],
+    modes: ["standard", "strict"],
+    content: dialogueDesignContent,
+    source: "built-in",
+    priority: 35,
+    tags: ["对话", "人物", "潜台词"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:scene-transition",
+    name: "转场过渡",
+    description: "掌握五种转场类型和自然衔接技巧,让场景切换流畅不生硬,保持阅读节奏。",
+    kind: ["structure"],
+    stages: ["drafting"],
+    modes: ["standard"],
+    content: sceneTransitionContent,
+    source: "built-in",
+    priority: 55,
+    tags: ["转场", "节奏", "衔接"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:emotion-rendering",
+    name: "情绪渲染",
+    description: "用四种情绪外化方式和节奏曲线,调动读者共情,让情绪表达有力量而不泛滥。",
+    kind: ["style"],
+    stages: ["drafting", "review"],
+    modes: ["standard", "strict"],
+    content: emotionRenderingContent,
+    source: "built-in",
+    priority: 40,
+    tags: ["情绪", "氛围", "共情"],
+  }),
+  normalizeUserSkill({
+    id: "builtin:quotable-lines",
+    name: "金句设计",
+    description: "四种金句类型和节制原则,创造有记忆点的台词,点题塑人,宁少勿滥。",
+    kind: ["style", "output"],
+    stages: ["drafting", "output"],
+    modes: ["standard"],
+    content: quotableLinesContent,
     source: "built-in",
+    priority: 60,
+    tags: ["金句", "台词", "记忆点"],
   }),
 ]
 
 export function getBuiltinSkillIds(): Set<string> {
   return new Set(DEFAULT_BUILTIN_WRITING_SKILLS.map((s) => s.id))
-}
+}

+ 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",