@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.
@@ -0,0 +1,112 @@
1
+ # 素材驱动选题 · ${GENRE}
2
+
3
+ 你正在把扫榜候选方向收敛成一个可执行的选题方案。
4
+
5
+ ## 一、当前候选
6
+
7
+ ```json
8
+ ${SUGGESTION_JSON}
9
+ ```
10
+
11
+ ## 二、素材库查询
12
+
13
+ 工作区:`${WORKSPACE}`
14
+
15
+ 查询脚本:`${QUERY_SCRIPT}`
16
+
17
+ 先按题材 `${GENRE}` 查询以下 kind。命令模板:
18
+
19
+ ```bash
20
+ node '${QUERY_SCRIPT}' --workspace '${WORKSPACE}' --genre '${GENRE}' --kind <kind> --limit 50 --json
21
+ ```
22
+
23
+ 需要查询的 kind:
24
+
25
+ - 能力组:${ABILITY_KINDS}
26
+ - 剧情与脑洞组:${PLOT_KINDS}
27
+
28
+ 规则:
29
+
30
+ 1. 每个 kind 至少查询一次;用 `--json` 读取结果。
31
+ 2. 只使用素材库里真实存在的素材;不要编造素材名或能力名。
32
+ 3. 如果某个 kind 在该题材下没有结果,可以去掉 `--genre` 再查一次,优先选通用素材,并在方案里说明。
33
+ 4. 组合素材时保持因果自洽:能力机制、发动条件、代价、限制、反制、力量来源要能互相对上;剧情与脑洞要能落成具体冲突、反转或爽点。
34
+ 5. 不要修改、删除 `.fancy-tools/fancy-material/` 下的脚本。
35
+
36
+ ## 三、生成 5 个差异化方案
37
+
38
+ 基于查询结果,给出 5 个**有明确差异**的方案。差异必须落在至少两个维度上,例如:
39
+
40
+ - 能力机制不同
41
+ - 能力代价/限制不同
42
+ - 核心冲突不同
43
+ - 反转或爽点不同
44
+ - 目标读者/情绪价值不同
45
+
46
+ 每个方案包含:
47
+
48
+ ```json
49
+ {
50
+ "title": "方案标题",
51
+ "summary": "一句话说明这个方案最核心的差异",
52
+ "genre": "${GENRE}",
53
+ "ability": {
54
+ "ability_mechanism": "……",
55
+ "ability_activation": "……",
56
+ "ability_cost": "……",
57
+ "ability_limit": "……",
58
+ "ability_counter": "……",
59
+ "power_source": "……"
60
+ },
61
+ "plot": {
62
+ "brainstorm": "……",
63
+ "conflict_seed": "……",
64
+ "conflict_escalation": "……",
65
+ "plot_twist": "……",
66
+ "mystery_seed": "……",
67
+ "payoff_scene": "……",
68
+ "trope": "……"
69
+ }
70
+ }
71
+ ```
72
+
73
+ 要求:
74
+
75
+ - 每个 kind 一条最终文案;如果组合了多条素材,用 `;` 连接。
76
+ - 每条文案要具体、可写,不要写“很强”“很神秘”这类空话。
77
+ - 5 个方案必须能明显区分,不能只换名字。
78
+ - `title` 要能概括方案差异,不要带“方案 N:”前缀。
79
+
80
+ ## 四、展示与讨论
81
+
82
+ 1. 先用一张表展示 5 个方案:序号、标题、一句话差异、能力机制关键词、核心冲突关键词。
83
+ 2. 再逐个方案展开 `ability` 和 `plot` 的关键内容,方便用户比较。
84
+ 3. 明确告诉用户:可以就任意方案继续讨论,例如改能力代价、换核心冲突、调整反转或爽点。
85
+ 4. 用户提出修改时,只修改对应方案,保持其它方案不变;修改后重新展示受影响的部分。
86
+
87
+ ## 五、确认与写入
88
+
89
+ 只有用户**明确确认**某个方案后,才调用工具:
90
+
91
+ ```text
92
+ fancy_topic_finalize
93
+ ```
94
+
95
+ 参数:
96
+
97
+ - `title`:选中方案的标题
98
+ - `genre`:题材
99
+ - `ability`:6 个 kind 的最终文案
100
+ - `plot`:7 个 kind 的最终文案
101
+
102
+ 调用成功后告诉用户:
103
+
104
+ - 已写入 `_novel_topic.json`
105
+ - 已创建/更新项目
106
+ - 下一步可以运行 `/fancy-material` 生成素材,或等待后续 `fancy-build` 适配
107
+
108
+ 在用户确认之前:
109
+
110
+ - 不要写入 `_novel_topic.json`
111
+ - 不要调用 `fancy_topic_finalize`
112
+ - 不要声称已经保存
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * save-minimal-topic.js —— 写入最小 `_novel_topic.json`
4
+ *
5
+ * LLM 在用户确认 5 个候选方案之一后,通过 `fancy_topic_finalize` 工具调用本脚本。
6
+ * 本脚本只做三件事:校验、原子写盘、推进 phase。项目库同步由宿主插件完成。
7
+ *
8
+ * 用法:
9
+ * node save-minimal-topic.js --project-root <abs> --input <json-file>
10
+ */
11
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
12
+ import { join, resolve } from 'path';
13
+ import { atomicWriteJSON } from '#atomic-write';
14
+ import { validateMinimalTopic, NOVEL_TOPIC_FILE } from '#material-topic';
15
+
16
+ const PHASE_ORDER = ['initialized', 'scanned', 'topic_done', 'bible_done', 'outline_done'];
17
+
18
+ function fail(message, code = 2) {
19
+ console.log(JSON.stringify({ ok: false, error: { code, message } }));
20
+ process.exit(code);
21
+ }
22
+
23
+ function parseArgs(argv) {
24
+ const out = {};
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const arg = argv[i];
27
+ if (!arg.startsWith('--')) continue;
28
+ const key = arg.slice(2).replace(/-/g, '_');
29
+ const value = argv[i + 1];
30
+ if (value === undefined) fail(`参数 ${arg} 缺少值`);
31
+ out[key] = value;
32
+ i += 1;
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function advancePhase(root, nextPhase) {
38
+ const deployedPath = join(root, '.fancy-deployed');
39
+ if (!existsSync(deployedPath)) return { updated: false, reason: 'project_not_initialized' };
40
+ try {
41
+ const data = JSON.parse(readFileSync(deployedPath, 'utf-8'));
42
+ const currentRank = PHASE_ORDER.indexOf(data.phase);
43
+ const nextRank = PHASE_ORDER.indexOf(nextPhase);
44
+ if (currentRank === -1 || nextRank === -1) {
45
+ return { updated: false, reason: 'unknown_phase', currentPhase: data.phase };
46
+ }
47
+ if (currentRank >= nextRank) {
48
+ return {
49
+ updated: false,
50
+ reason: currentRank === nextRank ? 'already_at_phase' : 'already_advanced',
51
+ currentPhase: data.phase,
52
+ };
53
+ }
54
+ const previousPhase = data.phase;
55
+ data.phase = nextPhase;
56
+ atomicWriteJSON(deployedPath, data, { mode: 0o600 });
57
+ return { updated: true, previousPhase, currentPhase: nextPhase };
58
+ } catch (e) {
59
+ return { updated: false, reason: 'error: ' + e.message };
60
+ }
61
+ }
62
+
63
+ function main() {
64
+ const args = parseArgs(process.argv.slice(2));
65
+ const root = resolve(String(args.project_root || ''));
66
+ if (!args.project_root || !existsSync(root)) fail('project-root 不存在:' + root);
67
+ if (!args.input) fail('缺少 --input');
68
+ const inputPath = resolve(String(args.input));
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(readFileSync(inputPath, 'utf-8'));
72
+ } catch (e) {
73
+ fail('读取 input JSON 失败:' + e.message);
74
+ }
75
+ const title = typeof parsed.title === 'string' ? parsed.title.trim() : '';
76
+ if (!title) fail('title 必须是非空字符串');
77
+ const validated = validateMinimalTopic(parsed);
78
+ if (!validated.ok) fail('选题字段校验失败:\n' + validated.errors.map(e => ' - ' + e).join('\n'));
79
+
80
+ const topicPath = join(root, NOVEL_TOPIC_FILE);
81
+ try {
82
+ atomicWriteJSON(topicPath, validated.normalized);
83
+ } catch (e) {
84
+ fail('写入 _novel_topic.json 失败:' + e.message, 4);
85
+ }
86
+ const phase = advancePhase(root, 'topic_done');
87
+ try { unlinkSync(inputPath); } catch { /* ignore */ }
88
+ console.log(JSON.stringify({
89
+ ok: true,
90
+ title,
91
+ topic: validated.normalized,
92
+ path: topicPath,
93
+ phase,
94
+ }));
95
+ }
96
+
97
+ main();
@@ -17,6 +17,7 @@ import { dirname, join } from 'path';
17
17
  import { createHash } from 'crypto';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { shellSingleQuote } from '#path-guard';
20
+ import { ABILITY_KINDS, PLOT_KINDS } from '#material-topic';
20
21
  import {
21
22
  TOPIC_FIELDS,
22
23
  TOPIC_FIELD_CN,
@@ -30,6 +31,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
31
  export const SAVE_SUGGESTION_SCRIPT = join(__dirname, 'scripts', 'save-suggestion.js');
31
32
  /** 静态提示词契约(**提示词层**;动态字段由本文件注入) */
32
33
  export const EXPLORATION_PROMPT_FILE = join(__dirname, 'prompts', '01-exploration-prompt.md');
34
+ export const SETUP_PROMPT_FILE = join(__dirname, 'prompts', '02-setup-prompt.md');
33
35
 
34
36
  /**
35
37
  * 渲染探索提示词:静态契约在 prompts/01-exploration-prompt.md,
@@ -53,6 +55,20 @@ export function renderExplorationPrompt(vars) {
53
55
  return text.replace(/\n$/, '');
54
56
  }
55
57
 
58
+ /**
59
+ * 渲染 setup 提示词:把 suggestion、kind 清单和查询脚本注入静态契约。
60
+ * @param {Record<string, string|number>} vars
61
+ * @returns {string}
62
+ */
63
+ export function renderSetupPrompt(vars) {
64
+ let text = readFileSync(SETUP_PROMPT_FILE, 'utf-8');
65
+ for (const [key, value] of Object.entries(vars)) {
66
+ text = text.split('${' + key + '}').join(value === undefined || value === null ? '' : String(value));
67
+ }
68
+ text = text.replace(/\$\{[a-z_]+\}/g, '');
69
+ return text.replace(/\n$/, '');
70
+ }
71
+
56
72
  const PLATFORM_OPTIONS = [
57
73
  { label: '番茄', value: 'fanqie' },
58
74
  { label: '起点', value: 'qidian' },
@@ -151,6 +167,40 @@ export const topic = {
151
167
  };
152
168
  },
153
169
 
170
+ /**
171
+ * 路径 A:只列出 decision 文件里实际存在的平台。
172
+ * @param {Array<{value:string,label:string,directions:Array}>} platforms
173
+ */
174
+ askPlatformOptions(platforms) {
175
+ return {
176
+ id: 'topic-platform',
177
+ question: '📚 请选择要查看候选方向的平台(只列出该文件中已有的平台):',
178
+ options: platforms.map((p) => ({
179
+ label: p.label,
180
+ description: `${p.directions.length} 个候选方向`,
181
+ })),
182
+ };
183
+ },
184
+
185
+ /**
186
+ * 路径 A:选择一个候选方向。
187
+ * @param {Array<{title:string,genre:string,positioning:string,market_score:number|string|null}>} directions
188
+ */
189
+ askDirection(directions) {
190
+ return {
191
+ id: 'topic-direction',
192
+ question: '🎯 请选择本次素材生成使用的选题方向:',
193
+ options: directions.map((d) => ({
194
+ label: d.title,
195
+ description: [
196
+ d.genre ? `题材:${d.genre}` : '',
197
+ d.market_score !== null && d.market_score !== undefined ? `评分:${d.market_score}` : '',
198
+ d.positioning ? `定位:${d.positioning}` : '',
199
+ ].filter(Boolean).join(';'),
200
+ })),
201
+ };
202
+ },
203
+
154
204
  /**
155
205
  * 路径 A:选 topic_decision 文件
156
206
  * @param {string[]} decisionFiles - 所有可用的 topic_decision_<date>.md 文件名(不含路径)
@@ -333,25 +383,44 @@ export const topic = {
333
383
  });
334
384
  },
335
385
 
386
+ /**
387
+ * 路径 B 的 LLM 提示词。
388
+ * @param {object} args
389
+ * @param {object} args.suggestion - `_novel_topic_suggestion.json`
390
+ * @param {string} args.queryScript - 已部署的素材查询脚本绝对路径
391
+ * @param {string} args.workspace - 工作区绝对路径
392
+ */
393
+ setupPrompt({ suggestion, queryScript, workspace }) {
394
+ const genre = suggestion?.genre || '';
395
+ return renderSetupPrompt({
396
+ GENRE: genre,
397
+ SUGGESTION_JSON: JSON.stringify(suggestion || {}, null, 2),
398
+ ABILITY_KINDS: ABILITY_KINDS.map(k => `\`${k}\``).join('、'),
399
+ PLOT_KINDS: PLOT_KINDS.map(k => `\`${k}\``).join('、'),
400
+ QUERY_SCRIPT: queryScript || '',
401
+ WORKSPACE: workspace || '',
402
+ });
403
+ },
404
+
336
405
  /**
337
406
  * 通知模板(左侧消息栏)
338
407
  */
339
- notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes, file, reasons, arg }) {
408
+ notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes, file, reasons, arg, direction, genre, suggestionPath }) {
340
409
  switch (type) {
341
410
  case 'help':
342
411
  return `📝 选题 用法
343
412
 
344
- /fancy-topic 探索选题:读取扫榜结果,生成字段建议和 6 维评分
345
- /fancy-topic setup 敲定选题:逐项确认字段并写入 _novel_topic.json
413
+ /fancy-topic 选择 decision 文件、平台和候选方向,写入 _novel_topic_suggestion.json
414
+ /fancy-topic setup 基于素材库生成 5 个候选方案,讨论确认后写入 _novel_topic.json
346
415
  /fancy-topic help 显示本帮助
347
416
 
348
- 作用:生成/确认选题与创意约束,供 fancy-build 使用。`;
417
+ 作用:把扫榜候选方向收敛为素材驱动的选题。`;
349
418
 
350
419
  case 'invalid_argument':
351
420
  return `❌ 参数无效:${arg || '(空)'}\n`
352
421
  + '/fancy-topic 支持以下用法:\n'
353
- + `- /fancy-topic:不带参数,进入探索模式 —— 读取扫榜结果,生成 ${TOPIC_FIELDS.length} 个字段建议和 6 维评分;\n`
354
- + '- /fancy-topic setup:带 setup 参数,进入敲定模式 —— 逐项确认选题字段并写入 _novel_topic.json;\n'
422
+ + '- /fancy-topic:选择 decision 文件、平台和候选方向,写入 _novel_topic_suggestion.json;\n'
423
+ + '- /fancy-topic setup:基于素材库生成 5 个候选方案,讨论确认后写入 _novel_topic.json;\n'
355
424
  + '- /fancy-topic help:查看本命令帮助。';
356
425
 
357
426
  case 'cancelled':
@@ -423,6 +492,21 @@ export const topic = {
423
492
  '请重新运行 /fancy-scan,让 LLM 重新写入报告正文。'
424
493
  );
425
494
 
495
+ case 'no_directions':
496
+ return `⚠️ 在 ${decisionFile || '所选文件'} 中没有解析到候选方向。\n` +
497
+ '请确认选题方向小节包含:题材、一句话定位、市场评分、推荐理由。';
498
+
499
+ case 'suggestion_write_failed':
500
+ return '❌ 写入 _novel_topic_suggestion.json 失败:' + (err || '未知错误');
501
+
502
+ case 'path_a_suggestion_done':
503
+ return `✅ 已写入候选选题建议\n` +
504
+ `📚 平台:${platform || ''}\n` +
505
+ `🎯 方向:${direction || ''}\n` +
506
+ `🏷️ 题材:${genre || ''}\n` +
507
+ `📁 文件:${suggestionPath || '_novel_topic_suggestion.json'}\n` +
508
+ '下一步:运行 /fancy-topic setup,让 LLM 从素材库生成 5 个候选方案。';
509
+
426
510
  case 'path_a_done': {
427
511
  const noteText = (notes && notes.length)
428
512
  ? '\n' + notes.map(n => 'ℹ️ ' + n).join('\n')
@@ -436,6 +520,20 @@ export const topic = {
436
520
  );
437
521
  }
438
522
 
523
+ case 'no_suggestion':
524
+ return '⚠️ 未找到 _novel_topic_suggestion.json。\n请先运行 /fancy-topic,选择 decision 文件、平台和候选方向。';
525
+
526
+ case 'material_not_initialized':
527
+ return '⚠️ 当前工作区还没有初始化素材库。\n请先运行 /fancy-bootstrap 初始化工作区,或运行 /fancy-material init 补齐素材库 schema。';
528
+
529
+ case 'setup_started':
530
+ return `✅ 已把素材驱动选题任务交给 LLM\n` +
531
+ `🏷️ 题材:${genre || ''}\n` +
532
+ 'LLM 会查询素材库并给出 5 个候选方案。你可以直接和它讨论;确认后它会调用 fancy_topic_finalize 写入 `_novel_topic.json`。';
533
+
534
+ case 'setup_prompt_failed':
535
+ return '❌ 向 LLM 发送 setup 提示词失败:' + (err || '未知错误');
536
+
439
537
  case 'path_b_done':
440
538
  return `✅ 选题已确认。
441
539
  下一步:运行 /fancy-build scale 构建故事(在此之前可选择运行 /fancy-analyze 拆解同题材爆款小说)。` +