@cloud411716/fancy-webnovel 1.0.46 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +168 -0
  242. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +184 -0
  243. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  244. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +131 -0
  245. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +85 -0
  246. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +103 -0
  247. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +68 -0
  248. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +142 -0
  249. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +292 -0
  250. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +266 -0
  251. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,2024 @@
1
+ /**
2
+ * fancy-analyze plugin entry
3
+ *
4
+ * 触发命令: /fancy-analyze [书名]
5
+ *
6
+ * 流程:
7
+ * 1. 检查 .fancy-deployed 存在(项目已初始化)
8
+ * 2. 取书名:参数 / 弹窗
9
+ * 3. 在 参考资料/ 下查找匹配的源文件
10
+ * 4. 调用 llm_wiki API 创建项目
11
+ * 5. 拷贝 schema-analyze.md → 对标/schema.md
12
+ * 6. 执行 split_source.js 拆书,结果存到 对标/raw/sources/<书名>/
13
+ * 7. 调用 llm_wiki sources/ingest 接口
14
+ * 8. 通知完成
15
+ *
16
+ * 不依赖 phase:这个 skill 可以独立于整条流水线使用
17
+ * (用户在写正文之前、中途、之后都可以调用来分析参考爆款)
18
+ */
19
+ import { existsSync, statSync, lstatSync, chmodSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
20
+ import { join, dirname, basename } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import { atomicWriteFile } from '#atomic-write';
23
+ import { askUserQuestion, spawnScript } from '#infra';
24
+ import { notify, llmFollowup } from '#infra';
25
+ import { describeFetchError, llmWikiError, readJsonSafe } from '#llm-wiki';
26
+ import { readDeployedState, DEPLOYED_STATUS } from '#deployed-state';
27
+ import { resolveWorkspace } from '#workspace';
28
+ import { sortByChapter, chapterNumber } from '#chapter-order';
29
+ import { findMissingIngestOutputs } from './ingest-coverage.js';
30
+ import { analyze } from './templates.js';
31
+ import {
32
+ scanSummaryProgress,
33
+ dirsToDispatch,
34
+ listSummaryDirs,
35
+ LEGACY_STRUCTURE_DIRS,
36
+ } from './summary-progress.js';
37
+ import { normalizeCommandInput, matchSubcommand } from './command-input.js';
38
+
39
+ export const inject = ['commands', 'userQuestions'];
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const SPLIT_SCRIPT = join(__dirname, 'scripts', 'split_source.js');
43
+ // 登记脚本:把"追加技法内容 + 登记章节"做成幂等原子的操作。
44
+ // 让模型手工编辑 YAML 头部很容易把已登记的清单冲掉 —— 头部是唯一事实源,
45
+ // 冲掉了下次 summary 会把老章节全部重做一遍。
46
+ //
47
+ // **脚本不在插件目录里执行** —— 插件目录在 node_modules 下(部署后位置
48
+ // 还不一样),让模型去那里跑文件既别扭又易变。派发提示词前把它复制到
49
+ // 项目里的 .fancy-tools/,提示词里给项目内的路径。
50
+ const REGISTER_SCRIPT = join(__dirname, 'scripts', 'append-summary-chapter.js');
51
+ const REGISTER_TOOL_DIR = '.fancy-tools';
52
+
53
+ /**
54
+ * 确保项目里有最新版登记脚本,返回它的绝对路径(失败返回 null)。
55
+ *
56
+ * 内容比对后才写 —— 每次 summary 都覆盖一次的话会白白改 mtime,
57
+ * 也会让"这个文件是不是被人手改过"变得看不出来。
58
+ *
59
+ * @param {string} projectRoot
60
+ * @returns {string|null}
61
+ */
62
+ export function ensureRegisterScript(projectRoot) {
63
+ try {
64
+ if (!projectRoot || typeof projectRoot !== 'string') return null;
65
+ if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) return null;
66
+ if (!existsSync(REGISTER_SCRIPT)) return null;
67
+
68
+ const destDir = join(projectRoot, REGISTER_TOOL_DIR);
69
+ let dirStat = null;
70
+ try { dirStat = lstatSync(destDir); } catch { /* 不存在 */ }
71
+ if (dirStat) {
72
+ // .fancy-tools 绝不能是符号链接 —— 否则脚本会被写到项目外。
73
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
74
+ console.error('[fancy-analyze] .fancy-tools 不是真实目录,拒绝部署登记脚本');
75
+ return null;
76
+ }
77
+ } else {
78
+ mkdirSync(destDir);
79
+ }
80
+
81
+ const dest = join(destDir, 'append-summary-chapter.js');
82
+ let destStat = null;
83
+ try { destStat = lstatSync(dest); } catch { /* 不存在 */ }
84
+ if (destStat && (destStat.isSymbolicLink() || !destStat.isFile())) {
85
+ console.error('[fancy-analyze] 登记脚本目标不是普通文件,拒绝覆盖:' + dest);
86
+ return null;
87
+ }
88
+
89
+ const srcContent = readFileSync(REGISTER_SCRIPT, 'utf-8');
90
+ if (destStat) {
91
+ const same = readFileSync(dest, 'utf-8') === srcContent;
92
+ if (!same) atomicWriteFile(dest, srcContent, { mode: 0o444 });
93
+ // 内容没变也要去掉写权限(老项目里可能是 0644)。
94
+ chmodSync(dest, 0o444);
95
+ return dest;
96
+ }
97
+
98
+ atomicWriteFile(dest, srcContent, { mode: 0o444 });
99
+ return dest;
100
+ } catch (err) {
101
+ console.error('[fancy-analyze] 准备登记脚本失败:' + (err && err.message ? err.message : String(err)));
102
+ return null;
103
+ }
104
+ }
105
+ const SCHEMA_SOURCE = join(__dirname, 'schema-analyze.md');
106
+ const DEFINITIONS_SOURCE = join(__dirname, 'analyze-definitions.md');
107
+ const DEFINITIONS_FILENAME = '分析定义.md';
108
+
109
+ /** 按章写作技法(与 schema-analyze.md 的 13 项保持一致)。 */
110
+ const CHAPTER_WRITING_TYPES = [
111
+ '叙事视角',
112
+ '章节结构',
113
+ '节奏韵律',
114
+ '对话技巧',
115
+ '描写技法',
116
+ '情节机制',
117
+ '开头钩子',
118
+ '结尾收束',
119
+ '句式变化',
120
+ '段落布局',
121
+ '场景转换',
122
+ '情绪渲染',
123
+ '张力构建',
124
+ ];
125
+ /** 只做称呼/关系称谓、通常不该单独建角色页的名字。 */
126
+ const RELATION_ONLY_ENTITY_RE = /^(?:哥哥|姐姐|弟弟|妹妹|父亲|母亲|爸爸|妈妈|爷爷|奶奶|外公|外婆|师父|师傅|老师|老板|同学|朋友|儿子|女儿)$/u;
127
+ /** 按章建页的类型集合(13 写作技法 + 章节功能设计)。 */
128
+ const CHAPTER_PAGE_TYPES = new Set([...CHAPTER_WRITING_TYPES, '章节功能设计']);
129
+
130
+ /** 结构设计页类型(与 schema-analyze.md 的 8 项保持一致)。 */
131
+ const ENTITY_STRUCTURE_TYPES = [
132
+ '人物关系设计',
133
+ '势力结构',
134
+ '世界观设计',
135
+ '冲突设计',
136
+ '主线支线结构',
137
+ '分卷节奏',
138
+ '伏笔布局',
139
+ '角色弧光设计',
140
+ ];
141
+
142
+ /** resummary 备份旧总结文件的目录(放在 对标/ 下,不在 wiki 树里,免得被当成技法目录)。 */
143
+ const SUMMARY_BACKUP_DIRNAME = '.summary-backup';
144
+
145
+ /**
146
+ * 把时间戳格式化成 "20261026-1930" 这样的目录名。
147
+ * @param {number} ts - 毫秒时间戳
148
+ * @returns {string}
149
+ */
150
+ function backupStamp(ts) {
151
+ const d = new Date(ts);
152
+ const pad = (n) => String(n).padStart(2, '0');
153
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
154
+ }
155
+
156
+ function getProjectRoot(session) {
157
+ // Analysis is shared at the workspace level: <workspace>/对标.
158
+ const cwd = session.header?.cwd;
159
+ if (!cwd) return null;
160
+ try {
161
+ return resolveWorkspace({ cwd, required: true });
162
+ } catch {
163
+ // Fall back to the session cwd so the caller can report "not initialized"
164
+ // with the right path instead of "no project root".
165
+ return cwd;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 读取 .fancy-deployed 配置
171
+ *
172
+ * 必须区分「读不到」和「读到了但坏了」:
173
+ * 之前一律返回 null,于是 .fancy-deployed 是坏 JSON 时也报
174
+ * "尚未初始化,请先运行 /fancy-bootstrap" —— 而 bootstrap 在这个
175
+ * 状态下多半也跑不通(文件已存在),用户会被这条错误指引带偏。
176
+ *
177
+ * @returns {{config: object|null, error: 'missing'|'corrupt'|'no_phase'|null, message?: string}}
178
+ */
179
+ function readDeployedConfig(projectRoot) {
180
+ const state = readDeployedState(projectRoot);
181
+ if (state.status === DEPLOYED_STATUS.OK) {
182
+ return { config: state.config, error: null };
183
+ }
184
+ if (state.status === DEPLOYED_STATUS.MISSING) {
185
+ return { config: null, error: 'missing' };
186
+ }
187
+ return { config: null, error: 'corrupt', message: state.error || state.status };
188
+ }
189
+
190
+ /**
191
+ * 更新 .fancy-deployed 中的指定字段
192
+ *
193
+ * 必须原子写:直接 writeFileSync 在写到一半时断电/中断,
194
+ * 会把 .fancy-deployed 截断成半个 JSON —— 而这个文件是所有插件
195
+ * 的项目状态源,坏掉之后整个项目都进不去。
196
+ * 仓库里 fancy-topic / fancy-scan 都用 tmp + rename 的写法。
197
+ */
198
+ function updateDeployedField(projectRoot, key, value) {
199
+ const deployedPath = join(projectRoot, '.fancy-deployed');
200
+ const { config } = readDeployedConfig(projectRoot);
201
+ if (!config) return false;
202
+ config[key] = value;
203
+ const tmpPath = join(dirname(deployedPath), `.${Date.now()}-${process.pid}.tmp`);
204
+ try {
205
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
206
+ renameSync(tmpPath, deployedPath);
207
+ return true;
208
+ } catch {
209
+ try { unlinkSync(tmpPath); } catch { /* 清理失败无所谓 */ }
210
+ return false;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * 在 handler 里取 .fancy-deployed 配置。
216
+ *
217
+ * 三条路径(fix / status / 普通)都要用,所以收成一个入口,
218
+ * 免得"损坏 vs 未初始化"在某个分支上又被压成同一种错误。
219
+ *
220
+ * @returns {{config: object|null, errorText: string|null}}
221
+ */
222
+ function loadDeployedConfig(projectRoot) {
223
+ const { config, error, message } = readDeployedConfig(projectRoot);
224
+ if (error === 'missing') {
225
+ return { config: null, errorText: analyze.notify({ type: 'not_initialized' }) };
226
+ }
227
+ if (error) {
228
+ return {
229
+ config: null,
230
+ errorText: analyze.notify({ type: 'deployed_unreadable', err: message }),
231
+ };
232
+ }
233
+ return { config, errorText: null };
234
+ }
235
+
236
+ /**
237
+ * 调用 llm_wiki API
238
+ * @param {string} baseUrl - llm_wiki_url(已去除末尾斜杠)
239
+ * @param {string} token - llm_wiki_token
240
+ * @param {string} path - API 路径
241
+ * @param {string} method - HTTP 方法
242
+ * @param {object} body - 请求体
243
+ * @param {number} [timeoutMs] - 超时(默认 30s)
244
+ */
245
+ async function callLlmWikiApi(baseUrl, token, path, method = 'POST', body = null, timeoutMs = 30000) {
246
+ const url = `${baseUrl}${path}`;
247
+ const headers = {
248
+ 'Content-Type': 'application/json',
249
+ 'Authorization': `Bearer ${token}`,
250
+ };
251
+ const options = {
252
+ method,
253
+ headers,
254
+ };
255
+ if (body) {
256
+ options.body = JSON.stringify(body);
257
+ }
258
+ // 必须有超时:fetch 默认永不超时,llm_wiki 挂住时命令会永远卡着,
259
+ // 用户既看不到提示也拿不回终端。
260
+ const controller = new AbortController();
261
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
262
+ try {
263
+ const response = await fetch(url, { ...options, signal: controller.signal });
264
+ // 响应体可能不是 JSON(例如网关返回 HTML 错误页)——
265
+ // readJsonSafe 返回 null 而不是抛 SyntaxError,把真正的原因盖掉。
266
+ const data = await readJsonSafe(response);
267
+ return { status: response.status, data };
268
+ } finally {
269
+ clearTimeout(timer);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * 转义正则元字符(用于把用户/文件里的字符串安全地拼进正则)。
275
+ */
276
+ function escapeRegExp(s) {
277
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
278
+ }
279
+
280
+ /**
281
+ * 查询 ingest 状态
282
+ * @param {string} baseUrl - llm_wiki_url
283
+ * @param {string} token - llm_wiki_token
284
+ * @param {string} projectId - reference_id
285
+ */
286
+ async function queryIngestStatus(baseUrl, token, projectId) {
287
+ return callLlmWikiApi(baseUrl, token, `/projects/${projectId}/ingest/querystatus`, 'GET', null);
288
+ }
289
+
290
+ /**
291
+ * summary / resummary 的前置门禁:ingest 必须真的完成。
292
+ *
293
+ * 只看本地 `对标/wiki` 存在是不够的:ingest 还在排队时,wiki 目录可能已经
294
+ * 有一半页面,summary 会把“还没落盘的章节”当成“没有输入”或永远等不到。
295
+ *
296
+ * 这里用 llm_wiki 的 /ingest/querystatus:
297
+ * - missingFiles 非空 → 还有源文件没进 wiki;
298
+ * - queue.pending / processing > 0 → 队列还在跑;
299
+ * - queue.failed > 0 → 有失败任务,需要 /fancy-analyze fix。
300
+ * 任一条成立都不让 summary 开工。
301
+ *
302
+ * @returns {{ok:boolean, text?:string}}
303
+ */
304
+ async function checkIngestReadyForSummary(projectRoot, opts = {}) {
305
+ const command = opts.command || 'summary';
306
+ const { config, errorText } = loadDeployedConfig(projectRoot);
307
+ if (errorText) return { ok: false, text: errorText };
308
+ const referenceId = config && typeof config.reference_id === 'string' ? config.reference_id.trim() : '';
309
+ if (!referenceId) {
310
+ return { ok: false, text: analyze.notify({ type: 'no_reference_id' }) };
311
+ }
312
+ const llmWikiUrl = (config.llm_wiki_url || '').replace(/\/$/, '');
313
+ const llmWikiToken = config.llm_wiki_token || '';
314
+ if (!llmWikiUrl || !llmWikiToken) {
315
+ return { ok: false, text: analyze.notify({ type: 'llm_wiki_not_configured' }) };
316
+ }
317
+ let result;
318
+ try {
319
+ result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
320
+ } catch (err) {
321
+ return { ok: false, text: analyze.notify({
322
+ type: 'status_query_failed',
323
+ err: describeFetchError(err),
324
+ }) };
325
+ }
326
+ const apiErr = llmWikiError(result);
327
+ if (apiErr) {
328
+ return { ok: false, text: analyze.notify({ type: 'status_query_failed', err: apiErr }) };
329
+ }
330
+ const data = result.data || {};
331
+ // 和 status 用同一套排序:服务端返回 missingFiles 的顺序是随机的,
332
+ // summary / summary-status / resummary 三个入口都走这里,统一按书名+章号排。
333
+ const missingFiles = sortByChapter(Array.isArray(data.missingFiles) ? data.missingFiles : []);
334
+ const queue = data.queue && typeof data.queue === 'object' ? data.queue : {};
335
+ const pending = Number(queue.pending) || 0;
336
+ const processing = Number(queue.processing) || 0;
337
+ const failed = Number(queue.failed) || 0;
338
+ if (missingFiles.length || pending || processing || failed) {
339
+ return {
340
+ ok: false,
341
+ text: analyze.notify({
342
+ type: 'summary_ingest_not_ready',
343
+ command,
344
+ missingFiles,
345
+ queue: { ...queue, pending, processing, failed },
346
+ }),
347
+ };
348
+ }
349
+ return { ok: true };
350
+ }
351
+
352
+ /**
353
+ * 在 参考资料/ 下查找匹配的源文件
354
+ *
355
+ * 匹配规则:
356
+ * - 接受文件名(不带后缀)或完整文件名(带后缀)
357
+ * - 忽略后缀比较(.txt / .md / .epub / .docx 都算同名的"诡秘之主")
358
+ * - 不区分大小写
359
+ *
360
+ * 支持目录结构:
361
+ * 参考资料/<书名>.txt (文件)
362
+ *
363
+ * 返回:
364
+ * { path } 找到唯一匹配
365
+ * { error: 'dir_missing' } 参考资料/ 不存在
366
+ * { error: 'not_found', matches: [...] } 无匹配,列出目录下所有条目供用户参考
367
+ * { error: 'ambiguous', matches: [...] } 多个匹配
368
+ */
369
+ function findSourceFile(refDir, bookName) {
370
+ if (!existsSync(refDir)) {
371
+ return { error: 'dir_missing' };
372
+ }
373
+
374
+ const stripExt = (s) => s.toLowerCase().replace(/\.[^./\\]+$/g, '');
375
+ // 只取文件名部分,忽略用户可能输入的路径前缀
376
+ const targetBase = bookName.trim().split(/[\\/]/).pop();
377
+ const target = stripExt(targetBase);
378
+
379
+ const stat = (() => {
380
+ try { return statSync(refDir); } catch { return null; }
381
+ })();
382
+ if (!stat) return { error: 'dir_missing' };
383
+ let entries = [];
384
+ try {
385
+ entries = stat.isDirectory() ? readdirSync(refDir) : [];
386
+ } catch {
387
+ entries = [];
388
+ }
389
+
390
+ // 情况:参考资料/<书名>.txt(文件)
391
+ const directMatches = [];
392
+ for (const name of entries) {
393
+ if (stripExt(name) === target) {
394
+ const full = join(refDir, name);
395
+ let st = null;
396
+ try { st = statSync(full); } catch { continue; }
397
+ if (st.isFile()) {
398
+ directMatches.push({ path: full, kind: 'file' });
399
+ }
400
+ }
401
+ }
402
+
403
+ if (directMatches.length === 1) {
404
+ return { path: directMatches[0].path };
405
+ }
406
+ if (directMatches.length > 1) {
407
+ return { error: 'ambiguous', matches: directMatches.map(m => m.path) };
408
+ }
409
+
410
+ // 没有匹配 → 列出目录下所有可识别的条目
411
+ const allCandidates = entries
412
+ .map(name => ({ name, stripped: stripExt(name) }))
413
+ .filter(e => e.name !== '.DS_Store' && e.name !== 'Thumbs.db');
414
+ return { error: 'not_found', matches: allCandidates.map(c => c.name) };
415
+ }
416
+
417
+ /**
418
+ * 从来源页里解析 `### 列表` / `### 结构设计分析` 的「| 名称 | 有/无 |」表格。
419
+ */
420
+ function parseHasTable(content, heading) {
421
+ const headingIndex = content.indexOf(heading);
422
+ if (headingIndex === -1) return {};
423
+ const after = content.substring(headingIndex);
424
+ const nextHeading = after.match(/\n#{1,3}\s+\S/);
425
+ const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
426
+ const tableContent = after.substring(0, tableEnd);
427
+ const out = {};
428
+ for (const line of tableContent.split('\n')) {
429
+ const row = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
430
+ if (row) out[row[1].trim()] = row[2] === '有';
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * 只保留当前 schema 定义的按章类型。
437
+ * 旧来源页的「### 列表」里还有 伏笔回收/世界观构建/角色弧光 ——
438
+ * 它们是按章页面,新版不会再生成对应页面。不过滤的话 status 会一直
439
+ * 报这三类缺失、fix 重传后又永远补不上。
440
+ */
441
+ function filterChapterTypes(table) {
442
+ return Object.fromEntries(
443
+ Object.entries(table).filter(([type]) => CHAPTER_PAGE_TYPES.has(type)));
444
+ }
445
+
446
+ /**
447
+ * 从来源页的「### 跨章节结构实体」表里解析实体行。
448
+ * 表头:结构类型 | 实体/线索 | 页面文件 | 本章动作 | 首次章节 | 本次章节
449
+ */
450
+ function parseEntityRows(content, bookName, chapterKey) {
451
+ const heading = '### 跨章节结构实体';
452
+ const headingIndex = content.indexOf(heading);
453
+ if (headingIndex === -1) return [];
454
+ const after = content.substring(headingIndex);
455
+ const nextHeading = after.match(/\n#{1,3}\s+\S/);
456
+ const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
457
+ const lines = after.substring(0, tableEnd).split('\n');
458
+ const rows = [];
459
+ for (const line of lines) {
460
+ if (!line.trim().startsWith('|')) continue;
461
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
462
+ if (cells.length < 6) continue;
463
+ const [type, entity, rawPageFile, action, firstChapter, currentChapter] = cells;
464
+ if (!ENTITY_STRUCTURE_TYPES.includes(type)) continue;
465
+ // 模型经常漏掉 .md、加反引号、或者写成 wiki/类型/文件名 的完整路径。
466
+ // 这里统一成裸文件名;带“第X章”的仍保留,交给下面的 invalidPageFile 报错。
467
+ let pageFile = String(rawPageFile || '')
468
+ .trim()
469
+ .replace(/^`+|`+$/g, '')
470
+ .replace(/^\[\[|\]\]$/g, '')
471
+ .trim();
472
+ if (pageFile.includes('/')) pageFile = pageFile.split('/').pop();
473
+ if (pageFile && pageFile !== '无' && pageFile !== '-' && !pageFile.endsWith('.md')) {
474
+ pageFile += '.md';
475
+ }
476
+ if (!pageFile || !pageFile.endsWith('.md')) continue;
477
+ rows.push({
478
+ bookName,
479
+ chapterKey,
480
+ type,
481
+ entity,
482
+ pageFile,
483
+ action,
484
+ // 跨章节实体页的文件名必须稳定:带“第X章”说明模型又按章建页了。
485
+ invalidPageFile: /第\d+章/.test(pageFile),
486
+ firstChapter: firstChapter && firstChapter !== '-' ? firstChapter : chapterKey,
487
+ currentChapter: currentChapter && currentChapter !== '-' ? currentChapter : chapterKey,
488
+ });
489
+ }
490
+ return rows;
491
+ }
492
+
493
+ /**
494
+ * 读实体页 frontmatter 里的 tags(支持行内数组和块列表)。
495
+ * 角色弧光页常把正式姓名/别名写在 tags 里,例如老班页的 tags 含“刘安国”。
496
+ */
497
+ function parseFrontmatterTags(content) {
498
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(content || ''));
499
+ if (!fm) return [];
500
+ const lines = fm[1].split(/\r?\n/);
501
+ const tags = [];
502
+ let inTags = false;
503
+ for (const line of lines) {
504
+ const m = /^tags\s*:\s*(.*)$/.exec(line.trim());
505
+ if (m) {
506
+ const raw = m[1].trim();
507
+ if (raw.startsWith('[') && raw.endsWith(']')) {
508
+ for (const item of raw.slice(1, -1).split(',')) {
509
+ const tag = item.trim().replace(/^["']|["']$/g, '');
510
+ if (tag) tags.push(tag);
511
+ }
512
+ inTags = false;
513
+ } else if (raw) {
514
+ tags.push(raw.replace(/^["']|["']$/g, ''));
515
+ inTags = false;
516
+ } else {
517
+ inTags = true;
518
+ }
519
+ continue;
520
+ }
521
+ if (!inTags) continue;
522
+ const item = /^\s*-\s+(.+)$/.exec(line);
523
+ if (item) {
524
+ const tag = item[1].trim().replace(/^["']|["']$/g, '');
525
+ if (tag) tags.push(tag);
526
+ } else if (line.trim()) {
527
+ inTags = false;
528
+ }
529
+ }
530
+ return tags;
531
+ }
532
+
533
+ /** 从实体页正文提取“正式姓名为X / 本名X / 又名X”这类别名。 */
534
+ function parseAliasesFromContent(content) {
535
+ const out = [];
536
+ const re = /(?:正式姓名|本名|真名|原名|又名|化名|姓名)\s*[为是::]\s*([\u4e00-\u9fa5]{2,4})/g;
537
+ let match;
538
+ while ((match = re.exec(String(content || ''))) !== null) {
539
+ if (match[1]) out.push(match[1]);
540
+ }
541
+ return out;
542
+ }
543
+
544
+ function hasChapterHeading(content, chapter) {
545
+ if (!chapter) return true;
546
+ const escaped = escapeRegExp(chapter);
547
+ // 允许 `### 第5章`、`## 第5章 转折`、`### 第5章:转折` 等写法;
548
+ // 但 `第1章` 不能匹配 `第10章`(后面紧跟数字时不放行)。
549
+ return new RegExp(`^#{2,3}\\s*${escaped}(?:\\s|$|[::((—-])`, 'm').test(content);
550
+ }
551
+
552
+ /**
553
+ * fix 重新 ingest 时带给 LLM 的修复指令。
554
+ *
555
+ * 只重传文件不够:模型可能再次漏掉同样的实体页/结构表。把这次
556
+ * 修复要重点检查的项目直接写进 ingest 任务,让分析/生成两个阶段都看到。
557
+ */
558
+ function buildFixInstruction(bookName, chapter, pageInfo = null) {
559
+ const existing = Array.isArray(pageInfo?.existing) ? pageInfo.existing : [];
560
+ const missing = Array.isArray(pageInfo?.missing) ? pageInfo.missing : [];
561
+ const list = (items) => items.slice(0, 40).map((item) => `- ${item}`).join('\n');
562
+
563
+ const lines = [`这是《${bookName}》${chapter} 的修复性重新 ingest。`, ''];
564
+
565
+ if (existing.length > 0) {
566
+ lines.push('上次已经生成并保留的页面(本次不要重写):');
567
+ lines.push(list(existing));
568
+ lines.push('');
569
+ }
570
+
571
+ lines.push('本次需要补的页面/小节:');
572
+ if (missing.length > 0) {
573
+ lines.push(list(missing));
574
+ } else {
575
+ // 正常不该走到这里;如果真没有 pageInfo,也只能给出本章级的明确动作,
576
+ // 不能再让模型去“猜 status 的缺失清单”。
577
+ lines.push('- 本章没有可枚举的页面级缺失;请重新核对来源页的「### 列表」与「### 跨章节结构实体」表,并按 schema 补齐。');
578
+ }
579
+ lines.push('');
580
+
581
+ lines.push('要求:');
582
+ lines.push('1. 只输出本次缺失页面/小节对应的 FILE 块;已经存在的页面不要重新生成;');
583
+ lines.push('2. 如果缺失的是实体页的 ### 第X章 小节,输出该实体页并只追加本章小节,页面合并会保留旧内容;');
584
+ lines.push('3. 如果缺失的是来源页或整章产物,按 schema 正常生成;');
585
+ lines.push('4. 已有实体页只追加本章小节,不得按章新建,不得换名新建;');
586
+ lines.push('5. 先读 对标/分析定义.md,按其中的术语定义判断,不要自创定义;');
587
+ lines.push('6. 14 种按章类型(13 种写作技法 + 章节功能设计)逐项判断“有/无”,标“有”的必须创建对应按章页面;');
588
+ lines.push('7. 本名/正式名优先;称呼、绰号、简称、集团/公司后缀不要另建实体。');
589
+ return lines.join('\n');
590
+ }
591
+
592
+ /**
593
+ * 拷贝 schema 文件到目标目录
594
+ */
595
+ function copySchemaFile(targetDir) {
596
+ const targetPath = join(targetDir, 'schema.md');
597
+ mkdirSync(targetDir, { recursive: true });
598
+ copyFileSync(SCHEMA_SOURCE, targetPath);
599
+ // 术语定义是 schema 的配套权威文件,必须和 schema 一起部署。
600
+ copyFileSync(DEFINITIONS_SOURCE, join(targetDir, DEFINITIONS_FILENAME));
601
+ return targetPath;
602
+ }
603
+
604
+ /** 读项目里的 对标/schema.md;不存在或读不了返回 null。 */
605
+ function readAnalyzeSchema(projectRoot) {
606
+ const schemaPath = join(projectRoot, '对标', 'schema.md');
607
+ if (!existsSync(schemaPath)) return null;
608
+ try {
609
+ return readFileSync(schemaPath, 'utf-8');
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * 判断 对标/schema.md 是否是上一版(按章结构设计)。
617
+ * 旧版没有「### 跨章节结构实体」,且带「### 结构设计分析」。
618
+ * 旧 schema 会让 fix 继续生成按章的 伏笔回收/世界观构建/角色弧光 页面,
619
+ * 所以 fix 前必须先把它换成新版。
620
+ */
621
+ function isLegacyAnalyzeSchema(content) {
622
+ if (!content) return true;
623
+ if (content.includes('### 结构设计分析')) return true;
624
+ if (!content.includes('### 跨章节结构实体')) return true;
625
+ if (!content.includes('世界观设计') || !content.includes('角色弧光设计')) return true;
626
+ return false;
627
+ }
628
+
629
+ /**
630
+ * fix 是否要把 对标/schema.md 刷新成插件当前版本。
631
+ *
632
+ * 不能只看“是不是旧版”:schema 里新加的强制核对清单、命名规则也要
633
+ * 能同步到已经初始化的项目,否则模型看不到最新约束。
634
+ */
635
+ /**
636
+ * 对标/raw/sources 下是否有任何拆书分片(递归,忽略隐藏目录)。
637
+ *
638
+ * status / fix 的第一步:连源文件都没有,说明还没跑过 /fancy-analyze <书名>,
639
+ * 这时去检查 schema、覆盖、.retry 全是无意义的噪音。
640
+ *
641
+ * @param {string} dir
642
+ * @returns {boolean}
643
+ */
644
+ function hasAnySourceFiles(dir) {
645
+ let entries;
646
+ try {
647
+ entries = readdirSync(dir, { withFileTypes: true });
648
+ } catch {
649
+ return false;
650
+ }
651
+ for (const entry of entries) {
652
+ if (entry.name.startsWith('.')) continue;
653
+ if (entry.isFile()) return true;
654
+ if (entry.isDirectory() && hasAnySourceFiles(join(dir, entry.name))) return true;
655
+ }
656
+ return false;
657
+ }
658
+
659
+ /**
660
+ * 对标/wiki 下是否已有任何拆解产物(旧项目可能清掉了 raw 分片)。
661
+ *
662
+ * 只看子目录里的 .md:wiki 根目录的 index.md / log.md / overview.md 是
663
+ * llm_wiki 建项目时生成的元数据,不能拿来证明“已经拆解过”。
664
+ */
665
+ function hasAnyAnalyzeOutput(dir, depth = 0) {
666
+ let entries;
667
+ try {
668
+ entries = readdirSync(dir, { withFileTypes: true });
669
+ } catch {
670
+ return false;
671
+ }
672
+ for (const entry of entries) {
673
+ if (entry.name.startsWith('.')) continue;
674
+ if (entry.isDirectory() && hasAnyAnalyzeOutput(join(dir, entry.name), depth + 1)) return true;
675
+ if (depth > 0 && entry.isFile() && entry.name.endsWith('.md')) return true;
676
+ }
677
+ return false;
678
+ }
679
+
680
+ function schemaNeedsRefresh(projectRoot) {
681
+ const current = readAnalyzeSchema(projectRoot);
682
+ if (!current) return true;
683
+ try {
684
+ const latest = readFileSync(SCHEMA_SOURCE, 'utf-8');
685
+ if (current.trim() !== latest.trim()) return true;
686
+
687
+ const defPath = join(projectRoot, '对标', DEFINITIONS_FILENAME);
688
+ if (!existsSync(defPath)) return true;
689
+ const currentDef = readFileSync(defPath, 'utf-8');
690
+ const latestDef = readFileSync(DEFINITIONS_SOURCE, 'utf-8');
691
+ return currentDef.trim() !== latestDef.trim();
692
+ } catch {
693
+ return isLegacyAnalyzeSchema(current);
694
+ }
695
+ }
696
+
697
+ export async function apply(ctx) {
698
+ ctx.commands.register({
699
+ name: 'fancy-analyze',
700
+ description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] | help )',
701
+ input: {
702
+ hint: '.',
703
+ },
704
+ handler: async (invocation) => {
705
+ const session = invocation.agent.session;
706
+ const { signal } = invocation;
707
+
708
+ if ((invocation.rawInput || '').trim().toLowerCase() === 'help') {
709
+ return notify(session, 'success', analyze.notify({ type: 'help' }));
710
+ }
711
+
712
+ const projectRoot = getProjectRoot(session);
713
+ if (!projectRoot) {
714
+ return notify(session, 'error', analyze.notify({
715
+ type: 'no_project_root',
716
+ err: '会话未提供当前工作目录'
717
+ }));
718
+ }
719
+
720
+ // 1. 检查 .fancy-deployed
721
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
722
+ return notify(session, 'error', analyze.notify({ type: 'not_initialized' }));
723
+ }
724
+
725
+ // 2. 解析命令类型
726
+ // 2. 解析命令类型
727
+ //
728
+ // 输入要先**剥掉不可见字符**再判断。
729
+ //
730
+ // ## 为什么
731
+ //
732
+ // 实测用户输入过 `summary-status\u200bsummary`(零宽空格 U+200B 粘在中间)。
733
+ // 旧代码直接 `lowerInput === 'summary-status'` 精确比较,于是这条命令
734
+ // **既不是** summary-status **也不是** summary —— 它掉进了"普通模式"
735
+ // (按书名拆书),而拆书会调 llm_wiki 的 sources/ingest 接口,
736
+ // 那条路**有破坏性**。
737
+ //
738
+ // 子命令判断失败时掉进一个有破坏性的分支,是很糟的默认行为。
739
+ // 所以:
740
+ // 1. 剥掉不可见字符(零宽空格/零宽连接符/BOM/不换行空格…);
741
+ // 2. 用**前缀匹配**认子命令 —— 用户手滑多打了几个字,
742
+ // 意图明显是那个子命令,不该被当成书名去拆书。
743
+ const rawInput = normalizeCommandInput(invocation.rawInput || '');
744
+ const lowerInput = rawInput.toLowerCase();
745
+ const sub = matchSubcommand(lowerInput);
746
+ const isStatus = sub === 'status';
747
+ const isFix = sub === 'fix';
748
+ const isSummary = sub === 'summary';
749
+ const isSummaryStatus = sub === 'summary-status';
750
+ const isResummary = sub === 'resummary';
751
+ const resummaryTarget = isResummary
752
+ ? rawInput.slice('resummary'.length).trim()
753
+ : '';
754
+
755
+ // 2.1 fix 模式:重新 ingest 缺失的文件
756
+ if (isFix) {
757
+ const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
758
+ if (configError) {
759
+ return notify(session, 'error', configError);
760
+ }
761
+
762
+ const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
763
+ const llmWikiToken = deployedConfig.llm_wiki_token || '';
764
+ const referenceId = deployedConfig.reference_id || '';
765
+
766
+ if (!llmWikiUrl || !llmWikiToken) {
767
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
768
+ }
769
+
770
+ if (!referenceId) {
771
+ return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
772
+ }
773
+
774
+ const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
775
+ const hasSources = hasAnySourceFiles(rawSourcesDir);
776
+ const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
777
+ if (!hasSources && !hasOutputs) {
778
+ return notify(session, 'error', analyze.notify({ type: 'fix_not_analyzed' }));
779
+ }
780
+
781
+ // fix 是迁移旧项目的主要入口:先把 对标/schema.md 换成新版,
782
+ // 否则重新 ingest 会继续按旧 schema 生成按章结构页。
783
+ // 这一步放在 .retry 之前:即使没有缺失章节,用户也能用 fix 完成
784
+ // schema 迁移,而不会被“没有需要重试的项目”提前挡回去。
785
+ let schemaUpdated = false;
786
+ try {
787
+ if (schemaNeedsRefresh(projectRoot)) {
788
+ copySchemaFile(join(projectRoot, '对标'));
789
+ schemaUpdated = true;
790
+ }
791
+ } catch (err) {
792
+ const msg = err instanceof Error ? err.message : String(err);
793
+ return notify(session, 'error',
794
+ `❌ 无法更新 对标/schema.md:${msg}\n\n` +
795
+ '续跑 fix 仍会按旧 schema 生成按章结构页,所以本次没有提交 ingest。\n' +
796
+ '请确认 对标/ 可写后重试。');
797
+ }
798
+ const schemaNote = schemaUpdated
799
+ ? '## 已更新对标 schema\n对标/schema.md 已更新为新版(写作技法 + 跨章节实体页)。\n\n'
800
+ : '';
801
+ const withSchemaNote = (text) => schemaNote ? schemaNote + text : text;
802
+
803
+ // 读取 .retry 文件
804
+ const retryFilePath = join(projectRoot, '.retry');
805
+ if (!existsSync(retryFilePath)) {
806
+ return notify(session, 'error', withSchemaNote(`未找到 .retry 文件。
807
+ 1. 如果已执行过 /fancy-analyze status,说明已成功提取,可以继续执行 /fancy-analyze summary;
808
+ 2. 如果尚未执行过 /fancy-analyze status,请先执行该命令。`));
809
+ }
810
+
811
+ let retryData;
812
+ try {
813
+ retryData = JSON.parse(readFileSync(retryFilePath, 'utf-8'));
814
+ } catch {
815
+ return notify(session, 'error', withSchemaNote('.retry 文件格式错误,请检查文件内容后重试。'));
816
+ }
817
+
818
+ if (!retryData || !Array.isArray(retryData.items) || retryData.items.length === 0) {
819
+ return notify(session, 'success', withSchemaNote('当前没有需要重试的项目。'));
820
+ }
821
+
822
+ // status 额外写出的“已有页面 / 缺失页面”清单。旧项目没有这个文件时,
823
+ // fix 自动退回原来的通用修复指令。
824
+ let retryPageInfo = {};
825
+ try {
826
+ const pagesPath = join(projectRoot, '.retry-pages.json');
827
+ if (existsSync(pagesPath)) {
828
+ const parsed = JSON.parse(readFileSync(pagesPath, 'utf-8'));
829
+ if (parsed && typeof parsed === 'object') retryPageInfo = parsed;
830
+ }
831
+ } catch {
832
+ retryPageInfo = {};
833
+ }
834
+
835
+ const results = [];
836
+ const errors = [];
837
+
838
+ // .retry 是 status 写的(现在有序),但老文件可能是乱序的 ——
839
+ // 补传顺序跟着章号走,跟 ingest 的正常流程保持一致。
840
+ for (const item of sortByChapter(retryData.items, (it) => it && it.bookName)) {
841
+ const { bookName, chapter } = item;
842
+ // 定位文件:书名/ 目录下查找文件名包含"第X章"的文件
843
+ const bookDir = join(rawSourcesDir, bookName);
844
+ if (!existsSync(bookDir)) {
845
+ errors.push(`未找到目录:${bookName}`);
846
+ continue;
847
+ }
848
+
849
+ let matchedFiles = [];
850
+ try {
851
+ const files = readdirSync(bookDir);
852
+ // 查找文件名中包含"第X章"的文件(如:第8章-心好痛.txt)
853
+ // chapter 来自 .retry(status 生成的"第N章"),但仍要转义:
854
+ // 直接拼进正则的话,一旦内容里出现正则元字符就会抛 SyntaxError,
855
+ // 而且 "." 未转义会让"第1章"匹配上"第1章X"这种文件名。
856
+ const pattern = new RegExp(`${escapeRegExp(chapter)}.*\\.txt$`);
857
+ //
858
+ // **要全部匹配到的分片,不能只取第一个** ——
859
+ // 超长章会被 split_source.js 二次切分成
860
+ // 第7章-武科五大关-1.txt / 第7章-武科五大关-2.txt
861
+ // 两个文件名都匹配同一个正则,而 files.find() 只返回其中一个,
862
+ // 另一个分片永远补不上:服务端那一章的产物始终是残缺的
863
+ // (第一次 ingest 是把整章的分片一起传的,服务端会合并成
864
+ // 一章的产物;只补一半反而会把完整产物覆盖成半章)。
865
+ //
866
+ // 排序用自然序,保证「第-10」排在「第-2」后面,
867
+ // 补传顺序与初次 ingest 一致。
868
+ matchedFiles = files
869
+ .filter(f => pattern.test(f))
870
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
871
+ if (matchedFiles.length === 0) {
872
+ errors.push(`未找到文件:${bookName} 下包含 ${chapter} 的文件`);
873
+ continue;
874
+ }
875
+
876
+ // 清除 ingest-cache 中对应条目的 hash
877
+ // (一次读、一次改、一次写:多个分片共用一份 cache,
878
+ // 循环里反复读写会把前一次的结果覆盖掉)
879
+ const ingestCachePath = join(projectRoot, '对标', '.llm-wiki', 'ingest-cache.json');
880
+ if (existsSync(ingestCachePath)) {
881
+ try {
882
+ const cacheContent = readFileSync(ingestCachePath, 'utf-8');
883
+ const cacheData = JSON.parse(cacheContent);
884
+ let cacheChanged = false;
885
+ for (const f of matchedFiles) {
886
+ const cacheKey = `${bookName}/${f}`;
887
+ if (cacheData.entries && cacheData.entries[cacheKey]) {
888
+ cacheData.entries[cacheKey].hash = '';
889
+ cacheChanged = true;
890
+ }
891
+ }
892
+ if (cacheChanged) {
893
+ writeFileSync(ingestCachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
894
+ }
895
+ } catch {
896
+ // 忽略缓存更新错误
897
+ }
898
+ }
899
+
900
+ // 整章的分片一起传 —— 与初次 ingest 的语义一致
901
+ // (初次是 split_files 全部一次传给服务端)。
902
+ const filePaths = matchedFiles.map(f => `raw/sources/${bookName}/${f}`);
903
+ // 必须检查返回:ingest 返回 4xx/5xx 时 llm_wiki 根本没收到文件,
904
+ // 之前这里不检查,命令照报"## 重新 ingest 成功"(实测:两个 POST
905
+ // 都返回 500,用户看到的仍是"成功")。
906
+ const ingestResult = await callLlmWikiApi(
907
+ llmWikiUrl,
908
+ llmWikiToken,
909
+ `/projects/${referenceId}/sources/ingest`,
910
+ 'POST',
911
+ {
912
+ paths: filePaths,
913
+ instruction: buildFixInstruction(
914
+ bookName,
915
+ chapter,
916
+ retryPageInfo[`${bookName}::${chapter}`] || null,
917
+ ),
918
+ }
919
+ );
920
+ const apiErr = llmWikiError(ingestResult);
921
+ if (apiErr) {
922
+ errors.push(`${bookName}/${matchedFiles.join('、')} - ${apiErr}`);
923
+ continue;
924
+ }
925
+ for (const f of matchedFiles) {
926
+ results.push(`${bookName}/${f}`);
927
+ }
928
+ } catch (err) {
929
+ errors.push(`${bookName}/${chapter} - ${describeFetchError(err)}`);
930
+ }
931
+ }
932
+
933
+ let message = '';
934
+ if (results.length > 0) {
935
+ message += '## 重新 ingest 成功\n' + results.join('\n');
936
+ }
937
+ if (errors.length > 0) {
938
+ if (results.length > 0) {
939
+ message += '\n';
940
+ }
941
+ message += '## 错误\n' + errors.join('\n');
942
+ }
943
+
944
+ // 全部失败时,标题要单独一行 —— 之前 "重新 ingest 失败:" 直接
945
+ // 粘在 "## 错误" 前面,渲染出来是一行乱糟糟的文本。
946
+ const headline = (errors.length > 0 && results.length === 0)
947
+ ? '重新 ingest 失败:\n\n'
948
+ : '';
949
+ const finalMessage = headline + schemaNote + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
950
+ return notify(session, results.length > 0 ? 'success' : 'error', finalMessage);
951
+ }
952
+
953
+ // 2.2 status 模式
954
+ if (isStatus) {
955
+ const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
956
+ if (configError) {
957
+ return notify(session, 'error', configError);
958
+ }
959
+
960
+ const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
961
+ const llmWikiToken = deployedConfig.llm_wiki_token || '';
962
+ const referenceId = deployedConfig.reference_id || '';
963
+
964
+ if (!llmWikiUrl || !llmWikiToken) {
965
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
966
+ }
967
+
968
+ if (!referenceId) {
969
+ return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
970
+ }
971
+
972
+ const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
973
+ const hasSources = hasAnySourceFiles(rawSourcesDir);
974
+ const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
975
+ if (!hasSources && !hasOutputs) {
976
+ return notify(session, 'error', analyze.notify({ type: 'status_not_analyzed' }));
977
+ }
978
+
979
+ const schemaOutdated = isLegacyAnalyzeSchema(readAnalyzeSchema(projectRoot));
980
+
981
+ // 检查来源目录下的文件,从"### 列表"表格中获取【有/无】信息
982
+ // 同时读取"来源"和"来源摘要"两个目录
983
+ // 映射格式:{ "书名": { "第X章": { "技法名": true/false, ... }, ... } }
984
+ const bookChapterTechHas = {};
985
+ // 记录缺少列表的章节
986
+ // 用 let:下面会按(书名, 章号)重排 —— readdirSync 返回的是目录项
987
+ // 哈希序(ext4 上表现为第7章排在第4章前面),不是字典序。
988
+ let missingListReports = [];
989
+ let structuralTableSeen = false;
990
+ let legacyStructuralTableSeen = false;
991
+ const entityRows = [];
992
+ const legacySourceChapters = [];
993
+
994
+ // "来源"和"来源摘要"两个目录里已有的 .md 文件名。
995
+ // 用来回答:"raw/sources 里的这个分片,拆解产物落地了吗?"
996
+ const sourceFileNames = new Set();
997
+
998
+ const sourceDirs = ['来源', '来源摘要'];
999
+ for (const sourceDir of sourceDirs) {
1000
+ const duiBiaoWikiDir = join(projectRoot, '对标', 'wiki', sourceDir);
1001
+ if (!existsSync(duiBiaoWikiDir)) continue;
1002
+
1003
+ try {
1004
+ const sourceFiles = readdirSync(duiBiaoWikiDir);
1005
+ for (const file of sourceFiles) {
1006
+ if (!file.endsWith('.md')) continue;
1007
+ sourceFileNames.add(file);
1008
+ // 提取书名和第X章(如:全球高武-第10章-气血检查.md → 书名:全球高武, 第X章:第10章)
1009
+ // 格式:书名-第X章-xxx.md 或 书名-技法名-第X章.md
1010
+ const fullMatch = file.match(/^(.+?)-(第\d+章)-/);
1011
+ if (!fullMatch) continue;
1012
+ const bookName = fullMatch[1];
1013
+ const chapterKey = fullMatch[2];
1014
+
1015
+ if (!bookChapterTechHas[bookName]) {
1016
+ bookChapterTechHas[bookName] = {};
1017
+ }
1018
+
1019
+ const content = readFileSync(join(duiBiaoWikiDir, file), 'utf-8');
1020
+
1021
+ // 来源页没有新版结构表 → 这一章还是旧 schema 的产物。
1022
+ // 只认 来源/,不认 来源摘要/:摘要页本来就可能没有技法表。
1023
+ const hasNewStructureTables = content.includes('### 跨章节结构实体')
1024
+ || content.includes('### 章节级结构设计');
1025
+ if (sourceDir === '来源' && !hasNewStructureTables) {
1026
+ legacySourceChapters.push({ bookName, chapter: chapterKey, file });
1027
+ }
1028
+
1029
+ // 查找"### 列表"三级标题的位置
1030
+ const listHeadingIndex = content.indexOf('### 列表');
1031
+ if (listHeadingIndex === -1) {
1032
+ // 记录缺少列表的章节
1033
+ missingListReports.push(`${bookName}${chapterKey}的列表未找到`);
1034
+ continue;
1035
+ }
1036
+
1037
+ if (content.includes('### 跨章节结构实体') || content.includes('### 章节级结构设计')) {
1038
+ structuralTableSeen = true;
1039
+ }
1040
+ // 上一版 schema 的按章结构设计表;只用于提示迁移,不参与检查。
1041
+ if (content.includes('### 结构设计分析')) legacyStructuralTableSeen = true;
1042
+ // 按章类型:写作技法 + 章节功能设计。
1043
+ // 过滤旧类型,避免 status 追着已废弃的按章页面不放。
1044
+ const techHas = {
1045
+ ...filterChapterTypes(parseHasTable(content, '### 列表')),
1046
+ ...filterChapterTypes(parseHasTable(content, '### 章节级结构设计')),
1047
+ };
1048
+ bookChapterTechHas[bookName][chapterKey] = techHas;
1049
+ entityRows.push(...parseEntityRows(content, bookName, chapterKey));
1050
+ }
1051
+ } catch {
1052
+ // 忽略读取错误
1053
+ }
1054
+ }
1055
+
1056
+ // 检查各技法目录下是否有对应的文件
1057
+ // 映射格式:{ "书名": { "技法名": ["第X章", "第Y章", ...], ... }, ... }
1058
+ const bookTechChapters = {};
1059
+ const legacyStructureDirs = new Map();
1060
+
1061
+ const duiBiaoWikiRoot = join(projectRoot, '对标', 'wiki');
1062
+ if (existsSync(duiBiaoWikiRoot)) {
1063
+ const allDirs = readdirSync(duiBiaoWikiRoot);
1064
+ for (const dir of allDirs) {
1065
+ // 跳过来源相关目录
1066
+ if (dir === '来源' || dir === '来源摘要') continue;
1067
+
1068
+ const dirPath = join(duiBiaoWikiRoot, dir);
1069
+ let dirStat = null;
1070
+ try { dirStat = statSync(dirPath); } catch { continue; }
1071
+ if (!dirStat.isDirectory()) continue;
1072
+
1073
+ let files = [];
1074
+ try { files = readdirSync(dirPath); } catch { continue; }
1075
+
1076
+ // 旧版按章结构页:只统计并提示迁移,不当作新版技法/实体页。
1077
+ if (LEGACY_STRUCTURE_DIRS.includes(dir)) {
1078
+ const pageCount = files.filter(
1079
+ (file) => file.endsWith('.md') && !file.endsWith('-总结.md')).length;
1080
+ legacyStructureDirs.set(dir, pageCount);
1081
+ continue;
1082
+ }
1083
+ for (const file of files) {
1084
+ if (!file.endsWith('.md')) continue;
1085
+ // 提取书名和第X章(如:全球高武-节奏韵律-第10章.md → 书名:全球高武, 第X章:第10章)
1086
+ const fullMatch = file.match(/^(.+?)-.+?-(第\d+章)/);
1087
+ if (fullMatch) {
1088
+ const bookName = fullMatch[1];
1089
+ const chapterKey = fullMatch[2];
1090
+ const techName = dir; // 目录名就是技法名
1091
+
1092
+ if (!bookTechChapters[bookName]) {
1093
+ bookTechChapters[bookName] = {};
1094
+ }
1095
+ if (!bookTechChapters[bookName][techName]) {
1096
+ bookTechChapters[bookName][techName] = [];
1097
+ }
1098
+ bookTechChapters[bookName][techName].push(chapterKey);
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ // 跨章节实体页:按“类型 + 文件名”索引,并读取正文用于检查章节小节。
1105
+ const entityPageContents = new Map();
1106
+ const legacyEntityChapterPages = new Map();
1107
+ for (const type of ENTITY_STRUCTURE_TYPES) {
1108
+ const dirPath = join(duiBiaoWikiRoot, type);
1109
+ if (!existsSync(dirPath)) continue;
1110
+ let files = [];
1111
+ try { files = readdirSync(dirPath); } catch { continue; }
1112
+ for (const file of files) {
1113
+ if (!file.endsWith('.md')) continue;
1114
+ if (file.endsWith('-总结.md')) continue;
1115
+ // 实体目录里的按章文件是上一版 schema 的产物;不把它当成
1116
+ // 新版实体页,否则 status 会误以为实体页已经存在。
1117
+ if (/第\d+章/.test(file)) {
1118
+ const list = legacyEntityChapterPages.get(type) || [];
1119
+ list.push(file);
1120
+ legacyEntityChapterPages.set(type, list);
1121
+ continue;
1122
+ }
1123
+ try {
1124
+ entityPageContents.set(
1125
+ `${type}::${file}`,
1126
+ readFileSync(join(dirPath, file), 'utf-8'),
1127
+ );
1128
+ } catch {
1129
+ // 读不到就当缺失,下面会报出来
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ // 章节列表按(书名, 章号)排 —— 遍历用的是 readdirSync 的返回顺序
1135
+ missingListReports = sortByChapter(missingListReports);
1136
+
1137
+ // ---- 拆解产物缺失检查 ----
1138
+ //
1139
+ // 前两项检查都是"文件在 来源/ 里存在"的前提下做的:
1140
+ // · "列表未找到" —— 文件在,但内容里没有 ### 列表 段;
1141
+ // · "缺失检测" —— 文件在,列表说某技法"有",但技法文件没落地。
1142
+ //
1143
+ // 但还有一种更靠前的情况压根没被检查到:**分片在 raw/sources/ 里,
1144
+ // 而 来源/ 和 来源摘要/ 里连对应的章节文件都没有** —— 这一章
1145
+ // 根本没被拆解,或者拆了但没落盘。
1146
+ //
1147
+ // 服务端的 missingFiles 只反映它自己的队列状态,拆解"跑完了"
1148
+ // 但产物没落盘这种情况它不知道,所以要本地查这一份。
1149
+ const missingIngestOutputs = findMissingIngestOutputs({
1150
+ rawSourcesDir,
1151
+ sourceFileNames,
1152
+ });
1153
+ const missingIngestReports = sortByChapter(
1154
+ missingIngestOutputs.map(o => o.report));
1155
+ const missingIngestItems = missingIngestOutputs
1156
+ .filter(o => o.chapter)
1157
+ .map(o => ({ bookName: o.bookName, chapter: o.chapter }));
1158
+
1159
+ // 比对结果:找出"标记为有、但技法目录里没有对应文件"的章节。
1160
+ // 之前同一段逻辑算了两遍(一次出报告、一次出 .retry),
1161
+ // 两份循环还得保持一致,改一处漏一处就会让报告和 .retry 对不上。
1162
+ let missingReports = [];
1163
+ const retryItems = [];
1164
+ const retryReasons = new Map();
1165
+ const addRetry = (bookName, chapter, reason) => {
1166
+ if (!bookName || !chapter) return;
1167
+ const key = bookName + '::' + chapter;
1168
+ if (reason) {
1169
+ const set = retryReasons.get(key) || new Set();
1170
+ set.add(reason);
1171
+ retryReasons.set(key, set);
1172
+ }
1173
+ const exists = retryItems.some(
1174
+ (item) => item.bookName === bookName && item.chapter === chapter);
1175
+ if (!exists) retryItems.push({ bookName, chapter });
1176
+ };
1177
+ for (const [bookName, chapterTechHas] of Object.entries(bookChapterTechHas)) {
1178
+ for (const [chapter, techHas] of Object.entries(chapterTechHas)) {
1179
+ const missingTechs = [];
1180
+ for (const [tech, hasValue] of Object.entries(techHas)) {
1181
+ // 只检查"有"的技法
1182
+ if (hasValue !== true) continue;
1183
+ // 检查该技法目录下是否有该章节的文件
1184
+ const hasFile = bookTechChapters[bookName]?.[tech]?.includes(chapter);
1185
+ if (!hasFile) {
1186
+ missingTechs.push(tech);
1187
+ }
1188
+ }
1189
+ if (missingTechs.length > 0) {
1190
+ missingReports.push(`${bookName}${chapter}的ingest有缺失,缺失的为${missingTechs.join('、')}`);
1191
+ addRetry(bookName, chapter, '按章技法页缺失:' + missingTechs.join('、'));
1192
+ }
1193
+ }
1194
+ }
1195
+ // 跨章节实体页检查:页面不存在 → 重 ingest 首次章节;
1196
+ // 页面存在但没有本次章节小节 → 重 ingest 本次章节。
1197
+ const entityMissingReports = [];
1198
+ const entitySectionGaps = new Map();
1199
+ const pushRetry = (bookName, chapter, reason) => addRetry(bookName, chapter, reason);
1200
+ // 同一实体可能在多章被引用;按“类型 + 页面文件”分组后再报,
1201
+ // 否则一个缺失页面会按章重复几十行,真正的缺失反而被淹没。
1202
+ const entityGroups = new Map();
1203
+ for (const row of entityRows) {
1204
+ const chapter = row.currentChapter || row.chapterKey;
1205
+ const key = `${row.bookName}::${row.type}::${row.pageFile}`;
1206
+ let group = entityGroups.get(key);
1207
+ if (!group) {
1208
+ group = {
1209
+ bookName: row.bookName,
1210
+ type: row.type,
1211
+ pageFile: row.pageFile,
1212
+ invalidPageFile: row.invalidPageFile,
1213
+ entities: new Set(),
1214
+ chapters: new Set(),
1215
+ };
1216
+ entityGroups.set(key, group);
1217
+ }
1218
+ group.entities.add(row.entity);
1219
+ if (chapter) group.chapters.add(chapter);
1220
+ if (row.invalidPageFile) group.invalidPageFile = true;
1221
+ }
1222
+
1223
+ for (const group of entityGroups.values()) {
1224
+ const chapters = [...group.chapters].sort(
1225
+ (a, b) => (chapterNumber(a) ?? Number.MAX_SAFE_INTEGER) -
1226
+ (chapterNumber(b) ?? Number.MAX_SAFE_INTEGER));
1227
+ const firstChapter = chapters[0] || null;
1228
+ const entityLabel = [...group.entities].join('、');
1229
+ if (group.invalidPageFile) {
1230
+ entityMissingReports.push(
1231
+ `${group.bookName}的${group.type}实体「${entityLabel}」页面文件名 ${group.pageFile} 带章节号;跨章节实体页必须用稳定实体名,不能按章建页`);
1232
+ for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页文件名带章节号:' + group.pageFile);
1233
+ continue;
1234
+ }
1235
+ const content = entityPageContents.get(`${group.type}::${group.pageFile}`);
1236
+ if (content === undefined) {
1237
+ entityMissingReports.push(
1238
+ `${group.bookName}的${group.type}实体「${entityLabel}」缺少页面 ${group.pageFile}(首次出现 ${firstChapter || '未知'};涉及 ${chapters.join('、') || '未知章节'})`);
1239
+ for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页整页缺失:' + group.pageFile);
1240
+ continue;
1241
+ }
1242
+ for (const chapter of chapters) {
1243
+ if (hasChapterHeading(content, chapter)) continue;
1244
+ entityMissingReports.push(
1245
+ `${group.bookName}的${group.type}实体「${entityLabel}」页面 ${group.pageFile} 缺少 ${chapter} 小节`);
1246
+ entitySectionGaps.set(
1247
+ group.type, (entitySectionGaps.get(group.type) || 0) + 1);
1248
+ pushRetry(group.bookName, chapter, '实体页缺少 ' + chapter + ' 小节:' + group.pageFile);
1249
+ }
1250
+ }
1251
+
1252
+ // 角色弧光覆盖的启发式检查:人物关系页的两端通常就是角色。
1253
+ // 缺页的角色所在关系章节进 .retry,fix 重传后才会按新版 schema
1254
+ // 为这些角色补角色弧光页。
1255
+ let missingArcs = [];
1256
+ const missingArcChapters = new Map();
1257
+ {
1258
+ const arcNames = new Set();
1259
+ for (const row of entityRows) {
1260
+ if (row.type !== '角色弧光设计') continue;
1261
+ const entity = String(row.entity || '').trim();
1262
+ if (entity) arcNames.add(entity);
1263
+ // 弧光页可能用“老班”建档,但正式姓名“刘安国”写在 tags 或正文里。
1264
+ // 只看 entity 会把这种别名的关系端点误报成“缺角色弧光页”。
1265
+ const content = entityPageContents.get(`${row.type}::${row.pageFile}`) || '';
1266
+ for (const tag of parseFrontmatterTags(content)) arcNames.add(tag);
1267
+ for (const alias of parseAliasesFromContent(content)) arcNames.add(alias);
1268
+ }
1269
+ const relationCharacterNames = new Set();
1270
+ const nonCharacterName = /(父母|家人|兄弟|姐妹|同学|老师|全班|众人|人群|集团|公司|大学|学院|医院|银行|协会|联盟|家族|国家|班级|政府|部门|学校|武大|一中)/u;
1271
+ for (const row of entityRows) {
1272
+ if (row.type !== '人物关系设计') continue;
1273
+ for (const part of String(row.entity || '').split(/[-—–与、,,]/u)) {
1274
+ const name = part.trim();
1275
+ if (name.length < 2 || name.length > 4) continue;
1276
+ if (nonCharacterName.test(name)) continue;
1277
+ relationCharacterNames.add(name);
1278
+ }
1279
+ }
1280
+ missingArcs = [...relationCharacterNames].filter((name) => !arcNames.has(name));
1281
+ for (const name of missingArcs) {
1282
+ const byBook = new Map();
1283
+ for (const row of entityRows) {
1284
+ if (row.type !== '人物关系设计') continue;
1285
+ const parts = String(row.entity || '')
1286
+ .split(/[-—–与、,,]/u)
1287
+ .map((part) => part.trim());
1288
+ if (!parts.includes(name)) continue;
1289
+ const chapter = row.currentChapter || row.chapterKey;
1290
+ if (!chapter) continue;
1291
+ if (!byBook.has(row.bookName)) byBook.set(row.bookName, new Set());
1292
+ byBook.get(row.bookName).add(chapter);
1293
+ }
1294
+ for (const [bookName, chapters] of byBook) {
1295
+ missingArcChapters.set(bookName + '::' + name, chapters);
1296
+ for (const chapter of chapters) {
1297
+ pushRetry(bookName, chapter, '角色弧光页整页缺失:' + name);
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ // 旧 schema 的来源页:重传这一章,才能生成「### 跨章节结构实体」
1304
+ // 表和后续实体页。没有这一步,旧项目永远停留在按章结构页。
1305
+ for (const item of legacySourceChapters) {
1306
+ pushRetry(item.bookName, item.chapter, '来源页需要重生成跨章节结构实体表');
1307
+ }
1308
+
1309
+ // 实体目录里已经存在按章文件:重传对应章节,让 ingest 先把它们
1310
+ // 合并成稳定实体页,再补本章内容。
1311
+ for (const files of legacyEntityChapterPages.values()) {
1312
+ for (const file of files) {
1313
+ const match = file.match(/^(.+?)-.*?第(\d+)章/);
1314
+ if (!match) continue;
1315
+ pushRetry(match[1], `第${match[2]}章`, '按章命名的实体页需要合并:' + file);
1316
+ }
1317
+ }
1318
+
1319
+ // 添加缺少列表的章节到 retry
1320
+ for (const report of missingListReports) {
1321
+ const match = report.match(/^(.+?)(第\d+章)的列表未找到/);
1322
+ if (match) {
1323
+ const bookName = match[1];
1324
+ const chapter = match[2];
1325
+ const exists = retryItems.some(item => item.bookName === bookName && item.chapter === chapter);
1326
+ if (!exists) {
1327
+ addRetry(bookName, chapter, '来源页缺少「### 列表」表');
1328
+ }
1329
+ }
1330
+ }
1331
+ // 拆解产物缺失的章节同样进 retry —— 补传这一章才能把产物补出来
1332
+ for (const item of missingIngestItems) {
1333
+ const exists = retryItems.some(
1334
+ r => r.bookName === item.bookName && r.chapter === item.chapter);
1335
+ if (!exists) {
1336
+ addRetry(item.bookName, item.chapter, '整章拆解产物缺失');
1337
+ }
1338
+ }
1339
+ // 章节列表按(书名, 章号)排
1340
+ missingReports = sortByChapter(missingReports);
1341
+
1342
+ // 给 fix 准备“已有哪些页面 / 本次缺哪些页面”。
1343
+ // 写在单独的 .retry-pages.json,避免改变 .retry 的既有结构。
1344
+ const buildRetryPageInfo = (bookName, chapter) => {
1345
+ const existing = new Set();
1346
+ const missing = new Set();
1347
+
1348
+ const sourcePrefix = `${bookName}-${chapter}-`;
1349
+ const sourcePages = [...sourceFileNames].filter((file) => file.startsWith(sourcePrefix));
1350
+ for (const file of sourcePages) {
1351
+ const sourceDir = existsSync(join(projectRoot, '对标', 'wiki', '来源', file))
1352
+ ? '来源'
1353
+ : existsSync(join(projectRoot, '对标', 'wiki', '来源摘要', file))
1354
+ ? '来源摘要'
1355
+ : '来源';
1356
+ existing.add(`wiki/${sourceDir}/${file}`);
1357
+ }
1358
+ if (sourcePages.length === 0) {
1359
+ missing.add(`wiki/来源/${bookName}-${chapter}-*.md(来源页)`);
1360
+ }
1361
+
1362
+ const techHas = bookChapterTechHas[bookName]?.[chapter] || {};
1363
+ for (const [tech, hasValue] of Object.entries(techHas)) {
1364
+ if (hasValue !== true) continue;
1365
+ const pagePath = `wiki/${tech}/${bookName}-${tech}-${chapter}.md`;
1366
+ if (bookTechChapters[bookName]?.[tech]?.includes(chapter)) {
1367
+ existing.add(pagePath);
1368
+ } else {
1369
+ missing.add(pagePath);
1370
+ }
1371
+ }
1372
+
1373
+ const rows = entityRows.filter(
1374
+ (row) => row.bookName === bookName &&
1375
+ (row.currentChapter || row.chapterKey) === chapter);
1376
+ for (const row of rows) {
1377
+ if (row.invalidPageFile) {
1378
+ missing.add(`wiki/${row.type}/${row.pageFile}(文件名带章节号,必须改成稳定实体名)`);
1379
+ continue;
1380
+ }
1381
+ const pagePath = `wiki/${row.type}/${row.pageFile}`;
1382
+ const content = entityPageContents.get(`${row.type}::${row.pageFile}`);
1383
+ if (content === undefined) {
1384
+ missing.add(`${pagePath}(实体页整页缺失)`);
1385
+ } else if (!hasChapterHeading(content, chapter)) {
1386
+ missing.add(`${pagePath}(缺少 ${chapter} 小节)`);
1387
+ } else {
1388
+ existing.add(pagePath);
1389
+ }
1390
+ }
1391
+
1392
+ // 角色弧光缺页:必须把具体页面写进 .retry-pages.json,
1393
+ // 否则 fix 的提示词没有可列出的页面,只能让模型去猜。
1394
+ for (const [key, chapters] of missingArcChapters) {
1395
+ if (!chapters.has(chapter)) continue;
1396
+ const sep = key.indexOf('::');
1397
+ const arcBook = key.slice(0, sep);
1398
+ const arcName = key.slice(sep + 2);
1399
+ if (arcBook !== bookName || !arcName) continue;
1400
+ missing.add(`wiki/角色弧光设计/${bookName}-角色-${arcName}.md(整页缺失)`);
1401
+ }
1402
+
1403
+ // 其他无法枚举成页面路径的缺失原因(来源页缺列表、旧 schema 等)
1404
+ // 也要明确写出来,不能让 fix 去猜。
1405
+ if (missing.size === 0) {
1406
+ const reasons = retryReasons.get(bookName + '::' + chapter);
1407
+ if (reasons) {
1408
+ for (const reason of reasons) missing.add(reason);
1409
+ }
1410
+ }
1411
+
1412
+ if (sourcePages.length === 0 && rows.length === 0 &&
1413
+ !Object.values(techHas).some((value) => value === true)) {
1414
+ missing.add('整章拆解产物(来源页 + 按章页面 + 实体页)');
1415
+ }
1416
+
1417
+ return { existing: [...existing], missing: [...missing] };
1418
+ };
1419
+
1420
+ const retryPageInfo = {};
1421
+ for (const item of retryItems) {
1422
+ retryPageInfo[`${item.bookName}::${item.chapter}`] =
1423
+ buildRetryPageInfo(item.bookName, item.chapter);
1424
+ }
1425
+
1426
+ // .retry 里的 items 同样排序:它是按缺失检测的遍历顺序 push 的,
1427
+ // 乱序会让 /fancy-analyze fix 的补传顺序跟着乱。
1428
+ const sortedRetryItems = sortByChapter(retryItems, (it) => it && it.bookName);
1429
+
1430
+ // 根据当前检查结果覆盖 .retry 文件
1431
+ // 写失败必须说出来:写不进去的话,后面 /fancy-analyze fix 只会报
1432
+ // "没有找到 .retry 文件",用户完全不知道真正的失败原因在这里。
1433
+ let retryWriteError = null;
1434
+ let retryPagesWriteError = null;
1435
+ try {
1436
+ if (sortedRetryItems.length > 0) {
1437
+ writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items: sortedRetryItems }, null, 2), 'utf-8');
1438
+ } else {
1439
+ // 没有缺失项时删除 .retry 文件
1440
+ const retryFilePath = join(projectRoot, '.retry');
1441
+ if (existsSync(retryFilePath)) {
1442
+ unlinkSync(retryFilePath);
1443
+ }
1444
+ }
1445
+ } catch (err) {
1446
+ retryWriteError = err instanceof Error ? err.message : String(err);
1447
+ }
1448
+
1449
+ try {
1450
+ const pagesPath = join(projectRoot, '.retry-pages.json');
1451
+ if (sortedRetryItems.length > 0) {
1452
+ writeFileSync(pagesPath, JSON.stringify(retryPageInfo, null, 2), 'utf-8');
1453
+ } else if (existsSync(pagesPath)) {
1454
+ unlinkSync(pagesPath);
1455
+ }
1456
+ } catch (err) {
1457
+ retryPagesWriteError = err instanceof Error ? err.message : String(err);
1458
+ }
1459
+
1460
+ try {
1461
+ const result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
1462
+
1463
+ // 既要查 data.ok,也要查 HTTP 状态码 —— llm_wiki 返回 502/401 时
1464
+ // data 里可能没有 error 字段,只判 ok 会漏。
1465
+ const queryErr = llmWikiError(result);
1466
+ if (queryErr || !result.data || !result.data.ok) {
1467
+ return notify(session, 'error', analyze.notify({
1468
+ type: 'status_query_failed',
1469
+ err: queryErr || result.data?.error || '查询失败'
1470
+ }));
1471
+ }
1472
+
1473
+ const missingFiles = sortByChapter(result.data.missingFiles || []);
1474
+ const queue = result.data.queue || {};
1475
+
1476
+ const entityStats = ENTITY_STRUCTURE_TYPES.map((type) => {
1477
+ const pages = new Set(
1478
+ entityRows.filter((row) => row.type === type).map((row) => row.pageFile));
1479
+ const missing = [...pages].filter(
1480
+ (file) => !entityPageContents.has(`${type}::${file}`)).length;
1481
+ return {
1482
+ type,
1483
+ entities: pages.size,
1484
+ missing,
1485
+ sectionGaps: entitySectionGaps.get(type) || 0,
1486
+ };
1487
+ });
1488
+ const coverageLines = [];
1489
+ if (entityStats.some((s) => s.entities > 0)) {
1490
+ coverageLines.push('## 跨章节实体覆盖');
1491
+ for (const s of entityStats) {
1492
+ const arcGap = s.type === '角色弧光设计' ? missingArcs.length : 0;
1493
+ if (s.entities === 0) {
1494
+ coverageLines.push(`- ℹ️ ${s.type}:0 个(本书未引用,不算缺失)`);
1495
+ } else if (s.missing > 0 || s.sectionGaps > 0 || arcGap > 0) {
1496
+ const parts = [`${s.entities} 个实体页`];
1497
+ if (s.missing > 0) parts.push(`缺 ${s.missing} 个`);
1498
+ if (s.sectionGaps > 0) parts.push(`另有 ${s.sectionGaps} 个章节小节缺失`);
1499
+ if (arcGap > 0) parts.push(`另有 ${arcGap} 个角色缺页`);
1500
+ coverageLines.push(`- ❌ ${s.type}:${parts.join(',')}`);
1501
+ } else {
1502
+ coverageLines.push(`- ✅ ${s.type}:${s.entities} 个实体页`);
1503
+ }
1504
+ }
1505
+ const totalMissingPages = entityStats.reduce((n, s) => n + s.missing, 0);
1506
+ const hasLegacyStructureFiles = legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0;
1507
+ coverageLines.push('');
1508
+ if (hasLegacyStructureFiles) {
1509
+ coverageLines.push('⚠️ 结构设计覆盖未完成:还有旧版/按章命名的实体文件需要迁移。');
1510
+ } else if (missingArcs.length > 0) {
1511
+ coverageLines.push(`❌ 角色弧光覆盖不完整:${missingArcs.length} 个角色缺角色弧光页。`);
1512
+ } else if (entityMissingReports.length === 0 && totalMissingPages === 0) {
1513
+ coverageLines.push('✅ 结构设计覆盖完整,未发现缺失。');
1514
+ }
1515
+ coverageLines.push('');
1516
+ }
1517
+ if (legacyStructureDirs.size > 0) {
1518
+ coverageLines.push('## 旧版结构页(不参与新实体检查)');
1519
+ for (const [name, pageCount] of legacyStructureDirs) {
1520
+ coverageLines.push(`- ⚠️ ${name}:${pageCount} 个按章页面`);
1521
+ }
1522
+ coverageLines.push(' ⚠️ 新版改用跨章节实体页:伏笔回收→伏笔布局、世界观构建/世界观规则设计→世界观设计、角色弧光→角色弧光设计。');
1523
+ coverageLines.push(' ⚠️ 重新 ingest 对应章节后由新 schema 生成实体页;旧页面不会自动迁移,可确认后手动删除。');
1524
+ coverageLines.push('');
1525
+ }
1526
+ if (legacyEntityChapterPages.size > 0) {
1527
+ coverageLines.push('## 按章命名的实体页(不符合跨章节规则)');
1528
+ for (const [type, files] of legacyEntityChapterPages) {
1529
+ coverageLines.push(`- ❌ ${type}:${files.length} 个带章节号的文件`);
1530
+ }
1531
+ coverageLines.push(' ❌ 实体页文件名不应带 `第X章`;/fancy-analyze fix 会在重新 ingest 时自动合并为稳定实体页。');
1532
+ coverageLines.push('');
1533
+ }
1534
+ // 去掉最后一个空行,同时保证各覆盖小节之间恰好一个空行。
1535
+ while (coverageLines.length > 0 && coverageLines[coverageLines.length - 1] === '') {
1536
+ coverageLines.pop();
1537
+ }
1538
+ let structuralCoverage;
1539
+ if (coverageLines.length > 0) {
1540
+ structuralCoverage = coverageLines.join('\n');
1541
+ } else if (structuralTableSeen) {
1542
+ structuralCoverage = '## 跨章节实体覆盖\nℹ️ 结构设计表存在,但还没有记录任何跨章节实体。';
1543
+ } else if (legacyStructuralTableSeen) {
1544
+ structuralCoverage = '## 跨章节实体覆盖\n⚠️ 检测到旧版「### 结构设计分析」表(按章结构页)。新版改为「### 跨章节结构实体」;重新 ingest 后才会生成跨章节实体页。';
1545
+ } else {
1546
+ structuralCoverage = '## 跨章节实体覆盖\nℹ️ 未发现结构设计分析表(可能是旧版拆解结果;重新 ingest 后才会出现跨章节实体页)。';
1547
+ }
1548
+
1549
+ const problemLines = [];
1550
+ if (schemaOutdated) {
1551
+ problemLines.push('## 对标 schema 版本');
1552
+ problemLines.push('⚠️ 检测到 对标/schema.md 是旧版(按章结构设计)。');
1553
+ problemLines.push('⚠️ /fancy-analyze fix 会先更新为新版 schema,再补传缺失章节;否则重新 ingest 仍会生成伏笔回收/世界观构建/角色弧光这些按章页面。');
1554
+ }
1555
+ if (retryWriteError) {
1556
+ problemLines.push('## .retry 写入失败');
1557
+ problemLines.push(`❌ 写入 .retry 文件失败:${retryWriteError}`);
1558
+ problemLines.push('❌ 缺失清单没能保存,/fancy-analyze fix 将无法工作。');
1559
+ }
1560
+ if (retryPagesWriteError) {
1561
+ problemLines.push('## .retry-pages 写入失败');
1562
+ problemLines.push(`⚠️ 写入 .retry-pages.json 失败:${retryPagesWriteError}`);
1563
+ problemLines.push('⚠️ fix 将退回通用修复指令,无法列出“已有页面/缺失页面”。');
1564
+ }
1565
+ if (missingReports.length > 0) {
1566
+ problemLines.push('## 缺失检测');
1567
+ for (const report of missingReports) problemLines.push(`❌ ${report}`);
1568
+ }
1569
+ if (missingListReports.length > 0) {
1570
+ problemLines.push('## 列表未找到');
1571
+ for (const report of missingListReports) problemLines.push(`❌ ${report}`);
1572
+ }
1573
+ if (missingIngestReports.length > 0) {
1574
+ problemLines.push('## 拆解产物缺失');
1575
+ for (const report of missingIngestReports) problemLines.push(`❌ ${report}`);
1576
+ }
1577
+ if (entityMissingReports.length > 0) {
1578
+ problemLines.push('## 结构设计实体缺失');
1579
+ for (const report of entityMissingReports) problemLines.push(`❌ ${report}`);
1580
+ }
1581
+ const aliasReports = new Map();
1582
+ for (const row of entityRows) {
1583
+ if (!RELATION_ONLY_ENTITY_RE.test(String(row.entity || '').trim())) continue;
1584
+ aliasReports.set(`${row.type}::${row.pageFile}`, row);
1585
+ }
1586
+ if (aliasReports.size > 0) {
1587
+ problemLines.push('## 疑似称呼实体');
1588
+ for (const row of aliasReports.values()) {
1589
+ problemLines.push(`⚠️ ${row.type}「${row.entity}」可能是某个角色的称呼,不应单独建页;请合并到本名页`);
1590
+ }
1591
+ }
1592
+
1593
+ if (missingArcs.length > 0) {
1594
+ problemLines.push('## 角色弧光覆盖');
1595
+ problemLines.push(`⚠️ 以下角色出现在人物关系里,但没有角色弧光页:${missingArcs.join('、')}`);
1596
+ problemLines.push('⚠️ 新版 schema 要求每个有名角色都有角色弧光页;/fancy-analyze fix 重传相关章节后会补。');
1597
+ }
1598
+
1599
+ if (legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0) {
1600
+ problemLines.push('## 结构设计文件需要迁移');
1601
+ problemLines.push('⚠️ 检测到旧版结构页或按章命名的实体页,详见下方覆盖统计;/fancy-analyze fix 会合并/重传。');
1602
+ }
1603
+ if (legacySourceChapters.length > 0) {
1604
+ const byBook = new Map();
1605
+ for (const item of legacySourceChapters) {
1606
+ if (!byBook.has(item.bookName)) byBook.set(item.bookName, new Set());
1607
+ byBook.get(item.bookName).add(item.chapter);
1608
+ }
1609
+ const lines = [];
1610
+ let total = 0;
1611
+ for (const [book, chapters] of byBook) {
1612
+ const sorted = [...chapters].sort(
1613
+ (a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
1614
+ const preview = sorted.slice(0, 5).join('、') +
1615
+ (sorted.length > 5 ? ` 等 ${sorted.length} 章` : '');
1616
+ lines.push(`⚠️ ${book}:${preview}`);
1617
+ total += sorted.length;
1618
+ }
1619
+ problemLines.push('## 旧 schema 章节');
1620
+ problemLines.push(...lines);
1621
+ problemLines.push(`⚠️ 共 ${total} 章缺少「### 跨章节结构实体」表。`);
1622
+ problemLines.push('⚠️ /fancy-analyze fix 会先更新 schema,再用新版重传这些章节。');
1623
+ }
1624
+
1625
+ return notify(session, 'success', analyze.notify({
1626
+ type: 'status_result',
1627
+ missingFiles,
1628
+ queue,
1629
+ extraMessage: problemLines.join('\n').replace(/\n(?=## )/g, '\n\n'),
1630
+ coverageMessage: structuralCoverage,
1631
+ }));
1632
+ } catch (err) {
1633
+ const errMsg = describeFetchError(err);
1634
+ return notify(session, 'error', analyze.notify({
1635
+ type: 'status_query_failed',
1636
+ err: errMsg
1637
+ }));
1638
+ }
1639
+ }
1640
+
1641
+ // 2.3 summary-status:查技法总结进度。
1642
+ //
1643
+ // 和 summary / resummary 一样先确认 ingest 完成:ingest 还没结束时,
1644
+ // wiki 里可能只有一部分来源页,此时显示"进度"会把用户引向错误结论。
1645
+ // 进度本身仍然是本地文件系统事实,但门禁必须先过。
1646
+ if (isSummaryStatus) {
1647
+ if (signal?.aborted) {
1648
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1649
+ }
1650
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary-status' });
1651
+ if (!ingestReady.ok) {
1652
+ return notify(session, 'error', ingestReady.text);
1653
+ }
1654
+ const wikiRoot = join(projectRoot, '对标', 'wiki');
1655
+ if (!existsSync(wikiRoot)) {
1656
+ return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
1657
+ }
1658
+ const progress = scanSummaryProgress(wikiRoot);
1659
+ return notify(session, 'success', analyze.notify({
1660
+ type: 'summary_status',
1661
+ summary: progress,
1662
+ }));
1663
+ }
1664
+
1665
+ // 2.4 resummary [技法名]:强制重新总结
1666
+ //
1667
+ // 语义:把指定技法(或全部技法)的总结文件**移入备份目录**,
1668
+ // 于是头部���「已总结章节」清单一起没了 —— 下次派发时所有源章节
1669
+ // 都会被当成"未总结",模型从头再写一遍。
1670
+ //
1671
+ // 为什么不"就地清空头部":那样旧技法条目还留在文件里,模型会把
1672
+ // 新写的和旧的混在一起,文件越滚越胖。移走是最干脆的"重来"。
1673
+ // 备份而不是删除:旧总结可能还有参考价值。
1674
+ if (isResummary) {
1675
+ if (signal?.aborted) {
1676
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1677
+ }
1678
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'resummary' });
1679
+ if (!ingestReady.ok) {
1680
+ return notify(session, 'error', ingestReady.text);
1681
+ }
1682
+ const wikiRoot = join(projectRoot, '对标', 'wiki');
1683
+ if (!existsSync(wikiRoot)) {
1684
+ return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
1685
+ }
1686
+
1687
+ const allDirs = listSummaryDirs(wikiRoot);
1688
+ const targets = resummaryTarget
1689
+ ? allDirs.filter((name) => name === resummaryTarget)
1690
+ : allDirs;
1691
+
1692
+ if (resummaryTarget && targets.length === 0) {
1693
+ const known = allDirs.join('、');
1694
+ return notify(session, 'error',
1695
+ `❌ 未找到名为「${resummaryTarget}」的总结目录` +
1696
+ (known ? `。可用目录:${known}` : '(对标/wiki 下尚无可用总结目录)'));
1697
+ }
1698
+
1699
+ const stamp = backupStamp(Date.now());
1700
+ const backupDir = join(projectRoot, '对标', SUMMARY_BACKUP_DIRNAME, stamp);
1701
+ const reset = [];
1702
+
1703
+ for (const name of targets) {
1704
+ const summaryPath = join(wikiRoot, name, `${name}-总结.md`);
1705
+ if (!existsSync(summaryPath)) continue;
1706
+ try {
1707
+ mkdirSync(backupDir, { recursive: true });
1708
+ const backupPath = join(backupDir, `${name}-总结.md`);
1709
+ renameSync(summaryPath, backupPath);
1710
+ reset.push({ name, backup: backupPath });
1711
+ } catch (err) {
1712
+ const msg = err instanceof Error ? err.message : String(err);
1713
+ return notify(session, 'error', `❌ 重置「${name}」失败:${msg}`);
1714
+ }
1715
+ }
1716
+
1717
+ const progress = scanSummaryProgress(wikiRoot);
1718
+ // 重置完,头部清单没了 → 所有源章节都算"未总结"。
1719
+ // 提示词同样只给目录:章节名让模型自己去目录里找。
1720
+ const tasks = dirsToDispatch(progress).map((p) => ({
1721
+ name: p.name,
1722
+ scope: p.scope,
1723
+ sourceCount: p.sourceCount,
1724
+ pendingCount: p.pendingFiles.length,
1725
+ }));
1726
+ const remainingChapters = tasks.reduce((n, t) => n + t.pendingCount, 0);
1727
+
1728
+ let dispatched = 0;
1729
+ if (tasks.length > 0) {
1730
+ // 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
1731
+ const registerTool = ensureRegisterScript(projectRoot);
1732
+ if (!registerTool) {
1733
+ return notify(session, 'error', analyze.notify({
1734
+ type: 'register_script_failed',
1735
+ projectRoot,
1736
+ }));
1737
+ }
1738
+ const prompt = analyze.generateSummarySystemPrompt({
1739
+ tasks,
1740
+ remainingChapters,
1741
+ projectRoot,
1742
+ registerScript: registerTool,
1743
+ });
1744
+ if (signal?.aborted) {
1745
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1746
+ }
1747
+ const follow = await llmFollowup(invocation, prompt);
1748
+ if (!follow || follow.ok !== true) {
1749
+ return notify(session, 'error',
1750
+ '❌ 重置完成,但提示词投递失败:' + ((follow && follow.error) || '未知错误') +
1751
+ '\n旧总结已备份,用 /fancy-analyze summary-status 确认当前状态');
1752
+ }
1753
+ dispatched = tasks.length;
1754
+ }
1755
+
1756
+ return notify(session, 'success', analyze.notify({
1757
+ type: 'summary_reset',
1758
+ summary: { reset, dispatched: tasks.map((t) => t.name), remainingChapters },
1759
+ }));
1760
+ }
1761
+
1762
+ // 2.5 summary 模式:总结各技法目录(只给目录,章节让模型自己找)
1763
+ //
1764
+ // **只派发"还有章节没总结"的目录**:
1765
+ // 头部「已总结章节」清单是唯一事实源,插件据此算出每个目录还差哪些章节,
1766
+ // 只把这部分交给模型。这样反复执行 summary 不会重复总结已有章节。
1767
+ //
1768
+ // 报的是"已派发"而不是"已完成":
1769
+ // llmFollowup 只代表提示词入队(lib/infra.js:329-332),真正的写作是
1770
+ // 模型在后续 agent loop 里异步完成的。之前这里直接报"✅ 技法总结完成",
1771
+ // 模型一个字都还没写 —— 那是假成功。
1772
+ if (isSummary) {
1773
+ try {
1774
+ if (signal?.aborted) {
1775
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1776
+ }
1777
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary' });
1778
+ if (!ingestReady.ok) {
1779
+ return notify(session, 'error', ingestReady.text);
1780
+ }
1781
+ const wikiRoot = join(projectRoot, '对标', 'wiki');
1782
+ if (!existsSync(wikiRoot)) {
1783
+ return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
1784
+ }
1785
+
1786
+ const progress = scanSummaryProgress(wikiRoot);
1787
+ const toDispatch = dirsToDispatch(progress);
1788
+
1789
+ const skippedDone = progress.done.map((p) => p.name);
1790
+ const skippedNoInput = progress.noInput.map((p) => p.name);
1791
+
1792
+ if (toDispatch.length === 0) {
1793
+ return notify(session, 'success', analyze.notify({
1794
+ type: 'summary_nothing_to_do',
1795
+ summary: { done: skippedDone, skippedNoInput },
1796
+ }));
1797
+ }
1798
+
1799
+ // 提示词**只给目录**,不给章节名 —— 一本长篇可能几千章,
1800
+ // 把章节名列进提示词就是几十 K token 的静态开销(实测 3200 章 → 71 KB)。
1801
+ // 每个目录里写清楚"怎么判断哪些章节还没总结",模型自己去目录里找。
1802
+ const tasks = toDispatch.map((p) => ({
1803
+ name: p.name,
1804
+ scope: p.scope,
1805
+ sourceCount: p.sourceCount,
1806
+ pendingCount: p.pendingFiles.length,
1807
+ }));
1808
+ const remainingChapters = toDispatch.reduce((n, p) => n + p.pendingFiles.length, 0);
1809
+
1810
+ // 生成提示词
1811
+ // 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
1812
+ const registerTool = ensureRegisterScript(projectRoot);
1813
+ if (!registerTool) {
1814
+ return notify(session, 'error', analyze.notify({
1815
+ type: 'register_script_failed',
1816
+ projectRoot,
1817
+ }));
1818
+ }
1819
+ const prompt = analyze.generateSummarySystemPrompt({
1820
+ tasks,
1821
+ remainingChapters,
1822
+ projectRoot,
1823
+ registerScript: registerTool,
1824
+ });
1825
+
1826
+ // 调用 LLM 进行总结
1827
+ if (signal?.aborted) {
1828
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1829
+ }
1830
+ const follow = await llmFollowup(invocation, prompt);
1831
+ // llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
1832
+ // 否则投递失败也会报"技法总结完成"
1833
+ if (!follow || follow.ok !== true) {
1834
+ return notify(session, 'error', '❌ 提示词投递失败:' + ((follow && follow.error) || '未知错误'));
1835
+ }
1836
+
1837
+ return notify(session, 'success', analyze.notify({
1838
+ type: 'summary_dispatched',
1839
+ summary: { tasks, skippedDone, skippedNoInput, remainingChapters },
1840
+ }));
1841
+ } catch (err) {
1842
+ const errMsg = err instanceof Error ? err.message : String(err);
1843
+ return notify(session, 'error', '❌ summary 执行失败:' + errMsg);
1844
+ }
1845
+ }
1846
+
1847
+ // 2.6 普通模式:取书名
1848
+ let bookName = rawInput;
1849
+
1850
+ if (!bookName) {
1851
+ // 弹窗
1852
+ const askedresult = await askUserQuestion(ctx, invocation, [analyze.askBookName()]);
1853
+ if (!askedresult.ok) {
1854
+ return notify(session, 'error', analyze.notify({
1855
+ type: askedresult.failure.type,
1856
+ err: askedresult.failure.message
1857
+ }));
1858
+ }
1859
+ const result = askedresult.answer;
1860
+ if (!result?.answers?.[0]) {
1861
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1862
+ }
1863
+ bookName = (result.answers[0].custom || result.answers[0].selected?.[0] || '').trim();
1864
+ if (!bookName) {
1865
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1866
+ }
1867
+ }
1868
+
1869
+ // 2.6.1 清理书名(去除后缀,用于目录和路径)
1870
+ // 注意:此时 bookNameClean 还未定义,下面会在 sourcePath 获取后重新计算
1871
+
1872
+ // 3. 在 参考资料/ 下查找匹配的源文件(忽略后缀)
1873
+ const refDir = join(projectRoot, '参考资料');
1874
+ const lookup = findSourceFile(refDir, bookName);
1875
+ if (lookup.error === 'dir_missing') {
1876
+ // 必须传 refDir 和 bookName —— 模板的"参考资料/ 目录不存在:${refDir}"
1877
+ // 和"手动下载《${bookName}》"要用它们;之前只传了 projectRoot,
1878
+ // 消息里路径和书名都是空的。
1879
+ return notify(session, 'error', analyze.notify({
1880
+ type: 'source_dir_missing',
1881
+ bookName,
1882
+ refDir,
1883
+ projectRoot,
1884
+ }));
1885
+ }
1886
+ if (lookup.error === 'not_found') {
1887
+ return notify(session, 'error', analyze.notify({
1888
+ type: 'source_missing',
1889
+ bookName,
1890
+ projectRoot,
1891
+ refDir,
1892
+ }));
1893
+ }
1894
+ if (lookup.error === 'ambiguous') {
1895
+ return notify(session, 'error', analyze.notify({
1896
+ type: 'source_ambiguous',
1897
+ bookName,
1898
+ matches: lookup.matches,
1899
+ }));
1900
+ }
1901
+ const sourcePath = lookup.path;
1902
+
1903
+ // 2.6.2 从实际文件路径获取真实文件名(去除后缀)
1904
+ // 必须用 basename:split('/').pop() 在 Windows 上取不到文件名
1905
+ // (路径分隔符是 '\'),会把整条路径当成书名,
1906
+ // 后面拆书就会写到 对标/raw/sources/C:\Users\...\ 这种乱路径下。
1907
+ const sourceFileName = sourcePath ? basename(sourcePath) : bookName;
1908
+ const bookNameClean = sourceFileName.replace(/\.[^./\\]+$/, '');
1909
+
1910
+ // 读取 .fancy-deployed 配置
1911
+ const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
1912
+ if (configError) {
1913
+ return notify(session, 'error', configError);
1914
+ }
1915
+
1916
+ const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
1917
+ const llmWikiToken = deployedConfig.llm_wiki_token || '';
1918
+
1919
+ if (!llmWikiUrl || !llmWikiToken) {
1920
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
1921
+ }
1922
+
1923
+ // 4. 调用 llm_wiki API 创建项目
1924
+ let referenceId = deployedConfig.reference_id || '';
1925
+ try {
1926
+ const result = await callLlmWikiApi(
1927
+ llmWikiUrl,
1928
+ llmWikiToken,
1929
+ '/projects/create',
1930
+ 'POST',
1931
+ {
1932
+ name: '对标',
1933
+ path: projectRoot,
1934
+ }
1935
+ );
1936
+
1937
+ if (result.data && result.data.error) {
1938
+ const errorMsg = String(result.data.error);
1939
+ if (errorMsg.includes('Directory already exists')) {
1940
+ // 目录已存在,继续下一步
1941
+ } else {
1942
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: errorMsg }));
1943
+ }
1944
+ } else if (result.data && result.data.ok === true && result.data.project && result.data.project.id) {
1945
+ // 成功,保存 reference_id
1946
+ referenceId = result.data.project.id;
1947
+ updateDeployedField(projectRoot, 'reference_id', referenceId);
1948
+ } else {
1949
+ // 既不是成功也不是 Directory already exists 错误
1950
+ const apiErr = llmWikiError(result);
1951
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: apiErr || 'API 调用失败' }));
1952
+ }
1953
+ } catch (err) {
1954
+ const errMsg = describeFetchError(err);
1955
+ return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: errMsg }));
1956
+ }
1957
+
1958
+ // 5. 拷贝 schema-analyze.md → 对标/schema.md
1959
+ const duiBiaoDir = join(projectRoot, '对标');
1960
+ try {
1961
+ copySchemaFile(duiBiaoDir);
1962
+ } catch (err) {
1963
+ return notify(session, 'error', analyze.notify({ type: 'write_failed', err: err.message }));
1964
+ }
1965
+
1966
+ // 6. 执行拆书脚本 → 对标/raw/sources/<书名>/
1967
+ const splitDir = join(projectRoot, '对标', 'raw', 'sources', bookNameClean);
1968
+ const splitResult = await spawnScript('node', [SPLIT_SCRIPT, '--source', sourcePath, '--output-dir', splitDir], { timeout: 60000 });
1969
+ if (!splitResult.ok) {
1970
+ return notify(session, 'error', analyze.notify({ type: 'split_failed', err: splitResult.error || '未知错误' }));
1971
+ }
1972
+ let splitData;
1973
+ try {
1974
+ splitData = JSON.parse(splitResult.output);
1975
+ } catch {
1976
+ return notify(session, 'error', analyze.notify({ type: 'split_failed', err: '拆书脚本返回格式错误' }));
1977
+ }
1978
+
1979
+ // 7. 调用 llm_wiki sources/ingest 接口
1980
+ //
1981
+ // 这里必须把失败**报给用户**。
1982
+ // 之前只是 try/catch + console.error,可 HTTP 4xx/5xx 根本不抛异常,
1983
+ // catch 压根不触发 —— 于是 ingest 返回 500 时命令照样返回
1984
+ // kind=success + "✅ 正在后台拆解中",而 llm_wiki 那边一个文件都没收到,
1985
+ // 用户等多久都不会有结果(实测复现)。
1986
+ if (referenceId && splitData && splitData.split_files && splitData.split_files.length > 0) {
1987
+ const paths = splitData.split_files.map(f => `raw/sources/${bookNameClean}/${f}`);
1988
+ try {
1989
+ const ingestResult = await callLlmWikiApi(
1990
+ llmWikiUrl,
1991
+ llmWikiToken,
1992
+ `/projects/${referenceId}/sources/ingest`,
1993
+ 'POST',
1994
+ { paths }
1995
+ );
1996
+ const apiErr = llmWikiError(ingestResult);
1997
+ if (apiErr) {
1998
+ console.error(`[fancy-analyze] sources/ingest failed: ${apiErr}`);
1999
+ return notify(session, 'error', analyze.notify({
2000
+ type: 'ingest_failed',
2001
+ err: apiErr,
2002
+ bookNameClean,
2003
+ }));
2004
+ }
2005
+ } catch (err) {
2006
+ const errMsg = describeFetchError(err);
2007
+ console.error(`[fancy-analyze] sources/ingest failed: ${errMsg}`);
2008
+ return notify(session, 'error', analyze.notify({
2009
+ type: 'ingest_failed',
2010
+ err: errMsg,
2011
+ bookNameClean,
2012
+ }));
2013
+ }
2014
+ }
2015
+
2016
+ // 8. 通知用户
2017
+ return notify(session, 'success', analyze.notify({
2018
+ type: 'done',
2019
+ bookNameClean,
2020
+ projectRoot,
2021
+ }));
2022
+ },
2023
+ });
2024
+ }