@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.
- package/docs/material-library/README.md +6 -5
- package/docs/material-library/prompt.md +3 -2
- package/docs/workspace-mcp.md +2 -2
- package/lib/material-topic.js +248 -0
- package/lib/topic-candidates.js +157 -0
- package/package.json +4 -2
- package/plugins/fancy-material/genres.js +4 -36
- package/plugins/fancy-material/index.js +18 -32
- package/plugins/fancy-material/templates.js +11 -3
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +11 -18
- package/plugins/fancy-scan/scripts/report-validate.js +39 -76
- package/plugins/fancy-scan/templates/report-schema.js +11 -8
- package/plugins/fancy-topic/index.js +216 -546
- package/plugins/fancy-topic/prompts/02-setup-prompt.md +112 -0
- package/plugins/fancy-topic/scripts/save-minimal-topic.js +97 -0
- package/plugins/fancy-topic/templates.js +104 -6
|
@@ -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
|
|
345
|
-
/fancy-topic setup
|
|
413
|
+
/fancy-topic 选择 decision 文件、平台和候选方向,写入 _novel_topic_suggestion.json
|
|
414
|
+
/fancy-topic setup 基于素材库生成 5 个候选方案,讨论确认后写入 _novel_topic.json
|
|
346
415
|
/fancy-topic help 显示本帮助
|
|
347
416
|
|
|
348
|
-
|
|
417
|
+
作用:把扫榜候选方向收敛为素材驱动的选题。`;
|
|
349
418
|
|
|
350
419
|
case 'invalid_argument':
|
|
351
420
|
return `❌ 参数无效:${arg || '(空)'}\n`
|
|
352
421
|
+ '/fancy-topic 支持以下用法:\n'
|
|
353
|
-
+
|
|
354
|
-
+ '- /fancy-topic setup
|
|
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 拆解同题材爆款小说)。` +
|