@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.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fancy-topic select-topic.js — 写盘与 phase 推进
|
|
4
|
+
*
|
|
5
|
+
* 由 fancy-topic/index.js 通过 spawnScript 调用。
|
|
6
|
+
*
|
|
7
|
+
* 子命令:
|
|
8
|
+
* skeleton --project-root <abs> 创建骨架 _novel_topic.json(不推进 phase)
|
|
9
|
+
* write --project-root <abs> --platform <p> --book-name <...> --genre <...> ...
|
|
10
|
+
* 原子写入 + phase 推进到 topic_done
|
|
11
|
+
*
|
|
12
|
+
* 所有写盘都是原子写(tempfile + rename)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync, existsSync, readFileSync, renameSync } from 'fs';
|
|
16
|
+
import { join, dirname } from 'path';
|
|
17
|
+
import { EC, errorResponse } from '#error-codes';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// 工具函数
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function nowIso() {
|
|
24
|
+
return new Date().toISOString().replace('.000Z', 'Z');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// _novel_topic.json 的字段定义(与 templates.js 保持一致)
|
|
28
|
+
const NOVEL_TOPIC_FIELDS = [
|
|
29
|
+
'book_name',
|
|
30
|
+
'genre',
|
|
31
|
+
'target_words',
|
|
32
|
+
'target_chapters',
|
|
33
|
+
'core_summary',
|
|
34
|
+
'core_conflict',
|
|
35
|
+
'hard_constraints',
|
|
36
|
+
'anti_tropes',
|
|
37
|
+
'platform',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 目标章数上限。
|
|
42
|
+
*
|
|
43
|
+
* 骨架按"每卷 40~120 章"排卷,5000 章已经是 42 卷 —— 再往上卷数会大到
|
|
44
|
+
* 骨架、节奏、写作三个阶段都排不开。与其让它一路算到某个阶段崩掉,
|
|
45
|
+
* 不如在入口就说清楚"请拆成多部书"。
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_TARGET_CHAPTERS = 5000;
|
|
48
|
+
|
|
49
|
+
function buildSkeleton() {
|
|
50
|
+
const obj = {};
|
|
51
|
+
for (const f of NOVEL_TOPIC_FIELDS) obj[f] = Array.isArray(obj[f]) ? [] : '';
|
|
52
|
+
obj.target_words = 0;
|
|
53
|
+
obj.target_chapters = 0;
|
|
54
|
+
obj.hard_constraints = [];
|
|
55
|
+
obj.anti_tropes = [];
|
|
56
|
+
return obj;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 原子写 JSON
|
|
60
|
+
function atomicWriteJSON(filepath, data) {
|
|
61
|
+
const tmpPath = join(dirname(filepath), `.${Date.now()}-${process.pid}.tmp`);
|
|
62
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
63
|
+
renameSync(tmpPath, filepath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 把 .fancy-deployed 的 phase 推进到指定值(只进不退)
|
|
67
|
+
const PHASE_ORDER = ['initialized', 'scanned', 'topic_done', 'bible_done', 'outline_done'];
|
|
68
|
+
function advancePhase(projectRoot, nextPhase) {
|
|
69
|
+
const deployedPath = join(projectRoot, '.fancy-deployed');
|
|
70
|
+
if (!existsSync(deployedPath)) return { updated: false, reason: 'project_not_initialized' };
|
|
71
|
+
try {
|
|
72
|
+
const data = JSON.parse(readFileSync(deployedPath, 'utf-8'));
|
|
73
|
+
const currentRank = PHASE_ORDER.indexOf(data.phase);
|
|
74
|
+
const nextRank = PHASE_ORDER.indexOf(nextPhase);
|
|
75
|
+
if (currentRank === -1 || nextRank === -1) {
|
|
76
|
+
return { updated: false, reason: 'unknown_phase', currentPhase: data.phase };
|
|
77
|
+
}
|
|
78
|
+
if (currentRank >= nextRank) {
|
|
79
|
+
return { updated: false, reason: currentRank === nextRank ? 'already_at_phase' : 'already_advanced', currentPhase: data.phase };
|
|
80
|
+
}
|
|
81
|
+
const previousPhase = data.phase;
|
|
82
|
+
data.phase = nextPhase;
|
|
83
|
+
atomicWriteJSON(deployedPath, data);
|
|
84
|
+
return { updated: true, previousPhase, currentPhase: nextPhase };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return { updated: false, reason: `error: ${e.message}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fail(ec, extra = {}) {
|
|
91
|
+
const exitCode = extra.exitCode ?? ec.code;
|
|
92
|
+
console.log(JSON.stringify(errorResponse(ec, extra)));
|
|
93
|
+
process.exit(exitCode);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// 子命令:skeleton
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function cmdSkeleton(args) {
|
|
101
|
+
const projectRoot = args.project_root;
|
|
102
|
+
if (!projectRoot) fail(EC.EC042_MISSING_REQUIRED, { message: '缺少 --project-root' });
|
|
103
|
+
|
|
104
|
+
const deployed = join(projectRoot, '.fancy-deployed');
|
|
105
|
+
if (!existsSync(deployed)) fail(EC.EC124_PROJECT_NOT_INITIALIZED, { message: '项目未初始化:.fancy-deployed 不存在' });
|
|
106
|
+
|
|
107
|
+
const novelTopicPath = join(projectRoot, '_novel_topic.json');
|
|
108
|
+
if (existsSync(novelTopicPath)) {
|
|
109
|
+
// 骨架已存在,不重复创建
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
ok: true, operation: 'skeleton', project_root: projectRoot, novel_topic: novelTopicPath,
|
|
112
|
+
created: false, reason: 'already_exists',
|
|
113
|
+
}));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const now = nowIso();
|
|
118
|
+
const skeleton = {
|
|
119
|
+
...buildSkeleton(),
|
|
120
|
+
created_at: now,
|
|
121
|
+
updated_at: now,
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
atomicWriteJSON(novelTopicPath, skeleton);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
fail(EC.EC045_TOPIC_WRITE_FAILED, { message: `创建骨架失败: ${e.message}` });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(JSON.stringify({
|
|
130
|
+
ok: true, operation: 'skeleton', project_root: projectRoot, novel_topic: novelTopicPath,
|
|
131
|
+
created: true,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// 子命令:write
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function cmdWrite(args) {
|
|
140
|
+
const projectRoot = args.project_root;
|
|
141
|
+
if (!projectRoot) fail(EC.EC042_MISSING_REQUIRED, { message: '缺少 --project-root' });
|
|
142
|
+
|
|
143
|
+
const deployed = join(projectRoot, '.fancy-deployed');
|
|
144
|
+
if (!existsSync(deployed)) fail(EC.EC124_PROJECT_NOT_INITIALIZED, { message: '项目未初始化:.fancy-deployed 不存在' });
|
|
145
|
+
|
|
146
|
+
// 验证 phase(路径 B 只允许 scanned 或 topic_done)
|
|
147
|
+
let phase;
|
|
148
|
+
try {
|
|
149
|
+
phase = JSON.parse(readFileSync(deployed, 'utf-8')).phase;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
fail(EC.EC123_JSON_PARSE_FAIL, { message: `解析 .fancy-deployed 失败: ${e.message}` });
|
|
152
|
+
}
|
|
153
|
+
if (phase !== 'scanned' && phase !== 'topic_done') {
|
|
154
|
+
fail(EC.EC043_PHASE_NOT_TOPIC, { message: `当前 phase=${phase},fancy-topic 需要 scanned 或 topic_done` });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 校验所有必填字段
|
|
158
|
+
for (const f of NOVEL_TOPIC_FIELDS) {
|
|
159
|
+
const v = args[f];
|
|
160
|
+
if (v === undefined || v === null || v === '') {
|
|
161
|
+
fail(EC.EC042_MISSING_REQUIRED, { message: `缺少必填字段: ${f}` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 字数 / 章数必须是正整数
|
|
166
|
+
//
|
|
167
|
+
// 用 Number() 而不是 parseInt():parseInt('600abc') 会**截断成 600**,
|
|
168
|
+
// 用户(或上游脚本)传进来的脏值就这样被当成合法值吞掉了。
|
|
169
|
+
// 2026-10-27 核实:旧实现 parseInt('600abc', 10) → 600,parseInt('600.9') → 600。
|
|
170
|
+
const targetWords = Number(args.target_words);
|
|
171
|
+
const targetChapters = Number(args.target_chapters);
|
|
172
|
+
if (!Number.isInteger(targetWords) || targetWords <= 0) {
|
|
173
|
+
fail(EC.EC046_INVALID_WORDS, { message: `target_words 必须为正整数: ${args.target_words}` });
|
|
174
|
+
}
|
|
175
|
+
if (!Number.isInteger(targetChapters) || targetChapters <= 0) {
|
|
176
|
+
fail(EC.EC047_INVALID_CHAPTERS, { message: `target_chapters 必须为正整数: ${args.target_chapters}` });
|
|
177
|
+
}
|
|
178
|
+
// 章数上限:超过这个量级,一本书的卷数会大到没法写(骨架按每卷 40~120 章排卷,
|
|
179
|
+
// 5000 章已经是 42 卷)。超了就说清楚要拆书,而不是让下游一路算到崩。
|
|
180
|
+
if (targetChapters > MAX_TARGET_CHAPTERS) {
|
|
181
|
+
fail(EC.EC047_INVALID_CHAPTERS, {
|
|
182
|
+
message: `target_chapters 超过上限 ${MAX_TARGET_CHAPTERS} 章(当前 ${targetChapters}):` +
|
|
183
|
+
`一本书记不下这么多章,请拆成多部书,每部不超过 ${MAX_TARGET_CHAPTERS} 章`
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 解析 hard_constraints / anti_tropes(可能是 JSON 数组字符串)
|
|
188
|
+
let hardConstraints, antiTropes;
|
|
189
|
+
try {
|
|
190
|
+
hardConstraints = Array.isArray(args.hard_constraints)
|
|
191
|
+
? args.hard_constraints
|
|
192
|
+
: (typeof args.hard_constraints === 'string' ? JSON.parse(args.hard_constraints) : []);
|
|
193
|
+
antiTropes = Array.isArray(args.anti_tropes)
|
|
194
|
+
? args.anti_tropes
|
|
195
|
+
: (typeof args.anti_tropes === 'string' ? JSON.parse(args.anti_tropes) : []);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
fail(EC.EC123_JSON_PARSE_FAIL, { message: `解析 hard_constraints/anti_tropes 失败: ${e.message}` });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 读已有 _novel_topic.json(保留 created_at)
|
|
201
|
+
const novelTopicPath = join(projectRoot, '_novel_topic.json');
|
|
202
|
+
let existing = {};
|
|
203
|
+
if (existsSync(novelTopicPath)) {
|
|
204
|
+
try { existing = JSON.parse(readFileSync(novelTopicPath, 'utf-8')); } catch (_) { existing = {}; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const now = nowIso();
|
|
208
|
+
const data = {
|
|
209
|
+
book_name: String(args.book_name),
|
|
210
|
+
genre: String(args.genre),
|
|
211
|
+
target_words: targetWords,
|
|
212
|
+
target_chapters: targetChapters,
|
|
213
|
+
core_summary: String(args.core_summary),
|
|
214
|
+
core_conflict: String(args.core_conflict),
|
|
215
|
+
hard_constraints: hardConstraints,
|
|
216
|
+
anti_tropes: antiTropes,
|
|
217
|
+
platform: String(args.platform),
|
|
218
|
+
created_at: existing.created_at || now,
|
|
219
|
+
updated_at: now,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
atomicWriteJSON(novelTopicPath, data);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
fail(EC.EC045_TOPIC_WRITE_FAILED, { message: `原子写失败: ${e.message}` });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 推进 phase → topic_done
|
|
229
|
+
const phaseResult = advancePhase(projectRoot, 'topic_done');
|
|
230
|
+
|
|
231
|
+
console.log(JSON.stringify({
|
|
232
|
+
ok: true, operation: 'write', project_root: projectRoot, novel_topic: novelTopicPath,
|
|
233
|
+
fields_written: Object.keys(data).length,
|
|
234
|
+
data,
|
|
235
|
+
phase_advance: phaseResult,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// 入口
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
function main() {
|
|
244
|
+
const args = process.argv.slice(2);
|
|
245
|
+
|
|
246
|
+
// 解析所有 --key value / --key=value 对
|
|
247
|
+
// 约定:所有 key 把连字符 '-' 标准化为下划线 '_'(便于 JS 访问)
|
|
248
|
+
const parsed = {};
|
|
249
|
+
let subcommand = null;
|
|
250
|
+
for (let i = 0; i < args.length; i++) {
|
|
251
|
+
const a = args[i];
|
|
252
|
+
if (a.startsWith('--')) {
|
|
253
|
+
const eq = a.indexOf('=');
|
|
254
|
+
const rawKey = eq !== -1 ? a.slice(2, eq) : a.slice(2);
|
|
255
|
+
const key = rawKey.replace(/-/g, '_');
|
|
256
|
+
const value = eq !== -1 ? a.slice(eq + 1) : (args[i + 1] || '');
|
|
257
|
+
parsed[key] = value;
|
|
258
|
+
if (eq === -1) i++;
|
|
259
|
+
} else if (subcommand === null) {
|
|
260
|
+
subcommand = a;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
switch (subcommand) {
|
|
265
|
+
case 'skeleton':
|
|
266
|
+
return cmdSkeleton(parsed);
|
|
267
|
+
case 'write':
|
|
268
|
+
return cmdWrite(parsed);
|
|
269
|
+
default:
|
|
270
|
+
fail(EC.EC042_MISSING_REQUIRED, { message: `缺少子命令:第一个非 -- 参数应为 'skeleton' 或 'write',收到 '${subcommand}'` });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
main();
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-topic templates — 通知 + 弹框 + 字段定义
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const PLATFORM_OPTIONS = [
|
|
6
|
+
{ label: '番茄', value: 'fanqie' },
|
|
7
|
+
{ label: '起点', value: 'qidian' },
|
|
8
|
+
{ label: '晋江', value: 'jinjiang' },
|
|
9
|
+
{ label: '七猫', value: 'qimao' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const GENRE_OPTIONS = [
|
|
13
|
+
{ label: '玄幻', value: 'xianxia' },
|
|
14
|
+
{ label: '都市', value: 'urban' },
|
|
15
|
+
{ label: '言情', value: 'romance' },
|
|
16
|
+
{ label: '悬疑', value: 'mystery' },
|
|
17
|
+
{ label: '历史', value: 'history' },
|
|
18
|
+
{ label: '科幻', value: 'scifi' },
|
|
19
|
+
{ label: '仙侠', value: 'xianxia-cultivation' },
|
|
20
|
+
{ label: '其他', value: 'other' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// 「取消(重新扫榜)」选项的 label。
|
|
24
|
+
// DSH 的 AskUserQuestionOption 只有 label/description 两个合法字段,
|
|
25
|
+
// selected 回传的是 label —— 此前这里用 value:'__rescan__' 判断,
|
|
26
|
+
// 而 value 根本不会被 DSH 回传,导致"重新扫榜"分支永远不可达。
|
|
27
|
+
const RESCAN_LABEL = '取消(重新扫榜)';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 剥离 value,转换成 DSH 合法的 AskUserQuestionOption。
|
|
31
|
+
*
|
|
32
|
+
* DSH 的 AskUserQuestionOption 只有 label / description;
|
|
33
|
+
* value 属于契约外字段,传了会被运行时静默忽略。
|
|
34
|
+
* 上面的 value 仅供插件内部做 label↔value 映射(platformCn/platformToValue)。
|
|
35
|
+
*
|
|
36
|
+
* description 是可选字段:**没有就整个键省掉**,不能写成 undefined ——
|
|
37
|
+
* DSH 的 api gateway 在远程模式下要求提问请求是"lossless JSON",
|
|
38
|
+
* 属性值不允许是 undefined(JSON.stringify 会悄悄丢掉它,但网关按属性
|
|
39
|
+
* 存在性判定),带上就会让整个请求被拒:
|
|
40
|
+
* "api gateway: Remote event request is not lossless JSON data"
|
|
41
|
+
*/
|
|
42
|
+
function toAskOptions(options) {
|
|
43
|
+
return options.map(o => {
|
|
44
|
+
const opt = { label: o.label };
|
|
45
|
+
if (o.description !== undefined) opt.description = o.description;
|
|
46
|
+
return opt;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 8 个字段定义(路径 B 弹框用)
|
|
52
|
+
* 每个字段:id、prompt 提示、kind(text/number/multiselect)、options、defaultGetter
|
|
53
|
+
*/
|
|
54
|
+
const TOPIC_FIELDS = [
|
|
55
|
+
{ id: 'book_name', label: '书名', kind: 'text', required: true,
|
|
56
|
+
placeholder: '例如:诡秘之主' },
|
|
57
|
+
{ id: 'genre', label: '题材', kind: 'select', options: GENRE_OPTIONS, required: true },
|
|
58
|
+
{ id: 'target_words', label: '目标字数', kind: 'number', required: true,
|
|
59
|
+
placeholder: '例如:2000000' },
|
|
60
|
+
{ id: 'target_chapters', label: '目标章数', kind: 'number', required: true,
|
|
61
|
+
placeholder: '例如:600' },
|
|
62
|
+
{ id: 'core_summary', label: '一句话故事', kind: 'text', required: true,
|
|
63
|
+
placeholder: '用一句话概括故事核心' },
|
|
64
|
+
{ id: 'core_conflict', label: '核心冲突', kind: 'text', required: true,
|
|
65
|
+
placeholder: '故事的主要矛盾是什么' },
|
|
66
|
+
{ id: 'hard_constraints', label: '硬门禁', kind: 'multiselect', required: true,
|
|
67
|
+
description: '绝对不能违反的约束;可多选;如无约束,输入 "无"' },
|
|
68
|
+
{ id: 'anti_tropes', label: '反套路', kind: 'multiselect', required: true,
|
|
69
|
+
description: '必须避免的俗套;可多选;如无,输入 "无"' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const topic = {
|
|
73
|
+
PLATFORM_OPTIONS,
|
|
74
|
+
GENRE_OPTIONS,
|
|
75
|
+
TOPIC_FIELDS,
|
|
76
|
+
RESCAN_LABEL,
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 路径 A:选平台弹框
|
|
80
|
+
*/
|
|
81
|
+
askPlatform() {
|
|
82
|
+
return {
|
|
83
|
+
id: 'topic-platform',
|
|
84
|
+
question: '📚 请选择小说计划发布到哪个平台:',
|
|
85
|
+
options: toAskOptions(PLATFORM_OPTIONS),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 路径 A:选 topic_decision 文件
|
|
91
|
+
* @param {string[]} decisionFiles - 所有可用的 topic_decision_<date>.md 文件名(不含路径)
|
|
92
|
+
*/
|
|
93
|
+
askDecisionFile(decisionFiles) {
|
|
94
|
+
return {
|
|
95
|
+
id: 'topic-decision',
|
|
96
|
+
question: '📊 请选择参考的扫榜分析文件:',
|
|
97
|
+
options: [
|
|
98
|
+
...decisionFiles.map(f => ({ label: f })),
|
|
99
|
+
{ label: RESCAN_LABEL },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 路径 B:8 字段弹框
|
|
106
|
+
*
|
|
107
|
+
* 问题对象只允许 DSH 的合法字段:id / question / detail / header /
|
|
108
|
+
* options / multiSelect / intent。
|
|
109
|
+
*
|
|
110
|
+
* placeholder / default / question 上的 description 都**不是** DSH 的字段,
|
|
111
|
+
* 传了会被运行时静默忽略(与 options.value 同类问题)—— 所以:
|
|
112
|
+
* - placeholder 里的示例、description 里的字段说明,都写进**标题行**
|
|
113
|
+
* (question 的第一行),归到标题的括号里;
|
|
114
|
+
* - "📌 当前值"和"💡 推荐值"写进 **detail**(不是 question):
|
|
115
|
+
* question 在 web 端被渲染成 <h2>,那条 CSS 没有 white-space: pre-wrap,
|
|
116
|
+
* 换行会被折成空格,标题和两个值会挤在同一行;detail 才是两端
|
|
117
|
+
* 都独立成块、都能换行的字段(web 走 markdown 硬换行,TUI 逐行渲染);
|
|
118
|
+
* - default 字段直接删掉:DSH 没有"预填输入框"的能力,
|
|
119
|
+
* 上次的值通过 detail 里的 "📌 当前值" 展示,能力不丢。
|
|
120
|
+
*
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {object} [opts.currentValues] - _novel_topic.json 中的值(已按平台过滤)
|
|
123
|
+
* @param {object} [opts.suggestionValues] - _novel_topic_suggestion.json 中的值(已按平台过滤)
|
|
124
|
+
*/
|
|
125
|
+
askFields(opts = {}) {
|
|
126
|
+
const { currentValues = {}, suggestionValues = null } = opts;
|
|
127
|
+
return TOPIC_FIELDS.map(f => {
|
|
128
|
+
const curVal = currentValues[f.id];
|
|
129
|
+
const sugVal = suggestionValues ? suggestionValues[f.id] : undefined;
|
|
130
|
+
|
|
131
|
+
// null / undefined / '' / 空数组 / 数字 0 → 视为无值
|
|
132
|
+
// (target_words / target_chapters = 0 是骨架模板的默认值,无意义)
|
|
133
|
+
const hasValue = (v) =>
|
|
134
|
+
v !== undefined && v !== null && v !== '' &&
|
|
135
|
+
!(Array.isArray(v) && v.length === 0) &&
|
|
136
|
+
!(typeof v === 'number' && v === 0);
|
|
137
|
+
|
|
138
|
+
const fmt = (v) => (Array.isArray(v) ? v.join('、') : String(v));
|
|
139
|
+
const curStr = hasValue(curVal) ? fmt(curVal) : '';
|
|
140
|
+
const sugStr = hasValue(sugVal) ? fmt(sugVal) : '';
|
|
141
|
+
|
|
142
|
+
// 标题 = 字段名 + (必填,示例 / 说明)
|
|
143
|
+
// 示例(placeholder)与说明(description)都归到括号里。
|
|
144
|
+
const titleBits = [];
|
|
145
|
+
if (f.required) titleBits.push('必填');
|
|
146
|
+
if (f.placeholder) titleBits.push(f.placeholder);
|
|
147
|
+
if (f.description) titleBits.push(f.description);
|
|
148
|
+
const title = titleBits.length ? `(${titleBits.join(',')})` : '';
|
|
149
|
+
const titleLine = `📝 ${f.label}${title}:`;
|
|
150
|
+
|
|
151
|
+
// 当前值 / 推荐值放 **detail**,不放 question。
|
|
152
|
+
//
|
|
153
|
+
// 为什么不能放 question:两端渲染方式不一样。
|
|
154
|
+
// web 端把 question 渲染成 <h2 class="title">,那条 CSS 没有
|
|
155
|
+
// white-space: pre-wrap —— HTML 默认会把 \n 折成空格,于是
|
|
156
|
+
// "标题 + 当前值 + 推荐值"挤在同一行(用户实测过)。
|
|
157
|
+
// TUI 端是 <Text>{question.question}</Text>,\n 会换行。
|
|
158
|
+
// 同一份内容两端表现不一致,必须改用两端都支持的 detail。
|
|
159
|
+
//
|
|
160
|
+
// detail 是独立的块,两端都是:
|
|
161
|
+
// web 端 → 标题下方的 <div class="detail">(走 MarkdownText)
|
|
162
|
+
// TUI 端 → detail.split('\n') 逐行渲染(dim 文本)
|
|
163
|
+
//
|
|
164
|
+
// 但 web 端的 detail 走 markdown:**单个 \n 是"软换行"**,mdast 会把它
|
|
165
|
+
// 留在 text 节点里,HTML 照样折成空格 —— 还是一行。
|
|
166
|
+
// 必须用**硬换行**(行尾两个空格):mdast 产出 break 节点,
|
|
167
|
+
// DSH 的 MarkdownText 把 break 渲染成 <br>,这才是真换行。
|
|
168
|
+
// (依据:dsh-web-frontend 的 markdown 渲染器里有
|
|
169
|
+
// case "break": <><br/>{"\n"}</>
|
|
170
|
+
// 而 .markdown 容器没有 white-space: pre-wrap,软换行必被折叠。)
|
|
171
|
+
//
|
|
172
|
+
// 两个空格写在字符串字面量里(' \n'),源码中没有行尾空白,
|
|
173
|
+
// 不会被格式化工具或编辑器悄悄吃掉。
|
|
174
|
+
const hints = [];
|
|
175
|
+
if (curStr) hints.push(`📌 当前值:${curStr}`);
|
|
176
|
+
if (sugStr) hints.push(`💡 推荐值:${sugStr}`);
|
|
177
|
+
|
|
178
|
+
const def = {
|
|
179
|
+
id: f.id,
|
|
180
|
+
question: titleLine,
|
|
181
|
+
};
|
|
182
|
+
// 没有当前值/推荐值时**不要**给 detail 赋值:DSH 端按
|
|
183
|
+
// `detail !== undefined` 判断要不要渲染这个块,给空串会渲染出一个
|
|
184
|
+
// 空 div(web 端还带 8px 下边距),TUI 端会渲染一行空的 dim 文本。
|
|
185
|
+
if (hints.length) def.detail = hints.join(' \n');
|
|
186
|
+
if (f.options) def.options = toAskOptions(f.options);
|
|
187
|
+
// multiSelect 必须在问题对象上声明:DSH 的 UI 靠它决定渲染 checkbox
|
|
188
|
+
// 还是 radio(dsh-client-ui-user-questions/lib/client.js:
|
|
189
|
+
// question.multiSelect === true ? "checkbox" : "radio")。
|
|
190
|
+
// 不设就是单选,会与标题里"可多选"的说明自相矛盾。
|
|
191
|
+
if (f.kind === 'multiselect') def.multiSelect = true;
|
|
192
|
+
return def;
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 路径 A 的 LLM 提示词
|
|
198
|
+
* @param {object} args
|
|
199
|
+
* @param {string} args.platform - 中文平台名(番茄/起点/晋江/七猫)
|
|
200
|
+
* @param {string} args.decisionFile - topic_decision_<date>.md 文件名
|
|
201
|
+
* @param {string} args.blocks - 拼接好的内容块(多个 # start/end 块用 --- 分隔)
|
|
202
|
+
* @param {number} args.blockCount - 本次平台用到的块数
|
|
203
|
+
* @param {Array} args.sectionSummary - 按平台分组的全文件块清单
|
|
204
|
+
* [{ platform: '起点', blocks: [{ headers: [...] }, ...] }, ...]
|
|
205
|
+
* @param {string[]} args.notes - 解析备注(写进提示词,让用户看见解析情况)
|
|
206
|
+
*/
|
|
207
|
+
explorePrompt({ platform, decisionFile, blocks, blockCount, sectionSummary = [], notes = [] }) {
|
|
208
|
+
const sourceLines = [];
|
|
209
|
+
if (sectionSummary.length) {
|
|
210
|
+
// 总数是**块数**(不是平台数):同一平台可能有多个 -start 块
|
|
211
|
+
const totalBlocks = sectionSummary.reduce((n, s) => n + s.blocks.length, 0);
|
|
212
|
+
sourceLines.push(`该文件按平台切分出 ${totalBlocks} 块:`);
|
|
213
|
+
for (const s of sectionSummary) {
|
|
214
|
+
sourceLines.push(` · ${s.platform}(${s.blocks.length} 块):`);
|
|
215
|
+
s.blocks.forEach((b, i) => {
|
|
216
|
+
sourceLines.push(` ${i + 1}. ${b.headers.join(' / ')}`);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
sourceLines.push(`本次只使用「${platform}」的 ${blockCount} 块。`);
|
|
220
|
+
}
|
|
221
|
+
if (notes.length) {
|
|
222
|
+
sourceLines.push('');
|
|
223
|
+
sourceLines.push('解析说明:');
|
|
224
|
+
for (const n of notes) sourceLines.push(` · ${n}`);
|
|
225
|
+
}
|
|
226
|
+
return [
|
|
227
|
+
`## 选题探索任务`,
|
|
228
|
+
``,
|
|
229
|
+
`平台:${platform}`,
|
|
230
|
+
`参考文件:${decisionFile}(含 ${blockCount} 个内容块)`,
|
|
231
|
+
...(sourceLines.length ? ['', ...sourceLines] : []),
|
|
232
|
+
``,
|
|
233
|
+
`以下是用户选中的 topic_decision 文件中与"${platform}"相关的所有内容块(每块用 ## 块 N 标记):`,
|
|
234
|
+
``,
|
|
235
|
+
blocks,
|
|
236
|
+
``,
|
|
237
|
+
`请执行以下分析:`,
|
|
238
|
+
``,
|
|
239
|
+
`### 第一部分:8 个字段建议`,
|
|
240
|
+
`基于扫榜内容,给出以下字段的推荐值(**必须**忽略历史上下文和之前的选择,且**禁止**使用单引号或者双引号):`,
|
|
241
|
+
`1. **书名**(book_name)— 根据榜单上同题材书籍的命名方式给出契合本平台风格的建议`,
|
|
242
|
+
`2. **题材**(genre)— 根据榜单中的热门题材给出契合的建议`,
|
|
243
|
+
`3. **目标字数**(target_words)— 整数,例如 2000000`,
|
|
244
|
+
`4. **目标章数**(target_chapters)— 整数,例如 600`,
|
|
245
|
+
`5. **一句话故事**(core_summary)— 50 字以内概括核心`,
|
|
246
|
+
`6. **核心冲突**(core_conflict)— 主要矛盾是什么`,
|
|
247
|
+
`7. **硬门禁**(hard_constraints)— 绝对不能违反的约束(可多值)`,
|
|
248
|
+
`8. **反套路**(anti_tropes)— 必须避免的俗套(可多值)`,
|
|
249
|
+
``,
|
|
250
|
+
`每个字段请给出一个推荐值 + 一句话解释为什么这样推荐。`,
|
|
251
|
+
``,
|
|
252
|
+
`### 第二部分:6 维评分`,
|
|
253
|
+
`基于上面给出的建议,对"在 ${platform} 平台写一本新书"这件事,从以下 6 个维度评分(1-10):`,
|
|
254
|
+
`1. **市场热度** — 该平台当前题材热度如何,在读人数越多、增长趋势越快、月票积分等指标越高则评分越高`,
|
|
255
|
+
`2. **竞争格局** — 同类作品的多少。同类作品多+市场热度高->竞争一般,同类作品少+市场热度高->竞争少,竞争越少评分越高`,
|
|
256
|
+
`3. **受众规模** — 题材潜在读者群大小,读者越多、积分越多则评分越高`,
|
|
257
|
+
`4. **创作难度** — 作者需要投入的精力,投入精力越少评分越高`,
|
|
258
|
+
`5. **变现潜力** — 商业化前景,变现能力越强评分越高`,
|
|
259
|
+
`6. **合规风险** — 低/中/高,风险越低评分越高、风险越高评分越低`,
|
|
260
|
+
`最后给出综合分(1-10)和是否值得进入的结论。`,
|
|
261
|
+
``,
|
|
262
|
+
`### 第三部分:保存建议`,
|
|
263
|
+
`在你给出 8 个字段推荐值后,把推荐值保存到 \`_novel_topic_suggestion.json\`:`,
|
|
264
|
+
`注意:platform 字段必须写平台中文名("${platform}"),不要写英文 value(如 "qidian")。` +
|
|
265
|
+
`所有字段值必须是合法 JSON(双引号、逗号、换行都要转义或避开)。` +
|
|
266
|
+
`core_summary / core_conflict 中如需引用书名 / 概念,用全角引号"…"或『…』,避免与 JSON 双引号冲突。`,
|
|
267
|
+
``,
|
|
268
|
+
`\`\`\`json`,
|
|
269
|
+
`{`,
|
|
270
|
+
` "book_name": "...",`,
|
|
271
|
+
` "genre": "...",`,
|
|
272
|
+
` "target_words": 2000000,`,
|
|
273
|
+
` "target_chapters": 600,`,
|
|
274
|
+
` "core_summary": "...",`,
|
|
275
|
+
` "core_conflict": "...",`,
|
|
276
|
+
` "hard_constraints": ["..."],`,
|
|
277
|
+
` "anti_tropes": ["..."],`,
|
|
278
|
+
` "platform": "${platform}",`,
|
|
279
|
+
` "source_decision_file": "${decisionFile}"`,
|
|
280
|
+
`}`,
|
|
281
|
+
`\`\`\``,
|
|
282
|
+
``,
|
|
283
|
+
`### 第四部分:与用户对话`,
|
|
284
|
+
`1. **必须**以用户可见的方式**完整**展示出 8 个字段推荐值和 6 维评分`,
|
|
285
|
+
`2. 提醒用户可继续根据当前给出的 8 个字段推荐值和 6 维评分展开讨论,根据用户的需求进行多次修正,并将用户最终的意见更新至 _novel_topic_suggestion.json 中`,
|
|
286
|
+
`3. 提醒用户运行 /fancy-topic setup 提交最终决定`,
|
|
287
|
+
].join('\n');
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 通知模板(左侧消息栏)
|
|
292
|
+
*/
|
|
293
|
+
notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes }) {
|
|
294
|
+
switch (type) {
|
|
295
|
+
case 'cancelled':
|
|
296
|
+
return '🚫 已取消';
|
|
297
|
+
|
|
298
|
+
case 'no_provider':
|
|
299
|
+
return '⚠️ 当前环境不支持交互式提问。请在 dsh-tui 中运行此命令,或确保 web UI 已正确加载交互组件。';
|
|
300
|
+
|
|
301
|
+
case 'no_project_root':
|
|
302
|
+
return '❌ 无法确定项目目录:' + (err || '会话未提供 cwd') + '。请在项目目录下运行该命令。';
|
|
303
|
+
|
|
304
|
+
case 'ask_failed':
|
|
305
|
+
return '❌ 提问失败:' + (err || '未知错误');
|
|
306
|
+
|
|
307
|
+
case 'not_initialized':
|
|
308
|
+
return '⚠️ 当前目录尚未初始化,请先运行 /fancy-bootstrap 进行初始化。';
|
|
309
|
+
|
|
310
|
+
case 'phase_wrong':
|
|
311
|
+
return `⚠️ 当前 phase(${phase || '未知'})不允许运行 /fancy-topic,请先运行 /fancy-scan 扫榜。`;
|
|
312
|
+
|
|
313
|
+
case 'phase_unreadable':
|
|
314
|
+
return (
|
|
315
|
+
`❌ 状态文件 .fancy-deployed 无法读取(${err || '内容异常'}),无法确定项目状态。\n` +
|
|
316
|
+
'请检查该文件是否被手工改坏,或重新运行 /fancy-bootstrap 初始化。'
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
case 'no_decision_file':
|
|
320
|
+
return '⚠️ 未找到 topic_decision_<date>.md 文件。请先运行 /fancy-scan 采集榜单并等待 LLM 分析完成';
|
|
321
|
+
|
|
322
|
+
case 'no_decision_block':
|
|
323
|
+
return (
|
|
324
|
+
`⚠️ 在 ${decisionFile || '所选文件'} 里没有找到任何 "# 平台-榜单-日期-start/end" 内容块。\n` +
|
|
325
|
+
'多半是 LLM 没有按约定格式写入,建议重新运行 /fancy-scan 触发分析。'
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
case 'platform_not_in_decision':
|
|
329
|
+
return (
|
|
330
|
+
`⚠️ 在 ${decisionFile || '所选文件'} 里没有找到「${platform || ''}」的内容块。\n` +
|
|
331
|
+
`该文件里已有的块:${(available && available.length) ? available.join('、') : '(无)'}\n` +
|
|
332
|
+
'请确认是否扫过该平台。'
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
case 'invalid_platform':
|
|
336
|
+
return `❌ 无效的平台:${platform}`;
|
|
337
|
+
|
|
338
|
+
case 'path_a_done': {
|
|
339
|
+
const noteText = (notes && notes.length)
|
|
340
|
+
? '\n' + notes.map(n => 'ℹ️ ' + n).join('\n')
|
|
341
|
+
: '';
|
|
342
|
+
return (
|
|
343
|
+
`📊 已根据扫榜结果请 LLM 分析 ${blockCount || '?'} 个数据块\n` +
|
|
344
|
+
`📚 平台:${platform || ''}\n` +
|
|
345
|
+
`📁 参考:${decisionFile || ''}` +
|
|
346
|
+
noteText + '\n' +
|
|
347
|
+
`💡 LLM 即将在对话中给出 6 维评分 + 8 字段建议;敲定后请运行 /fancy-topic setup 写入最终决定`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'path_b_done':
|
|
352
|
+
return `✅ 选题已确认!
|
|
353
|
+
下一步:
|
|
354
|
+
运行 /fancy-story-builder 构建故事 (在此之前可选择运行 /fancy-analyze 拆解同题材爆款小说)` +
|
|
355
|
+
(extra ? '\n\n' + extra : '');
|
|
356
|
+
|
|
357
|
+
case 'write_failed':
|
|
358
|
+
return '❌ 写入 _novel_topic.json 失败:' + (err || '未知错误');
|
|
359
|
+
|
|
360
|
+
case 'fields_missing':
|
|
361
|
+
return `⚠️ 以下必填字段未填写,请补全:${(missing || []).join('、')}`;
|
|
362
|
+
|
|
363
|
+
case 'path_a_rescan':
|
|
364
|
+
return '🔄 已选择重新扫榜。请运行 /fancy-scan';
|
|
365
|
+
|
|
366
|
+
case 'create_skeleton':
|
|
367
|
+
return '📝 未找到 _novel_topic.json,已创建骨架文件';
|
|
368
|
+
|
|
369
|
+
default:
|
|
370
|
+
return String(type);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
};
|