@cloud411716/fancy-webnovel 1.0.45 → 2.2.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 (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. 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 +204 -0
  105. 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
  106. 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
  107. 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
  108. 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 +66 -0
  109. 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 +76 -0
  110. 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 +72 -0
  111. 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 +37 -0
  112. 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 +53 -0
  113. 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 +40 -0
  114. 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 +79 -0
  115. 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 +132 -0
  116. 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 +133 -0
  117. 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 +160 -0
  118. 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
  119. 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 +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -160
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -275
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,704 @@
1
+ /**
2
+ * fancy-topic plugin entry
3
+ *
4
+ * 两条路径:
5
+ * A 探索模式 /fancy-topic [参数] trim 后为空 → 选平台/选文件/LLM 评分
6
+ * B 敲定模式 /fancy-topic setup 8 弹框收集 → 原子写盘 → phase=topic_done
7
+ *
8
+ * 不论哪条路径,都先检查 .fancy-deployed 存在且 phase ∈ {scanned, topic_done}。
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
14
+ import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
15
+ import { topic } from './templates.js';
16
+
17
+ export const inject = ['commands', 'userQuestions'];
18
+
19
+ /**
20
+ * select-topic.js 的绝对路径。
21
+ *
22
+ * 必须用 fileURLToPath:URL.pathname 会把中文/空格百分号编码
23
+ * (/tmp/普通 目录 → /tmp/%E6%99%AE%E9%80%9A%20%E7%9B%AE%E5%BD%95),
24
+ * 拼出来的路径不存在,spawnScript 必然报 MODULE_NOT_FOUND,
25
+ * 选题根本写不进去。run-scan.js 里同一个坑已经修过。
26
+ */
27
+ const SELECT_TOPIC_SCRIPT = join(
28
+ fileURLToPath(new URL('.', import.meta.url)),
29
+ 'scripts',
30
+ 'select-topic.js'
31
+ );
32
+
33
+ /**
34
+ * 多选字段的 id 集合。
35
+ *
36
+ * 这两个字段必须保持**数组**语义:多选回传 selected 是数组,
37
+ * 写盘时也是 JSON 数组。首次收集与必填重试轮必须用同一套取值逻辑 ——
38
+ * 曾经重试轮写成 `ans.selected?.[0]`,把多选塌成字符串且只保留第一项。
39
+ */
40
+ const MULTISELECT_IDS = new Set(['hard_constraints', 'anti_tropes']);
41
+
42
+ /**
43
+ * 从 DSH 的一条 answer 里取值。
44
+ *
45
+ * @param {object} ans - DSH 的 AskUserQuestionAnswerItem
46
+ * @returns {string|string[]|undefined} undefined 表示这条 answer 没有对应字段
47
+ */
48
+ function collectAnswerValue(ans) {
49
+ if (!ans || !ans.id) return undefined;
50
+ if (MULTISELECT_IDS.has(ans.id)) {
51
+ if (Array.isArray(ans.selected) && ans.selected.length > 0) return ans.selected.slice();
52
+ if (typeof ans.custom === 'string' && ans.custom.trim() !== '') return [ans.custom.trim()];
53
+ return [];
54
+ }
55
+ // 单选或文本:取 selected[0] 或 custom
56
+ return ans.selected?.[0] ?? ans.custom ?? '';
57
+ }
58
+
59
+ function getProjectRoot(session) {
60
+ // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
61
+ // 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
62
+ // 此时返回 null,由调用方给出明确报错。
63
+ const cwd = session.header?.cwd;
64
+ return cwd || null;
65
+ }
66
+
67
+ /**
68
+ * 读取项目 phase。
69
+ *
70
+ * 必须区分「读不到」和「phase 值不对」:
71
+ * 之前一律返回 null,调用方只能报"请先运行 /fancy-scan 扫榜" ——
72
+ * 但 .fancy-deployed 内容损坏时,跑 scan 也没用,错误信息会把用户带偏。
73
+ *
74
+ * @returns {{phase: string|null, error: 'missing'|'corrupt'|'no_phase'|null, message?: string}}
75
+ */
76
+ /**
77
+ * 平台目录(中文名 + 英文 value),用于判定某行标题归属哪个平台。
78
+ */
79
+ const PLATFORM_DIR = topic.PLATFORM_OPTIONS.map(p => ({
80
+ value: p.value,
81
+ label: p.label,
82
+ lowerValue: String(p.value).toLowerCase(),
83
+ }));
84
+
85
+ /**
86
+ * 判断一行标题归属哪个平台。
87
+ *
88
+ * 规则:去掉 start/end 后缀后,标题**以平台名开头**才算该平台的段标题。
89
+ * "番茄-男频阅读榜-20261021" → 番茄
90
+ * "番茄男频阅读榜分析" → 番茄(LLM 漏写标记时也认)
91
+ * "参考:起点同类作品" → 不以平台名开头 → 不归属任何平台
92
+ * 第二个规则很重要:正文里的 markdown 小标题常带平台名,若一律当段标题,
93
+ * 就会把别的平台的内容从段落中间切开。
94
+ */
95
+ function platformOfHeader(text) {
96
+ const core = String(text || '')
97
+ .trim()
98
+ .replace(/[-—–\s]*(start|end)\s*$/i, '')
99
+ .trim();
100
+ if (!core) return null;
101
+ for (const p of PLATFORM_DIR) {
102
+ if (core.startsWith(p.label)) return p.value;
103
+ if (core.toLowerCase().startsWith(p.lowerValue)) return p.value;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * 把 topic_decision_<date>.md 按**平台归属**切成块。
110
+ *
111
+ * 块边界规则(按优先级):
112
+ * 1. **-start 标记行** → 开一个新块(**哪怕前一个块是同平台**)
113
+ * 2. **-end 标记行** → 关闭当前块(同平台)
114
+ * 3. 其它以平台名开头的标题行 → 若当前有开着的**同平台**块,
115
+ * 它只是该块的另一个标题(LLM 常在 -start 后面再写一个报告大标题),
116
+ * 追加进该块;否则自己开一个新块
117
+ *
118
+ * 规则 1 是关键:同一个平台在一次报告里可能有**多个 -start 块**
119
+ * (例:"# 起点-全选-20261021-start" 与 "# 起点-签约榜-20261021-start"),
120
+ * 它们是两次独立分析,不能合并成一块 —— 合并后用户看到的
121
+ * "起点只有 1 块" 就是错的。
122
+ *
123
+ * 为什么不用 -start/-end 配对切块:LLM 偶尔漏写标记,靠配对会让漏掉 -end
124
+ * 的那一块一路吞到下一个 -end,把夹在中间的**别的平台**的分析一起带走
125
+ * —— 实测过:选番茄时起点的分析也一起发给了 LLM,而且全程没有任何报错。
126
+ * 现在靠"标题行"和"-start 标记"两个信号切块,漏写 -end 也不会串台。
127
+ *
128
+ * 返回 [{ platform, headers, body, hasStart, hasEnd }]
129
+ */
130
+ function splitDecisionSections(content) {
131
+ const lines = String(content || '').split('\n');
132
+ const raw = [];
133
+ let cur = null;
134
+ const preamble = [];
135
+
136
+ for (const line of lines) {
137
+ // 独占一行的代码围栏(``` / ~~~)是 markdown 装饰,不是正文内容。
138
+ // 扫榜提示词把 start/end 模板放在代码块里展示,LLM 照抄时会把围栏
139
+ // 一起写进 topic_decision 文件;这里只丢掉**围栏行本身**,
140
+ // 围栏里面的正文照旧保留(不"自愈"文件,只在解析时忽略装饰)。
141
+ if (/^[ \t]*(?:`{3,}|~{3,}).*$/.test(line)) continue;
142
+
143
+ const m = line.match(/^[ \t]*#{1,6}[ \t]*(.+?)[ \t]*$/);
144
+ const title = m ? m[1].trim() : '';
145
+ const platform = title ? platformOfHeader(title) : null;
146
+
147
+ if (platform) {
148
+ const isStartMark = /[-—–\s]*start\s*$/i.test(title);
149
+ const isEndMark = /[-—–\s]*end\s*$/i.test(title);
150
+
151
+ // -end 行:关闭当前块(同平台),**不开新块**
152
+ // —— 少了这一步,"-end" 自己会变成一个空的新块。
153
+ if (isEndMark && cur && cur.platform === platform) {
154
+ cur.hasEnd = true;
155
+ cur.headers.push(`# ${title}`);
156
+ raw.push(cur);
157
+ cur = null;
158
+ continue;
159
+ }
160
+
161
+ // 块内的另一个标题:同平台、当前块开着、且它不是 -start/-end 标记。
162
+ // 追加进当前块,不新开块 —— LLM 常在 -start 后面紧跟一个报告大标题。
163
+ if (cur && cur.platform === platform && !isStartMark && !isEndMark) {
164
+ cur.headers.push(`# ${title}`);
165
+ continue;
166
+ }
167
+
168
+ // 其余情况开新块(含 -start 标记行:同平台也必须开新块)
169
+ if (cur) raw.push(cur);
170
+ cur = {
171
+ platform,
172
+ headers: [`# ${title}`],
173
+ bodyLines: [],
174
+ hasStart: isStartMark,
175
+ hasEnd: false,
176
+ };
177
+ continue;
178
+ }
179
+
180
+ if (cur) cur.bodyLines.push(line);
181
+ else preamble.push(line);
182
+ }
183
+ if (cur) raw.push(cur);
184
+
185
+ // 文件前言并入第一块(LLM 有时把本次分析写在任何标题之前)
186
+ if (preamble.length && raw.length) {
187
+ raw[0].bodyLines = [...preamble, ...raw[0].bodyLines];
188
+ }
189
+
190
+ return raw.map((s) => ({
191
+ platform: s.platform,
192
+ headers: s.headers,
193
+ body: s.bodyLines.join('\n').trim(),
194
+ hasStart: s.hasStart,
195
+ hasEnd: s.hasEnd,
196
+ }));
197
+ }
198
+
199
+ /**
200
+ * 从块里挑出属于指定平台的块。
201
+ *
202
+ * 平台名(番茄/起点/晋江/七猫)出现在标题中即认为匹配;
203
+ * 同时也认英文 value(fanqie/qidian/...)—— LLM 偶尔会写成英文,
204
+ * 只认中文会让用户看到"没有找到该平台的内容块"。
205
+ */
206
+ function filterSectionsByPlatform(sections, platformValue) {
207
+ const val = String(platformValue || '').toLowerCase();
208
+ if (!val) return [];
209
+ return sections.filter(s => s.platform === val);
210
+ }
211
+
212
+ /**
213
+ * 从 扫榜结果/ 的产物文件名推断扫过哪些平台。
214
+ *
215
+ * 产物文件名形如 "番茄男频阅读榜_20261021.md" —— 以平台中文名开头。
216
+ * 用途:LLM 一次平台标记都没写时,若只扫过一个平台,
217
+ * 就能确定整份文件都属于那个平台(降级处理,不至于直接失败)。
218
+ */
219
+ function scannedPlatforms(scanDir) {
220
+ const found = [];
221
+ if (!existsSync(scanDir)) return found;
222
+ let files = [];
223
+ try { files = readdirSync(scanDir); } catch (_) { return found; }
224
+ for (const f of files) {
225
+ if (!f.endsWith('.md')) continue;
226
+ if (/^topic_decision_\d{8}\.md$/.test(f)) continue;
227
+ for (const p of PLATFORM_DIR) {
228
+ if (f.startsWith(p.label) && !found.includes(p.value)) found.push(p.value);
229
+ }
230
+ }
231
+ return found;
232
+ }
233
+
234
+ function platformCn(value) {
235
+ const m = topic.PLATFORM_OPTIONS.find(p => p.value === value);
236
+ return m ? m.label : value;
237
+ }
238
+
239
+ // 把任意形式的 platform(label 或 value)归一化成 value 用于比较
240
+ function platformToValue(p) {
241
+ if (!p) return '';
242
+ const direct = topic.PLATFORM_OPTIONS.find(opt => opt.value === p);
243
+ if (direct) return p;
244
+ const byLabel = topic.PLATFORM_OPTIONS.find(opt => opt.label === p);
245
+ return byLabel ? byLabel.value : p;
246
+ }
247
+
248
+ // 比较两个 platform 值(不论是 label 还是 value,都视为相等)
249
+ function platformEqual(a, b) {
250
+ if (!a || !b) return false;
251
+ if (a === b) return true;
252
+ return platformToValue(a) === platformToValue(b);
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // 路径 A:探索模式
257
+ // ---------------------------------------------------------------------------
258
+
259
+ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
260
+ // 弹框 1:选平台
261
+ if (signal?.aborted) {
262
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
263
+ }
264
+ const askedplatformChoice = await askUserQuestion(ctx, invocation, [topic.askPlatform()]);
265
+ if (!askedplatformChoice.ok) {
266
+ return notify(session, 'error', topic.notify({
267
+ type: askedplatformChoice.failure.type,
268
+ err: askedplatformChoice.failure.message
269
+ }));
270
+ }
271
+ const platformChoice = askedplatformChoice.answer;
272
+ if (!platformChoice?.answers?.[0]) {
273
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
274
+ }
275
+ const selectedPlatform = platformChoice.answers[0]?.selected?.[0];
276
+ if (!selectedPlatform) {
277
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
278
+ }
279
+
280
+ // 扫描 扫榜结果/ 目录下的 topic_decision_<date>.md 文件
281
+ const scanDir = join(projectRoot, '扫榜结果');
282
+ const decisionFiles = existsSync(scanDir)
283
+ ? readdirSync(scanDir)
284
+ .filter(f => /^topic_decision_\d{8}\.md$/.test(f))
285
+ .sort()
286
+ .reverse() // 最新的在前
287
+ : [];
288
+
289
+ if (decisionFiles.length === 0) {
290
+ return notify(session, 'error', topic.notify({ type: 'no_decision_file' }));
291
+ }
292
+
293
+ // 弹框 2:选 topic_decision 文件
294
+ if (signal?.aborted) {
295
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
296
+ }
297
+ const askedfileChoice = await askUserQuestion(ctx, invocation, [topic.askDecisionFile(decisionFiles)]);
298
+ if (!askedfileChoice.ok) {
299
+ return notify(session, 'error', topic.notify({
300
+ type: askedfileChoice.failure.type,
301
+ err: askedfileChoice.failure.message
302
+ }));
303
+ }
304
+ const fileChoice = askedfileChoice.answer;
305
+ if (!fileChoice?.answers?.[0]) {
306
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
307
+ }
308
+ const selectedFile = fileChoice.answers[0]?.selected?.[0];
309
+
310
+ // DSH 回传的是 label(不是 value),此处必须按 label 判断,
311
+ // 此前用 value:'__rescan__' 判断导致该分支永远不可达。
312
+ if (selectedFile === topic.RESCAN_LABEL) {
313
+ return notify(session, 'success', topic.notify({ type: 'path_a_rescan' }));
314
+ }
315
+ if (!selectedFile || !decisionFiles.includes(selectedFile)) {
316
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
317
+ }
318
+
319
+ // 读取 topic_decision_<date>.md
320
+ const decisionPath = join(projectRoot, '扫榜结果', selectedFile);
321
+ let content;
322
+ try {
323
+ content = readFileSync(decisionPath, 'utf-8');
324
+ } catch (e) {
325
+ return notify(session, 'error', topic.notify({
326
+ type: 'write_failed',
327
+ err: `读取 ${selectedFile} 失败: ${e.message}`,
328
+ }));
329
+ }
330
+
331
+ // 按平台归属切段。段边界由标题行决定,不靠 -start/-end 配对 ——
332
+ // 靠配对会让漏写 -end 的那段吞掉别的平台的内容(实测串台)。
333
+ const sections = splitDecisionSections(content);
334
+ const platformCnName = platformCn(selectedPlatform);
335
+ const platformValue = platformToValue(selectedPlatform);
336
+
337
+ let matchedSections = filterSectionsByPlatform(sections, platformValue);
338
+ const notes = [];
339
+
340
+ // 一次平台标记都没写:若只扫过一个平台,整份文件都属于它(降级,不直接失败)
341
+ if (sections.length === 0) {
342
+ const scanned = scannedPlatforms(scanDir);
343
+ if (scanned.length === 1 && scanned[0] === platformValue) {
344
+ matchedSections = [{
345
+ platform: platformValue,
346
+ headers: [`(整份文件,LLM 未写平台标记)`],
347
+ body: content.trim(),
348
+ hasStart: false,
349
+ hasEnd: false,
350
+ }];
351
+ notes.push('LLM 未在报告里写平台标记;本次只扫过一个平台,已按整份文件处理。');
352
+ } else {
353
+ // "文件里一个段都没有" 与 "有段但没有这个平台" 是两回事,
354
+ // 必须分开报错:前者是 LLM 没按格式写,后者是扫榜没扫这个平台。
355
+ return notify(session, 'error', topic.notify({
356
+ type: 'no_decision_block',
357
+ decisionFile: selectedFile,
358
+ }));
359
+ }
360
+ } else if (matchedSections.length === 0) {
361
+ return notify(session, 'error', topic.notify({
362
+ type: 'platform_not_in_decision',
363
+ platform: platformCnName,
364
+ decisionFile: selectedFile,
365
+ available: [...new Set(sections.map(s => s.headers[0]))],
366
+ }));
367
+ }
368
+
369
+ // 诊断:哪些块缺 start/end 标记(LLM 格式不规范,但已按标题隔离,不影响正确性)
370
+ // 分开统计 -start / -end:只缺 -end 的块被说成"标记缺失"是错的 ——
371
+ // 那种块明明写着 -start,却出现在告警名单里,用户会以为切错了。
372
+ const missingStart = matchedSections.filter(s => !s.hasStart);
373
+ const missingEnd = matchedSections.filter(s => !s.hasEnd);
374
+ if (missingStart.length || missingEnd.length) {
375
+ const parts = [];
376
+ if (missingStart.length) {
377
+ parts.push(`${missingStart.length} 块缺少 -start 标记(${missingStart.map(s => s.headers[0]).join(';')})`);
378
+ }
379
+ if (missingEnd.length) {
380
+ parts.push(`${missingEnd.length} 块缺少 -end 标记(${missingEnd.map(s => s.headers[0]).join(';')})`);
381
+ }
382
+ notes.push(`其中 ${parts.join(';')},已按标题行切分隔离,不影响平台归属。`);
383
+ }
384
+
385
+ // 拼装发给 LLM 的提示
386
+ const blocksText = matchedSections.map((s, i) =>
387
+ `## 块 ${i + 1}(${s.headers.join(' / ')})\n${s.body}`
388
+ ).join('\n\n');
389
+
390
+ // 汇总按平台分组(保持文件里的出现顺序),并给出每个平台的块数。
391
+ // 之前是"每块一行",同一个平台会出现多行、总数也和块数对不上;
392
+ // 术语统一用"块"(与下面的 "## 块 N" 标记一致)。
393
+ const summaryByPlatform = new Map();
394
+ for (const s of sections) {
395
+ const name = platformCn(s.platform) || s.platform;
396
+ if (!summaryByPlatform.has(name)) summaryByPlatform.set(name, []);
397
+ summaryByPlatform.get(name).push({ headers: s.headers });
398
+ }
399
+ const sectionSummary = [...summaryByPlatform.entries()].map(([name, blocks]) => ({
400
+ platform: name,
401
+ blocks,
402
+ }));
403
+
404
+ const prompt = topic.explorePrompt({
405
+ platform: platformCnName,
406
+ decisionFile: selectedFile,
407
+ blocks: blocksText,
408
+ blockCount: matchedSections.length,
409
+ sectionSummary,
410
+ notes,
411
+ });
412
+
413
+ // 触发 LLM 分析
414
+ try {
415
+ const follow = await llmFollowup(invocation, prompt);
416
+ // llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
417
+ // 否则投递失败也会报"分析已完成"
418
+ if (!follow || follow.ok !== true) {
419
+ return notify(session, 'error', topic.notify({
420
+ type: 'write_failed',
421
+ err: `触发 LLM 失败: ${(follow && follow.error) || '提示词投递失败'}`,
422
+ }));
423
+ }
424
+ } catch (err) {
425
+ // llmFollowup 通常不抛错(它自己 try/catch),但保险起见
426
+ return notify(session, 'error', topic.notify({
427
+ type: 'write_failed',
428
+ err: `触发 LLM 失败: ${err.message}`,
429
+ }));
430
+ }
431
+
432
+ return notify(session, 'success', topic.notify({
433
+ type: 'path_a_done',
434
+ platform: platformCnName,
435
+ decisionFile: selectedFile,
436
+ blockCount: matchedSections.length,
437
+ notes,
438
+ }));
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // 路径 B:敲定模式
443
+ // ---------------------------------------------------------------------------
444
+
445
+ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
446
+ // 中间过程提示的收集处(DSH 只有 handler 的返回值会被用户看到)
447
+ const extraNotes = [];
448
+ const novelTopicPath = join(projectRoot, '_novel_topic.json');
449
+
450
+ // 如果 _novel_topic.json 不存在,创建骨架
451
+ if (!existsSync(novelTopicPath)) {
452
+ const skeletonResult = await spawnScript('node', [
453
+ SELECT_TOPIC_SCRIPT,
454
+ '--project-root', projectRoot,
455
+ 'skeleton',
456
+ ], { timeout: 30000 });
457
+
458
+ if (!skeletonResult.ok) {
459
+ const errMsg = skeletonResult.error || '未知错误';
460
+ return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
461
+ }
462
+ // 这条信息不能只 notify 出去:DSH 只有 handler 的返回值会经
463
+ // settleThrown → command/done → channel.notify(),中间调用丢弃 = 用户看不到。
464
+ // 并入最终返回消息。
465
+ extraNotes.push(topic.notify({ type: 'create_skeleton' }));
466
+ }
467
+
468
+ // 弹框:选平台(路径 B 也独立选,因为路径 A 不写文件)
469
+ if (signal?.aborted) {
470
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
471
+ }
472
+ const askedplatformChoice = await askUserQuestion(ctx, invocation, [topic.askPlatform()]);
473
+ if (!askedplatformChoice.ok) {
474
+ return notify(session, 'error', topic.notify({
475
+ type: askedplatformChoice.failure.type,
476
+ err: askedplatformChoice.failure.message
477
+ }));
478
+ }
479
+ const platformChoice = askedplatformChoice.answer;
480
+ if (!platformChoice?.answers?.[0]) {
481
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
482
+ }
483
+ const selectedPlatform = platformChoice.answers[0]?.selected?.[0];
484
+ if (!selectedPlatform) {
485
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
486
+ }
487
+
488
+ // 弹框里展示的两组值,优先级:
489
+ // 1. "📌 当前值" —— _novel_topic.json 的值(仅当平台一致)
490
+ // 2. "💡 推荐值" —— _novel_topic_suggestion.json 的值(仅当平台一致)
491
+ //
492
+ // 两者都直接写进 question 文本展示。之前还有一段"用推荐值填补上次空字段"
493
+ // 的逻辑,但它唯一的落点是问题对象上的 default 字段 ——
494
+ // DSH 的 AskUserQuestionItem 根本没有 default 字段(会被静默忽略),
495
+ // 那段代码对用户完全不可见,属于死代码,已删除。
496
+ const suggestionPath = join(projectRoot, '_novel_topic_suggestion.json');
497
+ let suggestion = null;
498
+ if (existsSync(suggestionPath)) {
499
+ try {
500
+ suggestion = JSON.parse(readFileSync(suggestionPath, 'utf-8'));
501
+ } catch (_) { /* 损坏就忽略 */ }
502
+ }
503
+
504
+ // 比较 suggestion 与 selectedPlatform,决定是否应用建议值
505
+ // 比较 _novel_topic.json 与 selectedPlatform,决定是否显示"当前值"
506
+ const suggestionPlatformMatches = suggestion ? platformEqual(suggestion.platform, selectedPlatform) : false;
507
+
508
+ // 读 _novel_topic.json,仅当其 platform 与 selectedPlatform 一致时作为"当前值"展示
509
+ const currentValues = (() => {
510
+ if (!existsSync(novelTopicPath)) return {};
511
+ try {
512
+ const data = JSON.parse(readFileSync(novelTopicPath, 'utf-8'));
513
+ if (!data.platform) return data; // 没有 platform 字段就当作一致(兼容旧数据)
514
+ return platformEqual(data.platform, selectedPlatform) ? data : {};
515
+ } catch (_) { return {}; }
516
+ })();
517
+
518
+ // 只有 suggestion 平台匹配时,askFields 才显示"推荐值"
519
+ const suggestionValuesForDisplay = suggestionPlatformMatches ? suggestion : null;
520
+
521
+ if (signal?.aborted) {
522
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
523
+ }
524
+ const askedfieldAnswers = await askUserQuestion(ctx, invocation, topic.askFields({
525
+ currentValues,
526
+ suggestionValues: suggestionValuesForDisplay,
527
+ }));
528
+ if (!askedfieldAnswers.ok) {
529
+ return notify(session, 'error', topic.notify({
530
+ type: askedfieldAnswers.failure.type,
531
+ err: askedfieldAnswers.failure.message
532
+ }));
533
+ }
534
+ const fieldAnswers = askedfieldAnswers.answer;
535
+
536
+ if (!fieldAnswers?.answers) {
537
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
538
+ }
539
+
540
+ // 收集 8 字段的值。
541
+ // 首次收集与必填重试轮必须走同一套取值逻辑(collectAnswerValue)——
542
+ // 否则多选字段会在重试轮塌成字符串、丢掉第 2 项以后的选择。
543
+ const fieldValues = { platform: selectedPlatform };
544
+ for (const ans of fieldAnswers.answers) {
545
+ const v = collectAnswerValue(ans);
546
+ if (v !== undefined) fieldValues[ans.id] = v;
547
+ }
548
+
549
+ // 判断一个值是否"为空"(用于必填检查)
550
+ // 空数组 []、空字符串 ''、null、undefined、数字 0 都视为空
551
+ // (数字 0 是骨架模板里 target_words / target_chapters 的默认值,无意义)
552
+ function isEmpty(v) {
553
+ if (v === undefined || v === null) return true;
554
+ if (typeof v === 'string') return v.trim() === '';
555
+ if (Array.isArray(v)) return v.length === 0;
556
+ if (typeof v === 'number') return v === 0;
557
+ return false;
558
+ }
559
+
560
+ // 必填字段为空 → 重新弹窗(仅弹缺失字段,最多 3 轮)
561
+ const MAX_RETRY = 3;
562
+ for (let retry = 0; retry < MAX_RETRY; retry++) {
563
+ const missing = topic.TOPIC_FIELDS
564
+ .filter(f => f.required && isEmpty(fieldValues[f.id]))
565
+ .map(f => f.id);
566
+ if (missing.length === 0) break;
567
+
568
+ // 这里**不能**往 extraNotes 里塞"必填字段未填写"的告警:
569
+ // DSH 只有 handler 的返回值会送达用户(中间 notify 会被丢弃),
570
+ // 而这条告警是**每一轮重试前**无条件塞的 —— 用户在重试轮补全之后,
571
+ // 那条历史告警仍然留在最终消息里,用户会以为没填成功、流程其实已经成功。
572
+ // 缺失情况改由下面的最终校验统一报告。
573
+
574
+ // 只重新弹缺失字段;已有值保留在 fieldValues 里,
575
+ // 重试轮只覆盖这次被问到的字段。
576
+ if (signal?.aborted) {
577
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
578
+ }
579
+ const askedreAnswers = await askUserQuestion(ctx, invocation, topic.askFields({
580
+ currentValues,
581
+ suggestionValues: suggestionValuesForDisplay,
582
+ }).filter(q => missing.includes(q.id)));
583
+ if (!askedreAnswers.ok) {
584
+ return notify(session, 'error', topic.notify({
585
+ type: askedreAnswers.failure.type,
586
+ err: askedreAnswers.failure.message
587
+ }));
588
+ }
589
+ const reAnswers = askedreAnswers.answer;
590
+ if (!reAnswers?.answers) {
591
+ return notify(session, 'error', topic.notify({
592
+ type: 'fields_missing',
593
+ missing,
594
+ }));
595
+ }
596
+ for (const ans of reAnswers.answers) {
597
+ const v = collectAnswerValue(ans);
598
+ if (v !== undefined) fieldValues[ans.id] = v;
599
+ }
600
+ }
601
+
602
+ // 最终校验:只报**最终仍然缺失**的字段。
603
+ // 用户补全过就一条告警都不该留。
604
+ const stillMissing = topic.TOPIC_FIELDS
605
+ .filter(f => f.required && isEmpty(fieldValues[f.id]))
606
+ .map(f => f.id);
607
+ if (stillMissing.length > 0) {
608
+ return notify(session, 'error', topic.notify({
609
+ type: 'fields_missing',
610
+ missing: stillMissing,
611
+ }));
612
+ }
613
+
614
+ // 调用 select-topic.js 原子写盘 + phase 推进
615
+ if (signal?.aborted) {
616
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
617
+ }
618
+ const writeResult = await spawnScript('node', [
619
+ SELECT_TOPIC_SCRIPT,
620
+ '--project-root', projectRoot,
621
+ '--book-name', fieldValues.book_name,
622
+ '--genre', fieldValues.genre,
623
+ '--target-words', String(fieldValues.target_words),
624
+ '--target-chapters', String(fieldValues.target_chapters),
625
+ '--core-summary', fieldValues.core_summary,
626
+ '--core-conflict', fieldValues.core_conflict,
627
+ '--platform', fieldValues.platform,
628
+ '--hard-constraints', JSON.stringify(fieldValues.hard_constraints || []),
629
+ '--anti-tropes', JSON.stringify(fieldValues.anti_tropes || []),
630
+ 'write',
631
+ ], { timeout: 30000 });
632
+
633
+ if (!writeResult.ok) {
634
+ const errMsg = writeResult.error || '未知错误';
635
+ return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
636
+ }
637
+
638
+ return notify(session, 'success', topic.notify({
639
+ type: 'path_b_done',
640
+ fieldCount: Object.keys(fieldValues).length,
641
+ extra: extraNotes.join('\n\n'),
642
+ }));
643
+ }
644
+
645
+ export async function apply(ctx) {
646
+ ctx.commands.register({
647
+ name: 'fancy-topic',
648
+ description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] )',
649
+ input: {
650
+ hint: '.',
651
+ },
652
+ handler: async (invocation) => {
653
+ const session = invocation.agent.session;
654
+ const { signal } = invocation;
655
+
656
+ // 1. 解析路径:A 探索 / B 敲定
657
+ const rawInput = (invocation.rawInput || '').trim();
658
+ const isSetupPath = rawInput.toLowerCase() === 'setup';
659
+
660
+ // 2. 检查项目初始化
661
+ const projectRoot = getProjectRoot(session);
662
+ if (!projectRoot) {
663
+ return notify(session, 'error', topic.notify({
664
+ type: 'no_project_root',
665
+ err: '会话未提供 cwd'
666
+ }));
667
+ }
668
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
669
+ return notify(session, 'error', topic.notify({ type: 'not_initialized' }));
670
+ }
671
+
672
+ // 3. 检查 phase
673
+ // 区分"状态文件读不了"和"phase 值不对"——前者跑 scan 也没用,
674
+ // 报成"请先扫榜"会把用户带偏。
675
+ const deployedState = readDeployedState(projectRoot);
676
+ if (deployedState.status === DEPLOYED_STATUS.CORRUPT
677
+ || deployedState.status === DEPLOYED_STATUS.NO_PHASE
678
+ || deployedState.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
679
+ // 用共享的修复指引,而不是只说"重新运行 /fancy-bootstrap"——
680
+ // 文件**存在**时 bootstrap 会拒绝执行(它只在文件不存在时才初始化),
681
+ // 让用户去跑 bootstrap 等于把人送进死胡同。
682
+ return notify(session, 'error',
683
+ deployedStateProblemText(deployedState, { projectRoot })
684
+ || topic.notify({
685
+ type: 'phase_unreadable',
686
+ err: deployedState.status === DEPLOYED_STATUS.CORRUPT
687
+ ? '.fancy-deployed 不是合法 JSON'
688
+ : '.fancy-deployed 缺少 phase 字段',
689
+ }));
690
+ }
691
+ const phase = deployedState.phase;
692
+ if (phase !== 'scanned' && phase !== 'topic_done') {
693
+ return notify(session, 'error', topic.notify({ type: 'phase_wrong', phase }));
694
+ }
695
+
696
+ // 4. 路径分发
697
+ if (isSetupPath) {
698
+ return pathSetup(ctx, session, invocation, signal, projectRoot);
699
+ } else {
700
+ return pathExplore(ctx, session, invocation, signal, projectRoot);
701
+ }
702
+ },
703
+ });
704
+ }