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

fix(writing): 把 @技能和技能名字符串接入正文工作流

用户圈选的 skill 与原文中的 canonical 技能名现在会并入 selectedSkills,
并通过 getSelectedSkillsPrompt 注入任务书、初稿和返修,而不再只挂在外层 Agent 提示里。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
Cursor Agent пре 3 недеља
родитељ
комит
5aae31aa07

+ 3 - 0
src/components/chat/chat-panel.spec.tsx

@@ -252,6 +252,9 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("agentUserWritingSkills")
     expect(source).toContain("aiWorkflowMode,")
     expect(source).toContain("availableSkills: availableAgentSkills")
+    expect(source).toContain("selectedSkills: explicitSkills")
+    expect(source).toContain("collectExplicitSkills")
+    expect(source).toContain("getSelectedSkillsPrompt")
   })
 
   it("passes Plan Execute mode into the novel pre-plugin chain and consumes its final prompt", () => {

+ 26 - 6
src/components/chat/chat-panel.tsx

@@ -126,6 +126,7 @@ import { runNovelPrePluginChain } from "@/lib/agent/novel-pre-plugin-chain"
 import { buildInitialContextTraceInfo } from "@/lib/agent/context-trace-builders"
 import { runPostWriteCheckAI } from "@/lib/agent/plugins/post-write-check-ai"
 import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plugin"
+import { collectExplicitSkills } from "@/lib/novel/skill-route-registry"
 import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
 // import { getLoadedCategories, DATA_SOURCE_CATEGORY_LABELS } from "@/lib/novel/classification"
 // import { RetrievalStore } from "@/lib/novel/retrieval"
@@ -1007,6 +1008,11 @@ export function ChatPanel() {
     ],
   )
   // 存储用户最近确认的章节计划,供 run_chapter_workflow 兜底注入,不依赖模型是否自觉传参。
+  const selectedSkillsPromptRef = useRef("")
+  const getSelectedSkillsPrompt = useCallback(() => {
+    const value = selectedSkillsPromptRef.current.trim()
+    return value || undefined
+  }, [])
   const {
     config: agentConfig,
     registry: agentRegistry,
@@ -1015,7 +1021,7 @@ export function ChatPanel() {
     skillConfig: agentSkillConfig,
     writingSkills: agentUserWritingSkills,
     mcpCapabilities: agentMcpCapabilities,
-  } = useAgentConfig(agentSystemPrompt)
+  } = useAgentConfig(agentSystemPrompt, undefined, getSelectedSkillsPrompt)
   const deferredReferenceText = useDeferredValue(referenceText)
   const liveContextUsage = useMemo(() => {
     const historyMessages = selectContextHistoryMessages(
@@ -1487,6 +1493,14 @@ export function ChatPanel() {
       let goldenDirective = ""
       let prePluginResult: PrePluginChainResult | null = null
       const shouldRunNovelPrePluginChain = novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)
+      const explicitSkills = collectExplicitSkills(
+        availableAgentSkills,
+        plainText,
+        tokens
+          .filter((token) => Boolean(token.skillId) || token.category === "skill")
+          .map((token) => ({ skillId: token.skillId, title: token.title })),
+      )
+      selectedSkillsPromptRef.current = ""
       void shouldRunNovelPrePluginChain
       let hasAgentError = false
       let lastAgentError = "生成失败"
@@ -1650,6 +1664,7 @@ export function ChatPanel() {
               aiWorkflowMode,
               planExecuteEnabled: planExecuteActive,
               availableSkills: availableAgentSkills,
+              selectedSkills: explicitSkills,
               mcpCapabilities: agentMcpCapabilities,
               selectedFile,
             },
@@ -1668,6 +1683,11 @@ export function ChatPanel() {
         effectiveTaskRoute = prePluginResult.effectiveTaskRoute ?? effectiveTaskRoute
         contextPack = prePluginResult.contextPack || null
       }
+      const sessionSelectedSkills = prePluginResult?.selectedSkills?.length
+        ? prePluginResult.selectedSkills
+        : explicitSkills
+      const sessionSkillsPrompt = buildSelectedSkillsPrompt(sessionSelectedSkills)
+      selectedSkillsPromptRef.current = sessionSkillsPrompt
 
       if (novelMode) {
         const now = Date.now()
@@ -1685,7 +1705,7 @@ export function ChatPanel() {
           kind: "skill_used",
           title: "本次启用 Skill",
           content: buildSelectedSkillsActivityContent(
-            prePluginResult?.selectedSkills,
+            sessionSelectedSkills,
             (prePluginResult?.missingSkillNames as string[] | undefined) ?? [],
           ),
           timestamp: now + 1,
@@ -1781,8 +1801,8 @@ export function ChatPanel() {
             "",
             "## 当前会话去AI味技能",
             buildDeAiSkillSystemPrompt(effectiveDeAiSkill.content),
-            (!prePluginSystemPrompt && prePluginResult?.selectedSkills && prePluginResult.selectedSkills.length > 0
-              ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
+            (!prePluginSystemPrompt && sessionSelectedSkills.length > 0
+              ? `## 当前会话写作技能\n${sessionSkillsPrompt}`
               : ""),
           ].filter(Boolean).join("\n")
         : [
@@ -1798,8 +1818,8 @@ export function ChatPanel() {
             prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : taskDirective,
             goldenDirective,
             prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : selectedSkillsPrompt,
-            !prePluginSystemRulesPrompt && !hasSplitSystemRules && prePluginResult?.selectedSkills?.length
-              ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
+            !prePluginSystemRulesPrompt && !hasSplitSystemRules && sessionSelectedSkills.length > 0
+              ? `## 当前会话写作技能\n${sessionSkillsPrompt}`
               : "",
           ])
         : null

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

@@ -26,7 +26,11 @@ export interface UseAgentConfigResult {
   mcpWarnings: string[]
 }
 
-export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => string | undefined): UseAgentConfigResult {
+export function useAgentConfig(
+  systemPrompt: string,
+  getPlanBlueprint?: () => string | undefined,
+  getSelectedSkillsPrompt?: () => string | undefined,
+): UseAgentConfigResult {
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel)
   const novelDefaultLlmModel = useWikiStore((s) => s.novelConfig.defaultLlmModel)
@@ -155,6 +159,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       draftMode: novelMode,
       projectPath: normalizePath(projectPath),
       getPlanBlueprint,
+      getSelectedSkillsPrompt,
       disabledTools: ["write_chapter", "write_outline_node", "write_memory"],
     })
 
@@ -185,5 +190,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
     writingSkills,
     getChatConversations,
     getOutlineConversations,
+    getPlanBlueprint,
+    getSelectedSkillsPrompt,
   ])
 }

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

@@ -218,4 +218,72 @@ describe("SelectSkillsPlugin", () => {
       "正文输出协议",
     ])
   })
+
+  it("prepends @ selected skills and canonical name hits before auto-routed skills", async () => {
+    const plugin = createSelectSkillsPlugin()
+    const combat = skill({
+      id: "combat",
+      name: "combat-action",
+      kind: ["style"],
+      stages: ["drafting"],
+      modes: ["fast", "standard", "strict"],
+      categoryId: SKILL_ROUTE_CATEGORY_IDS.writing,
+    })
+
+    const fromName = await plugin.run({
+      userMessage: "写下一章 combat-action",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableSkills: [...availableSkills, combat],
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+    expect(fromName.selectedSkills?.map((item) => item.name)).toEqual([
+      "combat-action",
+      "正文输出协议",
+      "基础去AI味",
+    ])
+
+    const fromToken = await plugin.run({
+      userMessage: "写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableSkills: [...availableSkills, combat],
+      selectedSkills: [combat],
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+    expect(fromToken.selectedSkills?.map((item) => item.name)).toEqual([
+      "combat-action",
+      "正文输出协议",
+      "基础去AI味",
+    ])
+  })
+
+  it("keeps explicit skills in fast mode without auto-selecting helpers", async () => {
+    const plugin = createSelectSkillsPlugin()
+    const combat = skill({
+      id: "combat",
+      name: "combat-action",
+      kind: ["style"],
+      stages: ["drafting"],
+      modes: ["fast", "standard", "strict"],
+      categoryId: SKILL_ROUTE_CATEGORY_IDS.writing,
+    })
+
+    const result = await plugin.run({
+      userMessage: "直接写下一章 combat-action",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "fast",
+      availableSkills: [...availableSkills, combat],
+      selectedSkills: [combat],
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual(["combat-action"])
+  })
 })

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

@@ -4,9 +4,11 @@ import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import type { SkillKind, SkillStage, UserSkill } from "@/lib/novel/skill-library"
 import { filterSkillsForSkillRoute, filterSkillsForSkillRoutes, inferSkillRoute, type SkillRoute } from "@/lib/novel/skill-route"
 import {
+  collectExplicitSkills,
   getOutlineSkillNames,
   getWritingSkillNames,
   resolveAvailableSkillsByNames,
+  uniqueSkillsById,
 } from "@/lib/novel/skill-route-registry"
 
 const WRITING_INTENTS = new Set<NovelTaskIntent>([
@@ -70,7 +72,12 @@ export function createSelectSkillsPlugin(): PrePlugin {
       const deterministicNames = route.intent === "generate_outline"
         ? getOutlineSkillNames(input.userMessage)
         : getWritingSkillNames(route.intent, input.userMessage)
-      const selectedSkills = selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage)
+      const explicitSkills = uniqueSkillsById([
+        ...(input.selectedSkills ?? []),
+        ...collectExplicitSkills(availableSkills, input.userMessage),
+      ])
+      const routedSkills = selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage)
+      const selectedSkills = uniqueSkillsById([...explicitSkills, ...routedSkills])
       return {
         selectedSkills,
         missingSkillNames: deterministicNames.length > 0

+ 2 - 0
src/lib/agent/tools/index.ts

@@ -65,6 +65,7 @@ export interface ToolFactoryOptions {
   runDeepChapterGeneration?: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   getPlanBlueprint?: () => string | undefined
+  getSelectedSkillsPrompt?: () => string | undefined
   readTextFile?: (path: string) => Promise<string>
 }
 
@@ -124,6 +125,7 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
       runDeepChapterGeneration: options.runDeepChapterGeneration,
       onToolEvent: options.onToolEvent,
       getPlanBlueprint: options.getPlanBlueprint,
+      getSelectedSkillsPrompt: options.getSelectedSkillsPrompt,
     }))
   }
   for (const tool of options.mcpTools ?? []) {

+ 32 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -332,6 +332,38 @@ describe("createRunChapterWorkflowTool", () => {
     )
   })
 
+  it("passes getSelectedSkillsPrompt into deep chapter generation", async () => {
+    const runDeepChapterGeneration = vi.fn(async () => ({
+      finalContent: "最终正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: false,
+    }))
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration,
+      getSelectedSkillsPrompt: () => "## 本次启用 Skill\n规则:combat-action",
+    })
+
+    await tool.execute({
+      intent: "write_chapter",
+      userRequest: "生成第3章",
+      chapterNumber: 3,
+    })
+
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({
+        skillsPrompt: "## 本次启用 Skill\n规则:combat-action",
+      }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+  })
+
   it("includes plan compliance in the tool result when available", async () => {
     const runDeepChapterGeneration = vi.fn(async () => ({
       finalContent: "最终正文",

+ 6 - 0
src/lib/agent/tools/run-chapter-workflow.ts

@@ -28,6 +28,10 @@ export interface RunChapterWorkflowToolOptions {
    * 保证用户确认的计划为强制约束,不依赖模型是否遵守自然语言提示。
    */
   getPlanBlueprint?: () => string | undefined
+  /**
+   * 本轮已选定的写作 Skill 提示。不作为工具参数,避免模型漏传。
+   */
+  getSelectedSkillsPrompt?: () => string | undefined
 }
 
 interface RunChapterWorkflowParams {
@@ -128,6 +132,7 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
       // 兜底:AI 未在工具调用参数中携带 planBlueprint 时,从外部 getter 补上,
       // 保证用户确认的计划一定进入章节生成链路,不依赖模型是否遵守自然语言提示。
       const planBlueprint = params.planBlueprint?.trim() || options.getPlanBlueprint?.()?.trim() || undefined
+      const skillsPrompt = options.getSelectedSkillsPrompt?.()?.trim() || undefined
       const result = await options.runDeepChapterGeneration(
         {
           projectPath: options.projectPath,
@@ -136,6 +141,7 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
           llmConfig: options.llmConfig,
           aiWorkflowMode: params.workflowMode ?? options.aiWorkflowMode,
           planBlueprint,
+          skillsPrompt,
         },
         {
           onWorkflowEvent: (event) => {

+ 5 - 0
src/lib/novel/deep-chapter-generation.ts

@@ -80,6 +80,8 @@ export interface DeepChapterGenerationInput {
   resumeCheckpoint?: DeepChapterGenerationResumeCheckpoint;
   /** 用户在会话层确认的章节计划,作为写作任务书的权威依据注入 brief 阶段。 */
   planBlueprint?: string;
+  /** 本轮启用的写作 Skill 约束,注入任务书/初稿/返修(稳定缓存前缀之后)。 */
+  skillsPrompt?: string;
 }
 
 export interface DeepChapterGenerationCallbacks {
@@ -827,6 +829,7 @@ export async function runDeepChapterGeneration(
                 lengthSpec,
                 planExecutionSummary,
                 executionContractText,
+                input.skillsPrompt,
               ),
             },
           ],
@@ -902,6 +905,7 @@ export async function runDeepChapterGeneration(
                 input.chapterNumber,
                 input.goldenThreeChapter,
                 lengthSpec,
+                input.skillsPrompt,
               ),
             },
           ],
@@ -1256,6 +1260,7 @@ export async function runDeepChapterGeneration(
                 input.userRequest,
                 input.chapterNumber,
                 input.goldenThreeChapter,
+                input.skillsPrompt,
               ),
             },
           ],

+ 19 - 0
src/lib/novel/deep-chapter-prompts.spec.ts

@@ -7,6 +7,8 @@ import {
   DEEP_CHAPTER_TARGET_CHARS,
   buildDeepChapterBriefPrompt,
   buildDeepChapterDraftPrompt,
+  buildDeepChapterRevisionPrompt,
+  buildStableContextPrefix,
   resolveChapterLengthSpec,
 } from "./deep-chapter-prompts"
 
@@ -52,4 +54,21 @@ describe("chapter prompts honor the configured length spec", () => {
     expect(draft).toContain(`阶段3正文草稿最多 ${spec.draftMaxChars} 字`)
     expect(draft).not.toContain("目标约 3000 字")
   })
+
+  it("injects skills after the stable cache prefix in brief, draft and revision prompts", () => {
+    const outline = "# 大纲"
+    const context = "上下文包"
+    const skillsPrompt = "## 本次启用 Skill\n规则:combat-action"
+    const prefix = buildStableContextPrefix(outline, context)
+    const spec = resolveChapterLengthSpec(2000)
+    const brief = buildDeepChapterBriefPrompt(outline, context, "写下一章", 5, undefined, spec, undefined, undefined, skillsPrompt)
+    const draft = buildDeepChapterDraftPrompt(outline, context, "任务书", "写下一章", 5, undefined, spec, skillsPrompt)
+    const revision = buildDeepChapterRevisionPrompt(outline, context, "任务书", "初稿", [], "写下一章", 5, undefined, skillsPrompt)
+
+    for (const prompt of [brief, draft, revision]) {
+      expect(prompt.startsWith(prefix)).toBe(true)
+      expect(prompt).toContain(skillsPrompt)
+      expect(prompt.indexOf(skillsPrompt)).toBeGreaterThan(prefix.length)
+    }
+  })
 })

+ 11 - 0
src/lib/novel/deep-chapter-prompts.ts

@@ -59,6 +59,11 @@ export function buildStableContextPrefix(outline: string, contextPrompt: string)
   ].filter(Boolean).join("\n")
 }
 
+/** 可变 Skill 约束必须放在稳定缓存前缀之后,避免截断公共前缀。 */
+export function skillsConstraintSection(skillsPrompt?: string): string {
+  return skillsPrompt?.trim() ?? ""
+}
+
 export function buildDeepChapterBriefPrompt(
   outline: string,
   contextPrompt: string,
@@ -68,6 +73,7 @@ export function buildDeepChapterBriefPrompt(
   lengthSpec: ChapterLengthSpec = DEFAULT_CHAPTER_LENGTH_SPEC,
   planBlueprint?: string,
   executionContractText?: string,
+  skillsPrompt?: string,
 ): string {
   const contractSection = executionContractText && executionContractText.trim()
     ? [
@@ -128,6 +134,7 @@ export function buildDeepChapterBriefPrompt(
   return [
     buildStableContextPrefix(outline, contextPrompt),
     "",
+    skillsConstraintSection(skillsPrompt),
     "你是小说写作任务规划助手。",
     "请基于上述上下文输出一份写作任务书,供后续创作使用。",
     "",
@@ -148,10 +155,12 @@ export function buildDeepChapterDraftPrompt(
   chapterNumber?: number,
   goldenThreeChapter?: GoldenThreeChapterRequest,
   lengthSpec: ChapterLengthSpec = DEFAULT_CHAPTER_LENGTH_SPEC,
+  skillsPrompt?: string,
 ): string {
   return [
     buildStableContextPrefix(outline, contextPrompt),
     "",
+    skillsConstraintSection(skillsPrompt),
     "你是专业小说正文写作助手。",
     "请严格根据上述上下文和下方写作任务书起草章节正文。",
     "",
@@ -186,10 +195,12 @@ export function buildDeepChapterRevisionPrompt(
   userRequest: string,
   chapterNumber?: number,
   goldenThreeChapter?: GoldenThreeChapterRequest,
+  skillsPrompt?: string,
 ): string {
   return [
     buildStableContextPrefix(outline, contextPrompt),
     "",
+    skillsConstraintSection(skillsPrompt),
     "你是小说正文返修助手。",
     "请根据审稿问题返修章节正文。",
     "",

+ 19 - 0
src/lib/novel/skill-route-registry.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import { DEFAULT_SKILL_HUB_SKILLS } from "./skill-hub-seed"
 import {
+  collectExplicitSkills,
   findSkillRouteByAlias,
   getOutlineSkillNames,
   getSkillRouteSkillNames,
@@ -66,4 +67,22 @@ describe("skill route registry", () => {
     expect(result.skills).toEqual([])
     expect(result.missingNames).toEqual(["chapter-outline-builder"])
   })
+
+  it("collects explicit skills from canonical names and @ skill ids, not short aliases", () => {
+    const combat = DEFAULT_SKILL_HUB_SKILLS.find((skill) => skill.name === "combat-action")
+    expect(combat).toBeTruthy()
+    const short = { id: "short:dialog", name: "对话" }
+    const skills = [combat!, short]
+
+    expect(collectExplicitSkills(skills, "写下一章 combat-action").map((skill) => skill.name)).toEqual([
+      "combat-action",
+    ])
+    expect(collectExplicitSkills(skills, "写一段对话").map((skill) => skill.name)).toEqual([])
+    expect(collectExplicitSkills(skills, "写下一章", [{ skillId: combat!.id }]).map((skill) => skill.name)).toEqual([
+      "combat-action",
+    ])
+    expect(collectExplicitSkills(skills, "写下一章", [{ skillId: short.id }]).map((skill) => skill.name)).toEqual([
+      "对话",
+    ])
+  })
 })

+ 47 - 0
src/lib/novel/skill-route-registry.ts

@@ -279,6 +279,53 @@ export function resolveSkillReference<T extends { id: string; name: string }>(
   return canonicalName ? skills.find((skill) => skill.name === canonicalName) : undefined
 }
 
+/** 短中文名(如「对话」)做子串匹配会误伤,显式技能名至少 4 个规范化字符。 */
+export const MIN_EXPLICIT_SKILL_NAME_LENGTH = 4
+
+export interface ExplicitSkillReference {
+  skillId?: string
+  title?: string
+}
+
+export function uniqueSkillsById<T extends { id: string }>(skills: readonly T[]): T[] {
+  const result: T[] = []
+  for (const skill of skills) {
+    if (!result.some((item) => item.id === skill.id)) result.push(skill)
+  }
+  return result
+}
+
+/**
+ * 收集用户显式指定的 skill:@ 引用的 skillId/标题,以及原文中的 canonical name 子串。
+ * 不使用中文别名 includes,避免「对话」一类短词误匹配。
+ */
+export function collectExplicitSkills<T extends { id: string; name: string }>(
+  skills: readonly T[],
+  userMessage: string,
+  references: readonly ExplicitSkillReference[] = [],
+): T[] {
+  const collected: T[] = []
+  const add = (skill: T | undefined) => {
+    if (!skill) return
+    if (!collected.some((item) => item.id === skill.id)) collected.push(skill)
+  }
+
+  for (const reference of references) {
+    add(resolveSkillReference(skills, { id: reference.skillId, name: reference.title }))
+  }
+
+  const normalizedMessage = normalizeAlias(userMessage)
+  if (!normalizedMessage) return collected
+
+  for (const skill of skills) {
+    const normalizedName = normalizeAlias(skill.name)
+    if (normalizedName.length < MIN_EXPLICIT_SKILL_NAME_LENGTH) continue
+    if (normalizedMessage.includes(normalizedName)) add(skill)
+  }
+
+  return collected
+}
+
 function normalizeAlias(value: string): string {
   return value.toLowerCase().replace(/[\s「」『』【】]/g, "").trim()
 }