select-skills-plugin.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
  2. import { resolveAiWorkflowMode, type AiWorkflowMode } from "../workflow-mode"
  3. import type { NovelTaskIntent } from "@/lib/novel/task-router"
  4. import type { SkillKind, SkillStage, UserSkill } from "@/lib/novel/skill-library"
  5. import { filterSkillsForSkillRoute, filterSkillsForSkillRoutes, inferSkillRoute, type SkillRoute } from "@/lib/novel/skill-route"
  6. import {
  7. getOutlineSkillNames,
  8. getWritingSkillNames,
  9. resolveAvailableSkillsByNames,
  10. } from "@/lib/novel/skill-route-registry"
  11. const WRITING_INTENTS = new Set<NovelTaskIntent>([
  12. "write_chapter",
  13. "continue_chapter",
  14. "rewrite_chapter",
  15. "polish_chapter",
  16. ])
  17. const REVIEW_INTENTS = new Set<NovelTaskIntent>(["review_chapter", "lint_chapter"])
  18. const QUERY_INTENTS = new Set<NovelTaskIntent>([
  19. "search_plot",
  20. "character_query",
  21. "foreshadowing_query",
  22. "timeline_query",
  23. "setting_query",
  24. ])
  25. const STANDARD_WRITING_SKILL_NAMES = [
  26. "章节承接",
  27. "下一章计划",
  28. "人物动机",
  29. "冲突升级",
  30. "剧情自检",
  31. "正文输出协议",
  32. ]
  33. const STRICT_WRITING_SKILL_NAMES = [
  34. ...STANDARD_WRITING_SKILL_NAMES,
  35. "主线检查",
  36. "伏笔管理",
  37. "节奏检查",
  38. "结尾钩子",
  39. ]
  40. const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "基础去AI味"]
  41. const EXCLUDED_FROM_FALLBACK = ["去AI味"]
  42. const OUTLINE_SUPPORT_ROUTES: SkillRoute[] = [
  43. "character",
  44. "setting",
  45. "worldbuilding",
  46. "faction",
  47. "foreshadowing",
  48. "map",
  49. "topic",
  50. ]
  51. export function createSelectSkillsPlugin(): PrePlugin {
  52. return {
  53. name: "select_skills",
  54. priority: 35,
  55. run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
  56. if (!input.novelMode) return { selectedSkills: [] }
  57. const route = input.effectiveTaskRoute || input.taskRoute
  58. if (!route) return { selectedSkills: [] }
  59. const availableSkills = input.availableSkills ?? []
  60. const mode = resolveAiWorkflowMode(input.aiWorkflowMode)
  61. const deterministicNames = route.intent === "generate_outline"
  62. ? getOutlineSkillNames(input.userMessage)
  63. : getWritingSkillNames(route.intent, input.userMessage)
  64. const selectedSkills = selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage)
  65. return {
  66. selectedSkills,
  67. missingSkillNames: deterministicNames.length > 0
  68. ? resolveAvailableSkillsByNames(mode === "fast"
  69. ? availableSkills
  70. : availableSkills.filter((skill) => skill.modes.includes(mode)), deterministicNames).missingNames
  71. : [],
  72. }
  73. },
  74. }
  75. }
  76. export function selectSkillsForRoute(
  77. skills: UserSkill[],
  78. intent: NovelTaskIntent,
  79. mode: AiWorkflowMode,
  80. requestText = "",
  81. ): UserSkill[] {
  82. if (mode === "fast") return []
  83. const modeSkills = skills.filter((skill) => skill.modes.includes(mode))
  84. if (modeSkills.length === 0) return []
  85. if (WRITING_INTENTS.has(intent)) {
  86. return selectWritingSkills(modeSkills, mode, intent, requestText)
  87. }
  88. if (intent === "generate_outline") {
  89. const routedNames = getOutlineSkillNames(requestText)
  90. if (routedNames.length > 0) {
  91. const routed = resolveAvailableSkillsByNames(modeSkills, routedNames).skills
  92. if (routed.length > 0) return routed
  93. }
  94. return selectOutlineSkills(modeSkills, mode)
  95. }
  96. if (REVIEW_INTENTS.has(intent)) {
  97. return selectByShape(modeSkills, mode, {
  98. kinds: ["review", "knowledge", "output"],
  99. stages: ["review", "output"],
  100. limit: mode === "strict" ? 8 : 5,
  101. })
  102. }
  103. if (QUERY_INTENTS.has(intent)) {
  104. return selectByShape(modeSkills, mode, {
  105. kinds: ["knowledge", "review", "output"],
  106. stages: ["planning", "review", "output"],
  107. limit: mode === "strict" ? 6 : 3,
  108. })
  109. }
  110. return []
  111. }
  112. function selectOutlineSkills(skills: UserSkill[], mode: Exclude<AiWorkflowMode, "fast">): UserSkill[] {
  113. const limit = mode === "strict" ? 8 : 5
  114. const options = {
  115. kinds: ["planning", "structure", "output"],
  116. stages: ["planning", "output"],
  117. limit,
  118. } satisfies { kinds: SkillKind[]; stages: SkillStage[]; limit: number }
  119. const outlineSkills = selectByShape(filterSkillsForSkillRoute(skills, "outline"), mode, options)
  120. const supportSkills = selectByShape(filterSkillsForSkillRoutes(skills, OUTLINE_SUPPORT_ROUTES), mode, options)
  121. const selected: UserSkill[] = []
  122. for (const skill of [...outlineSkills, ...supportSkills]) {
  123. if (selected.length >= limit) break
  124. if (!selected.some((item) => item.id === skill.id)) {
  125. selected.push(skill)
  126. }
  127. }
  128. if (selected.length > 0) {
  129. return selected
  130. }
  131. return selectByShape(skills, mode, options)
  132. }
  133. function selectWritingSkills(
  134. skills: UserSkill[],
  135. mode: Exclude<AiWorkflowMode, "fast">,
  136. intent: NovelTaskIntent,
  137. requestText: string,
  138. ): UserSkill[] {
  139. const writingSkills = skills.filter((skill) => {
  140. const route = inferSkillRoute(skill)
  141. return route === "writing" || route === null
  142. })
  143. const scopedSkills = writingSkills.length > 0 ? writingSkills : skills
  144. const primaryNames = getWritingSkillNames(intent, requestText)
  145. const supportNames = mode === "strict" ? STRICT_WRITING_SKILL_NAMES : FAST_WRITING_SKILL_NAMES
  146. const preferredNames = [...primaryNames, ...supportNames]
  147. if (mode === "standard") {
  148. return selectPreferredNames(scopedSkills, preferredNames, Math.max(3, primaryNames.length + 2), false)
  149. }
  150. if (mode === "strict") {
  151. return selectPreferredNames(scopedSkills, preferredNames, 14)
  152. }
  153. return []
  154. }
  155. function selectPreferredNames(skills: UserSkill[], names: string[], limit: number, fillWithRelevant = true): UserSkill[] {
  156. const selected: UserSkill[] = []
  157. for (const name of names) {
  158. const skill = skills.find((item) => item.name === name || item.id === name)
  159. if (skill && !selected.some((item) => item.id === skill.id)) {
  160. selected.push(skill)
  161. }
  162. }
  163. const fallback = skills
  164. .filter((skill) => isWritingSkill(skill))
  165. .filter((skill) => !EXCLUDED_FROM_FALLBACK.some((name) => skill.name === name))
  166. .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
  167. if (selected.length > 0) {
  168. if (!fillWithRelevant) return selected.slice(0, limit)
  169. for (const skill of fallback) {
  170. if (selected.length >= limit) break
  171. if (!selected.some((item) => item.id === skill.id)) {
  172. selected.push(skill)
  173. }
  174. }
  175. return selected.slice(0, limit)
  176. }
  177. return fallback.slice(0, limit)
  178. }
  179. function selectByShape(
  180. skills: UserSkill[],
  181. mode: Exclude<AiWorkflowMode, "fast">,
  182. options: { kinds: SkillKind[]; stages: SkillStage[]; limit: number },
  183. ): UserSkill[] {
  184. return skills
  185. .filter((skill) =>
  186. skill.kind.some((kind) => options.kinds.includes(kind))
  187. || skill.stages.some((stage) => options.stages.includes(stage)),
  188. )
  189. .sort((a, b) => scoreSkill(b, mode, options) - scoreSkill(a, mode, options))
  190. .slice(0, options.limit)
  191. }
  192. function isWritingSkill(skill: UserSkill): boolean {
  193. return skill.kind.some((kind) => kind === "planning" || kind === "structure" || kind === "review" || kind === "output" || kind === "style")
  194. || skill.stages.some((stage) => stage === "planning" || stage === "drafting" || stage === "review" || stage === "output" || stage === "rewrite")
  195. }
  196. function scoreSkill(
  197. skill: UserSkill,
  198. mode: Exclude<AiWorkflowMode, "fast">,
  199. options: { kinds: SkillKind[]; stages: SkillStage[] },
  200. ): number {
  201. let score = 0
  202. score += skill.kind.filter((kind) => options.kinds.includes(kind)).length * 3
  203. score += skill.stages.filter((stage) => options.stages.includes(stage)).length * 2
  204. if (skill.modes.includes(mode)) score += 1
  205. if (skill.source === "built-in") score += 0.5
  206. score += (100 - (skill.priority ?? 50)) * 0.1
  207. return score
  208. }
  209. export function buildSelectedSkillsPrompt(skills: UserSkill[] | undefined): string {
  210. if (!skills || skills.length === 0) return ""
  211. const blocks = skills.map((skill, index) => [
  212. `### ${index + 1}. ${skill.name}`,
  213. `类型:${skill.kind.join(", ")}`,
  214. `阶段:${skill.stages.join(", ")}`,
  215. skill.description ? `说明:${skill.description}` : "",
  216. "规则:",
  217. skill.content,
  218. ].filter(Boolean).join("\n"))
  219. return [
  220. "## 本次启用 Skill",
  221. "以下 Skill 只用于本次任务的内部写作决策和输出约束。不要在最终回复中解释 Skill、列出 Skill 分析过程,除非用户明确要求。",
  222. ...blocks,
  223. ].join("\n\n")
  224. }