@cloud411716/fancy-webnovel 3.0.4 → 4.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.
@@ -297,12 +297,13 @@ DEFINE ANALYZER material_zh
297
297
  /fancy-material export 导出 <工作区>/素材库.md
298
298
  ```
299
299
 
300
- 如果 `扫榜结果` 下有多份 `topic_decision_*.md`,不带参数运行时会先弹窗,
301
- 让你选择本次使用哪一份;插件只读取选中的那份文件里的「选题方向」,不会合并
302
- 多个报告。只有一份时自动使用;没有时回退到番茄扫榜题材。
300
+ 素材题材只认用户选题链路:
303
301
 
304
- 带参数运行(例如 `/fancy-material 地名`)不弹窗,沿用最新的
305
- `topic_decision` 文件。
302
+ 1. `_novel_topic.json`(最终选题)
303
+ 2. `_novel_topic_suggestion.json`(扫榜候选方向选择结果)
304
+ 3. 两者都没有时,弹框确认后使用内置全题材(番茄男频 + 女频)
305
+
306
+ 不再从 `topic_decision` 里猜题材。
306
307
 
307
308
  不带参数时,插件会覆盖全部 54 个 kind:
308
309
 
@@ -238,8 +238,9 @@ brainstorm → 给活人打印死亡证明的打印店
238
238
  ## 七、题材来源与优先级
239
239
 
240
240
  - 当前模式:`{{MATERIAL_MODE}}`
241
- - `topic_decision`:从 `{{TOPIC_FILE}}` 的「选题方向」中提取全部题材。
242
- - `all_genres`:使用番茄扫榜数据中的全部题材。
241
+ - `topic`:题材来自 `_novel_topic.json`。
242
+ - `suggestion`:题材来自 `_novel_topic_suggestion.json`。
243
+ - `builtin`:没有选题记录,使用内置全题材。
243
244
  - 本次题材列表:{{GENRES}}
244
245
 
245
246
  不同题材优先补充的 kind:
@@ -147,8 +147,8 @@ namespace: fancy
147
147
  ### 创作
148
148
 
149
149
  ```text
150
- /fancy-topic # 在工作区读扫榜结果,生成选题建议
151
- /fancy-topic setup # 敲定选题 → 创建 projects/<uuid>/ + 项目库 + 用户
150
+ /fancy-topic # 选 decision 文件、平台和候选方向 → _novel_topic_suggestion.json
151
+ /fancy-topic setup # 素材驱动生成 5 个候选方案 → _novel_topic.json + 项目库 + 用户
152
152
  /fancy-build scale|macro|cast|rhythm|outline|write
153
153
  /fancy-export
154
154
  ```
@@ -0,0 +1,248 @@
1
+ /**
2
+ * material-topic.js —— 素材驱动选题的最小契约
3
+ *
4
+ * 2026-11 新链路:
5
+ * - `_novel_topic_suggestion.json` 只保存扫榜候选方向的选择结果;
6
+ * - `_novel_topic.json` 只保存题材 +「能力」组 +「剧情与脑洞」组的素材。
7
+ *
8
+ * 不再保存 11 项方向设计字段。fancy-build 目前仍读旧格式,
9
+ * 这轮先不动,等后续单独适配。
10
+ */
11
+ import { existsSync, readFileSync, statSync } from 'fs';
12
+ import { join, resolve } from 'path';
13
+
14
+ /** 「能力」组:与 plugins/fancy-material/kinds.js 的 KIND_GROUPS 保持一致 */
15
+ export const ABILITY_KINDS = [
16
+ 'ability_mechanism',
17
+ 'ability_activation',
18
+ 'ability_cost',
19
+ 'ability_limit',
20
+ 'ability_counter',
21
+ 'power_source',
22
+ ];
23
+
24
+ /** 「剧情与脑洞」组 */
25
+ export const PLOT_KINDS = [
26
+ 'brainstorm',
27
+ 'conflict_seed',
28
+ 'conflict_escalation',
29
+ 'plot_twist',
30
+ 'mystery_seed',
31
+ 'payoff_scene',
32
+ 'trope',
33
+ ];
34
+
35
+ /** 内置题材:番茄男频 + 女频主流分类(fancy-material 兜底) */
36
+ export const BUILTIN_GENRES = [
37
+ '西方奇幻',
38
+ '东方仙侠',
39
+ '科幻末世',
40
+ '都市日常',
41
+ '都市修真',
42
+ '都市高武',
43
+ '历史古代',
44
+ '战神赘婿',
45
+ '都市种田',
46
+ '传统玄幻',
47
+ '历史脑洞',
48
+ '悬疑脑洞',
49
+ '都市脑洞',
50
+ '玄幻脑洞',
51
+ '悬疑灵异',
52
+ '抗战谍战',
53
+ '游戏体育',
54
+ '动漫衍生',
55
+ '男频衍生',
56
+ '古风世情',
57
+ '女频衍生',
58
+ '玄幻言情',
59
+ '种田',
60
+ '年代',
61
+ '现言脑洞',
62
+ '宫斗宅斗',
63
+ '古言脑洞',
64
+ '快穿',
65
+ '青春甜宠',
66
+ '星光璀璨',
67
+ '女频悬疑',
68
+ '职场婚恋',
69
+ '豪门总裁',
70
+ '民国言情',
71
+ ];
72
+
73
+ export const TOPIC_PLATFORMS = ['番茄', '起点', '晋江', '七猫'];
74
+
75
+ export const NOVEL_TOPIC_FILE = '_novel_topic.json';
76
+ export const NOVEL_SUGGESTION_FILE = '_novel_topic_suggestion.json';
77
+
78
+ function isPlainObject(value) {
79
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
80
+ }
81
+
82
+ function nonEmptyString(value) {
83
+ return typeof value === 'string' && value.trim().length > 0;
84
+ }
85
+
86
+ function isControlFree(text) {
87
+ return !/[\x00-\x1f\x7f]/.test(text);
88
+ }
89
+
90
+ function validateKindGroup(group, kinds, label) {
91
+ const errors = [];
92
+ if (!isPlainObject(group)) {
93
+ return [`${label} 必须是对象`];
94
+ }
95
+ const keys = Object.keys(group);
96
+ for (const kind of kinds) {
97
+ if (!(kind in group)) {
98
+ errors.push(`${label} 缺 kind: ${kind}`);
99
+ continue;
100
+ }
101
+ const value = group[kind];
102
+ if (!nonEmptyString(value)) {
103
+ errors.push(`${label}.${kind} 必须是非空字符串`);
104
+ } else if (!isControlFree(value)) {
105
+ errors.push(`${label}.${kind} 不能含换行/控制字符`);
106
+ }
107
+ }
108
+ for (const key of keys) {
109
+ if (!kinds.includes(key)) {
110
+ errors.push(`${label} 出现未知 kind: ${key}`);
111
+ }
112
+ }
113
+ return errors;
114
+ }
115
+
116
+ /**
117
+ * 校验最小选题对象:{ genre, ability, plot }。
118
+ *
119
+ * @param {object} data
120
+ * @returns {{ok: boolean, errors: string[], normalized: object|null}}
121
+ */
122
+ export function validateMinimalTopic(data) {
123
+ const errors = [];
124
+ if (!isPlainObject(data)) {
125
+ return { ok: false, errors: ['顶层必须是 JSON 对象'], normalized: null };
126
+ }
127
+ if (!nonEmptyString(data.genre)) {
128
+ errors.push('genre 必须是非空字符串');
129
+ } else if (!isControlFree(data.genre)) {
130
+ errors.push('genre 不能含换行/控制字符');
131
+ }
132
+ errors.push(...validateKindGroup(data.ability, ABILITY_KINDS, 'ability'));
133
+ errors.push(...validateKindGroup(data.plot, PLOT_KINDS, 'plot'));
134
+ if (errors.length) return { ok: false, errors, normalized: null };
135
+ return {
136
+ ok: true,
137
+ errors: [],
138
+ normalized: {
139
+ genre: data.genre.trim(),
140
+ ability: Object.fromEntries(ABILITY_KINDS.map(k => [k, data.ability[k].trim()])),
141
+ plot: Object.fromEntries(PLOT_KINDS.map(k => [k, data.plot[k].trim()])),
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * 校验 `_novel_topic_suggestion.json`。
148
+ *
149
+ * @param {object} data
150
+ * @returns {{ok: boolean, errors: string[], normalized: object|null}}
151
+ */
152
+ export function validateSuggestion(data) {
153
+ const errors = [];
154
+ if (!isPlainObject(data)) {
155
+ return { ok: false, errors: ['顶层必须是 JSON 对象'], normalized: null };
156
+ }
157
+ for (const field of ['direction_title', 'genre', 'positioning']) {
158
+ if (!nonEmptyString(data[field])) errors.push(`${field} 必须是非空字符串`);
159
+ else if (!isControlFree(data[field])) errors.push(`${field} 不能含换行/控制字符`);
160
+ }
161
+ if (!nonEmptyString(data.platform)) {
162
+ errors.push('platform 必须是非空字符串');
163
+ } else if (!TOPIC_PLATFORMS.includes(data.platform)) {
164
+ errors.push(`platform 必须是 ${TOPIC_PLATFORMS.join(' / ')} 之一`);
165
+ }
166
+ if (!nonEmptyString(data.source_decision_file)) {
167
+ errors.push('source_decision_file 必须是非空字符串');
168
+ } else {
169
+ const source = resolve(data.source_decision_file);
170
+ if (!existsSync(source) || !statSync(source).isFile()) {
171
+ errors.push('source_decision_file 不存在或不是文件');
172
+ }
173
+ }
174
+ if (data.market_score !== undefined && data.market_score !== null
175
+ && typeof data.market_score !== 'number' && typeof data.market_score !== 'string') {
176
+ errors.push('market_score 必须是数字或字符串');
177
+ }
178
+ if (data.recommendation !== undefined && data.recommendation !== null
179
+ && typeof data.recommendation !== 'string') {
180
+ errors.push('recommendation 必须是字符串');
181
+ }
182
+ if (errors.length) return { ok: false, errors, normalized: null };
183
+ return {
184
+ ok: true,
185
+ errors: [],
186
+ normalized: {
187
+ platform: data.platform,
188
+ source_decision_file: resolve(data.source_decision_file),
189
+ direction_title: data.direction_title.trim(),
190
+ genre: data.genre.trim(),
191
+ positioning: data.positioning.trim(),
192
+ market_score: data.market_score ?? null,
193
+ recommendation: typeof data.recommendation === 'string' ? data.recommendation.trim() : '',
194
+ },
195
+ };
196
+ }
197
+
198
+ function readJson(file) {
199
+ try {
200
+ const data = JSON.parse(readFileSync(file, 'utf8'));
201
+ return isPlainObject(data) ? data : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /** 读取最小 `_novel_topic.json`;旧格式没有 ability/plot 时返回 null */
208
+ export function readMinimalTopic(root) {
209
+ const data = readJson(join(root, NOVEL_TOPIC_FILE));
210
+ if (!data) return null;
211
+ const v = validateMinimalTopic(data);
212
+ return v.ok ? v.normalized : null;
213
+ }
214
+
215
+ /** 读取 suggestion;旧格式也兼容(有 genre 即可) */
216
+ export function readSuggestion(root) {
217
+ const data = readJson(join(root, NOVEL_SUGGESTION_FILE));
218
+ if (!data) return null;
219
+ const v = validateSuggestion(data);
220
+ return v.ok ? v.normalized : null;
221
+ }
222
+
223
+ /**
224
+ * fancy-material 的题材来源。
225
+ *
226
+ * 优先级:_novel_topic.json > _novel_topic_suggestion.json > null。
227
+ * null 表示两个文件都不存在/不可读,由调用方弹框确认后使用 BUILTIN_GENRES。
228
+ *
229
+ * @param {string} root
230
+ * @returns {{mode: 'topic'|'suggestion'|null, genres: string[], source: string|null}}
231
+ */
232
+ export function resolveMaterialTopic(root) {
233
+ // 新格式优先;旧格式(有 genre、没有 ability/plot)也继续可用。
234
+ const topic = readJson(join(root, NOVEL_TOPIC_FILE));
235
+ if (topic && nonEmptyString(topic.genre)) {
236
+ const normalized = validateMinimalTopic(topic);
237
+ if (normalized.ok || (Array.isArray(topic.genres) && topic.genres.length)) {
238
+ const genres = normalized.ok ? [normalized.normalized.genre] : [String(topic.genre).trim()];
239
+ return { mode: 'topic', genres, source: NOVEL_TOPIC_FILE };
240
+ }
241
+ return { mode: 'topic', genres: [String(topic.genre).trim()], source: NOVEL_TOPIC_FILE };
242
+ }
243
+ const suggestion = readJson(join(root, NOVEL_SUGGESTION_FILE));
244
+ if (suggestion && nonEmptyString(suggestion.genre)) {
245
+ return { mode: 'suggestion', genres: [String(suggestion.genre).trim()], source: NOVEL_SUGGESTION_FILE };
246
+ }
247
+ return { mode: null, genres: [], source: null };
248
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * topic-candidates.js —— 从 topic_decision 文件里解析候选方向。
3
+ *
4
+ * 新格式(2026-11)每个方向只要求四项:
5
+ * - **题材**:
6
+ * - **一句话定位**:
7
+ * - **市场评分**:
8
+ * - **推荐理由**:
9
+ *
10
+ * 旧格式里的 11 项仍然兼容:只取「定位」,题材从方向标题前缀推导。
11
+ */
12
+ import { TOPIC_PLATFORMS } from '#material-topic';
13
+
14
+ export const PLATFORM_OPTIONS = [
15
+ { label: '番茄', value: 'fanqie' },
16
+ { label: '起点', value: 'qidian' },
17
+ { label: '晋江', value: 'jinjiang' },
18
+ { label: '七猫', value: 'qimao' },
19
+ ];
20
+
21
+ const PLATFORM_DIR = PLATFORM_OPTIONS.map((p) => ({
22
+ value: p.value,
23
+ label: p.label,
24
+ lowerValue: String(p.value).toLowerCase(),
25
+ }));
26
+
27
+ function platformOfHeader(text) {
28
+ const core = String(text || '')
29
+ .trim()
30
+ .replace(/[-—–\s]*(start|end)\s*$/i, '')
31
+ .trim();
32
+ if (!core) return null;
33
+ for (const p of PLATFORM_DIR) {
34
+ if (core.startsWith(p.label)) return p.value;
35
+ if (core.toLowerCase().startsWith(p.lowerValue)) return p.value;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function platformLabel(value) {
41
+ return PLATFORM_OPTIONS.find((p) => p.value === value)?.label || value;
42
+ }
43
+
44
+ function cleanValue(value) {
45
+ return String(value || '')
46
+ .replace(/\*\*/g, '')
47
+ .trim();
48
+ }
49
+
50
+ function parseScore(value) {
51
+ const text = cleanValue(value);
52
+ const m = text.match(/-?\d+(?:\.\d+)?/);
53
+ return m ? Number(m[0]) : null;
54
+ }
55
+
56
+ function splitTitleGenre(title) {
57
+ const text = cleanValue(title);
58
+ const m = text.match(/^([^·::|—–-]+?)\s*(?:[·::|—–-]|$)/);
59
+ return m ? m[1].trim() : '';
60
+ }
61
+
62
+ /**
63
+ * @typedef {object} CandidateDirection
64
+ * @property {string} title
65
+ * @property {string} genre
66
+ * @property {string} positioning
67
+ * @property {number|string|null} market_score
68
+ * @property {string} recommendation
69
+ */
70
+
71
+ /**
72
+ * @param {string} content
73
+ * @returns {{platforms: Array<{value:string,label:string,directions:CandidateDirection[]}>}}
74
+ */
75
+ export function parseDecisionCandidates(content) {
76
+ const lines = String(content || '').split(/\r?\n/);
77
+ const sections = [];
78
+ let cur = null;
79
+ let inFence = false;
80
+
81
+ for (const line of lines) {
82
+ if (/^\s*(?:```|~~~)/.test(line)) {
83
+ inFence = !inFence;
84
+ continue;
85
+ }
86
+ if (inFence) continue;
87
+
88
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
89
+ if (heading && /^#\s/.test(line)) {
90
+ const platform = platformOfHeader(heading[1]);
91
+ if (platform) {
92
+ if (cur) sections.push(cur);
93
+ cur = { platform, lines: [] };
94
+ continue;
95
+ }
96
+ }
97
+ if (cur) cur.lines.push(line);
98
+ }
99
+ if (cur) sections.push(cur);
100
+
101
+ const byPlatform = new Map();
102
+ for (const section of sections) {
103
+ const directions = parseDirections(section.lines);
104
+ if (!directions.length) continue;
105
+ const existing = byPlatform.get(section.platform);
106
+ if (existing) existing.directions.push(...directions);
107
+ else byPlatform.set(section.platform, {
108
+ value: section.platform,
109
+ label: platformLabel(section.platform),
110
+ directions,
111
+ });
112
+ }
113
+
114
+ const order = new Map(PLATFORM_OPTIONS.map((p, i) => [p.value, i]));
115
+ const platforms = [...byPlatform.values()].sort(
116
+ (a, b) => (order.get(a.value) ?? 99) - (order.get(b.value) ?? 99),
117
+ );
118
+ return { platforms };
119
+ }
120
+
121
+ function parseDirections(lines) {
122
+ const starts = [];
123
+ lines.forEach((line, index) => {
124
+ if (/^#{2,4}\s*方向\s*\d+\s*[::]/.test(line.trim())) starts.push(index);
125
+ });
126
+ const out = [];
127
+ for (let i = 0; i < starts.length; i += 1) {
128
+ const start = starts[i];
129
+ const end = i + 1 < starts.length ? starts[i + 1] : lines.length;
130
+ const block = lines.slice(start, end);
131
+ const title = cleanValue(block[0].replace(/^#{2,4}\s*方向\s*\d+\s*[::]\s*/, ''));
132
+ const fields = {};
133
+ for (const line of block.slice(1)) {
134
+ const m = line.match(/^\s*[-*+]?\s*\*\*([^*]+?)\*\*\s*[::]\s*(.+)$/);
135
+ if (!m) continue;
136
+ const key = cleanValue(m[1]);
137
+ const value = cleanValue(m[2]);
138
+ if (!value) continue;
139
+ fields[key] = value;
140
+ }
141
+ const positioning = fields['一句话定位'] || fields['定位'] || '';
142
+ const genre = fields['题材'] || splitTitleGenre(title);
143
+ const recommendation = fields['推荐理由'] || fields['优先级'] || '';
144
+ const market_score = parseScore(fields['市场评分'] ?? fields['评分'] ?? '');
145
+ if (!title || !genre || !positioning) continue;
146
+ out.push({
147
+ title,
148
+ genre,
149
+ positioning,
150
+ market_score,
151
+ recommendation,
152
+ });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ export { TOPIC_PLATFORMS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "3.0.4",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -26,7 +26,9 @@
26
26
  "#projects": "./lib/projects.js",
27
27
  "#db-init": "./lib/db-init.js",
28
28
  "#surreal-server": "./lib/surreal-server.js",
29
- "#material-db": "./lib/material-db.js"
29
+ "#material-db": "./lib/material-db.js",
30
+ "#material-topic": "./lib/material-topic.js",
31
+ "#topic-candidates": "./lib/topic-candidates.js"
30
32
  },
31
33
  "engines": {
32
34
  "node": ">=18.0.0",
@@ -15,42 +15,10 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { workspacePaths } from '#workspace';
17
17
 
18
- export const FALLBACK_GENRES = [
19
- '西方奇幻',
20
- '东方仙侠',
21
- '科幻末世',
22
- '都市日常',
23
- '都市修真',
24
- '都市高武',
25
- '历史古代',
26
- '战神赘婿',
27
- '都市种田',
28
- '传统玄幻',
29
- '历史脑洞',
30
- '悬疑脑洞',
31
- '都市脑洞',
32
- '玄幻脑洞',
33
- '悬疑灵异',
34
- '抗战谍战',
35
- '游戏体育',
36
- '动漫衍生',
37
- '男频衍生',
38
- '古风世情',
39
- '女频衍生',
40
- '玄幻言情',
41
- '种田',
42
- '年代',
43
- '现言脑洞',
44
- '宫斗宅斗',
45
- '古言脑洞',
46
- '快穿',
47
- '青春甜宠',
48
- '星光璀璨',
49
- '女频悬疑',
50
- '职场婚恋',
51
- '豪门总裁',
52
- '民国言情',
53
- ];
18
+ import { BUILTIN_GENRES } from '#material-topic';
19
+
20
+ /** 兼容旧名字:内置全题材列表统一放在 lib/material-topic.js */
21
+ export const FALLBACK_GENRES = BUILTIN_GENRES;
54
22
 
55
23
  function unique(values) {
56
24
  const seen = new Set();
@@ -22,9 +22,10 @@ import {
22
22
  workspacePaths,
23
23
  } from '#workspace';
24
24
  import { connectMaterial, ensureMaterialDatabase, materialDatabaseName, removeMaterialDatabase } from '#material-db';
25
+ import { BUILTIN_GENRES, resolveMaterialTopic } from '#material-topic';
25
26
  import { makeWaitForTurn } from '../fancy-story-builder/writing-driver.js';
26
27
  import { exportMaterials } from './exporter.js';
27
- import { allKnownGenres, listTopicDecisionFiles, parseTopicDecisionGenres, resolveMaterialGenres } from './genres.js';
28
+ import { allKnownGenres } from './genres.js';
28
29
  import { buildExistingIndex, countByKind, countKind } from './inventory.js';
29
30
  import { KIND_LABELS, MATERIAL_KINDS, focusBlock, renderKindTable, resolveFocus } from './kinds.js';
30
31
  import { nextBatchRequest, planMaterialQueue } from './queue.js';
@@ -388,38 +389,27 @@ async function runGenerate(ctx, invocation, session, focusRaw = '') {
388
389
  return notify(session, 'error', '❌ 未识别素材类型:' + focusRaw
389
390
  + '\n可用示例:地名、人名、姓氏、门派、技能、超能力、武器、颜色、特效、脑洞、人物性格、关系、场景。');
390
391
  }
391
- // No-arg generation asks which topic_decision report to use when several
392
- // exist. Focused runs keep the previous behavior (latest report) so a
393
- // targeted one-kind request does not require an extra click.
394
- const topicFiles = !focusRaw ? listTopicDecisionFiles(root) : [];
395
- let selectedTopicFile = null;
396
- if (topicFiles.length > 1) {
397
- const options = topicFiles.map((file) => {
398
- let genres = [];
399
- try { genres = parseTopicDecisionGenres(fs.readFileSync(file, 'utf8')); } catch { /* preview only */ }
400
- return {
401
- label: path.basename(file),
402
- description: genres.length ? genres.join('、') : '未解析到选题方向',
403
- };
404
- });
392
+ // 素材题材只认用户选题链路:
393
+ // _novel_topic.json > _novel_topic_suggestion.json > 内置全题材。
394
+ // 不再从 topic_decision 里猜题材,避免素材和用户最终选题脱节。
395
+ let plan = resolveMaterialTopic(root);
396
+ if (!plan.mode) {
405
397
  const asked = await askUserQuestion(ctx, invocation, [{
406
- id: 'material-topic-file',
407
- header: '选择选题方向',
408
- question: '📊 请选择本次素材库使用的 topic_decision 文件:',
409
- detail: '只会读取选中文件里的「选题方向」,不会合并多个文件。',
410
- options,
398
+ id: 'material-builtin-confirm',
399
+ header: '没有选题记录',
400
+ question: '当前没有执行过扫榜和 /fancy-topic,将使用内置全题材生成素材(男频 + 女频)。是否继续?',
401
+ detail: '内置题材共 ' + BUILTIN_GENRES.length + ' 类;确认后所有 kind 都会按全题材生成。',
402
+ options: [
403
+ { label: '确认', description: '使用内置全题材生成素材' },
404
+ { label: '取消', description: '不生成任何素材' },
405
+ ],
411
406
  }]);
412
407
  if (!asked.ok) {
413
408
  return notify(session, 'error', asked.failure?.message || '❌ 提问失败');
414
409
  }
415
410
  const selected = asked.answer?.answers?.[0]?.selected?.[0];
416
- if (!selected) return notify(session, 'error', '已取消素材生成。');
417
- selectedTopicFile = topicFiles.find((file) => path.basename(file) === selected) || null;
418
- if (!selectedTopicFile) {
419
- return notify(session, 'error', '❌ 未找到选中的选题文件:' + selected);
420
- }
421
- } else if (topicFiles.length === 1) {
422
- selectedTopicFile = topicFiles[0];
411
+ if (selected !== '确认') return notify(session, 'success', '操作已取消。');
412
+ plan = { mode: 'builtin', genres: [...BUILTIN_GENRES], topicFile: null };
423
413
  }
424
414
 
425
415
  const config = generationConfig();
@@ -429,10 +419,6 @@ async function runGenerate(ctx, invocation, session, focusRaw = '') {
429
419
  return notify(session, 'error', '❌ 部署素材查询脚本失败:\n' + (tools.problems.join('\n') || '未知错误'));
430
420
  }
431
421
  const database = materialDatabaseName(readWorkspace(root));
432
- const plan = resolveMaterialGenres(root, { topicFile: selectedTopicFile || undefined });
433
- if (selectedTopicFile && plan.mode !== 'topic_decision') {
434
- return notify(session, 'error', '❌ 选中的文件没有解析到「选题方向」:' + path.basename(selectedTopicFile));
435
- }
436
422
  const counts = await countByKind(root, plan.genres);
437
423
  const kinds = [...new Set(focus.kinds.length ? focus.kinds : MATERIAL_KINDS)];
438
424
  const planned = planMaterialQueue({ kinds, target: config.targetPerKind });
@@ -791,7 +777,7 @@ export async function apply(ctx) {
791
777
  try { root = workspaceOf(session); } catch { /* prompt also works outside a workspace */ }
792
778
  const genres = allKnownGenres(root);
793
779
  const rendered = renderBasePrompt({
794
- mode: 'all_genres',
780
+ mode: 'builtin',
795
781
  genres,
796
782
  topicFile: '(prompt 子命令固定输出全题材)',
797
783
  existingIndex: '(运行 /fancy-material 时由插件提供)',
@@ -17,8 +17,9 @@ export function helpText() {
17
17
  说明:
18
18
  - 素材是原子积木,每条只有 id / kind / name / tags / genres / note / source。
19
19
  - 每次运行:每个 kind 新增 50 条;每批最多 10 条,插件自动分批直到完成。
20
- - 未指定类型:54 个 kind 全部排队;有多份 topic_decision 文件时先弹窗选择。
21
- - 指定类型:只生成指定 kind,沿用最新的 topic_decision 文件。
20
+ - 未指定类型:54 个 kind 全部排队。
21
+ - 题材来源:优先 _novel_topic.json,其次 _novel_topic_suggestion.json;都没有时弹框确认后使用内置全题材。
22
+ - 指定类型:只生成指定 kind,题材来源不变。
22
23
  - 不设库内上限:运行两次就是每个 kind 累计 100 条;已有素材只用于去重。
23
24
  - 中断后重跑会开始新一轮 50 条配额,已经导入的素材不会撤销。
24
25
  - 只追加,不覆盖;同 kind 同 name 的素材会被跳过。
@@ -103,7 +104,14 @@ export function generationPrompt({
103
104
  lines.push('- 工作区目录:' + workspace);
104
105
  lines.push('- 目标文件(**绝对路径**):`' + target + '`');
105
106
  lines.push('- 批次 ID:`' + batchId + '`');
106
- lines.push('- 题材模式:' + (mode === 'topic_decision' ? 'topic_decision(选题方向)' : 'all_genres(番茄全题材)'));
107
+ const modeLabel = mode === 'topic'
108
+ ? 'topic(_novel_topic.json)'
109
+ : mode === 'suggestion'
110
+ ? 'suggestion(_novel_topic_suggestion.json)'
111
+ : mode === 'builtin'
112
+ ? 'builtin(内置全题材)'
113
+ : String(mode || '未指定');
114
+ lines.push('- 题材模式:' + modeLabel);
107
115
  if (Array.isArray(genres) && genres.length) lines.push('- 本次题材:' + genres.join('、'));
108
116
  if (focus && focus.query) {
109
117
  const kinds = (focus.kinds || []).map((item) => `\`${item}\``).join('、');