@cloud411716/fancy-webnovel 2.2.0 → 3.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/client.js +201 -15
- package/cordis.patch.yml +5 -0
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -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 +267 -44
- 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 +121 -19
- 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 +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -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 +76 -17
- 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 +56 -8
- 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 +63 -13
- 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 +34 -6
- 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 +81 -18
- 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 +202 -42
- 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 +160 -27
- 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 +217 -41
- 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 +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fancy-topic
|
|
2
|
+
* fancy-topic 模板层 — 通知 + 弹框 + 字段定义 + 提示词渲染
|
|
3
|
+
*
|
|
4
|
+
* ## 分层
|
|
5
|
+
*
|
|
6
|
+
* prompts/01-exploration-prompt.md 提示词层:静态任务契约、数据边界、
|
|
7
|
+
* 保存脚本的退出码与失败处置。
|
|
8
|
+
* **不含任何直接写目标文件的命令**。
|
|
9
|
+
* templates.js(本文件) 模板层:字段/选项定义、把动态数据注入提示词。
|
|
10
|
+
* scripts/select-topic.js 脚本层:setup 写盘 + 字段校验 + phase 推进。
|
|
11
|
+
* scripts/save-suggestion.js 脚本层:探索建议的校验 + 原子写盘。
|
|
12
|
+
*
|
|
13
|
+
* 提示词只描述契约;校验与写盘都在脚本里,模型按退出码与 stdout JSON 决策。
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'fs';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { shellSingleQuote } from '#path-guard';
|
|
20
|
+
import {
|
|
21
|
+
TOPIC_FIELDS,
|
|
22
|
+
TOPIC_FIELD_CN,
|
|
23
|
+
GENRE_OPTIONS,
|
|
24
|
+
topicFieldListBlock,
|
|
25
|
+
topicJsonTemplateBlock,
|
|
26
|
+
} from '#topic-fields';
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
/** 保存建议的唯一入口(校验 + 原子写盘都在脚本里) */
|
|
30
|
+
export const SAVE_SUGGESTION_SCRIPT = join(__dirname, 'scripts', 'save-suggestion.js');
|
|
31
|
+
/** 静态提示词契约(**提示词层**;动态字段由本文件注入) */
|
|
32
|
+
export const EXPLORATION_PROMPT_FILE = join(__dirname, 'prompts', '01-exploration-prompt.md');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 渲染探索提示词:静态契约在 prompts/01-exploration-prompt.md,
|
|
36
|
+
* 数据、块清单、路径与脚本命令由本文件注入。
|
|
37
|
+
*
|
|
38
|
+
* @param {Record<string, string|number>} vars
|
|
39
|
+
* @returns {string}
|
|
3
40
|
*/
|
|
41
|
+
export function renderExplorationPrompt(vars) {
|
|
42
|
+
let text = readFileSync(EXPLORATION_PROMPT_FILE, 'utf-8');
|
|
43
|
+
const meta = text.indexOf('\n@meta-only\n');
|
|
44
|
+
if (meta !== -1) {
|
|
45
|
+
text = text.slice(meta + '\n@meta-only\n'.length);
|
|
46
|
+
text = text.replace(/^\s*---\s*\n/, '');
|
|
47
|
+
}
|
|
48
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
49
|
+
text = text.split('${' + key + '}').join(value === undefined || value === null ? '' : String(value));
|
|
50
|
+
}
|
|
51
|
+
text = text.replace(/\$\{[a-z_]+\}/g, '');
|
|
52
|
+
// 与旧版 array.join('\n') 的输出保持一致:末尾不留多余换行
|
|
53
|
+
return text.replace(/\n$/, '');
|
|
54
|
+
}
|
|
4
55
|
|
|
5
56
|
const PLATFORM_OPTIONS = [
|
|
6
57
|
{ label: '番茄', value: 'fanqie' },
|
|
@@ -9,16 +60,39 @@ const PLATFORM_OPTIONS = [
|
|
|
9
60
|
{ label: '七猫', value: 'qimao' },
|
|
10
61
|
];
|
|
11
62
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
/**
|
|
64
|
+
* 数据里如果自带 `<<<FANCY_SCAN_DATA>>>` / `<<<END_FANCY_SCAN_DATA>>>`,
|
|
65
|
+
* 会把哨兵提前闭合、让后面的数据变成“任务指令”。这里把数据里的同名
|
|
66
|
+
* 哨兵改成带 `_ESCAPED` 的形式,外层哨兵保持固定(测试与解析都认)。
|
|
67
|
+
*/
|
|
68
|
+
function escapeScanSentinels(text) {
|
|
69
|
+
return String(text == null ? '' : text)
|
|
70
|
+
.replace(/<<<FANCY_SCAN_DATA>>>/g, '<<<FANCY_SCAN_DATA_ESCAPED>>>')
|
|
71
|
+
.replace(/<<<END_FANCY_SCAN_DATA>>>/g, '<<<END_FANCY_SCAN_DATA_ESCAPED>>>');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 块清单里只展示块标题:去掉 Markdown 的 `#` 前缀与行尾的 `-start` / `-end`
|
|
76
|
+
* 标记,同一块里重复的标题只留一次。
|
|
77
|
+
*
|
|
78
|
+
* 标记只用来切块,不是给人读的内容;数据块正文里已经不再带它们,
|
|
79
|
+
* 参考文件清单也没必要重复显示。
|
|
80
|
+
*/
|
|
81
|
+
function summarizeBlockHeaders(headers) {
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const raw of headers || []) {
|
|
85
|
+
const text = String(raw)
|
|
86
|
+
.replace(/^\s*#{1,6}\s*/, '')
|
|
87
|
+
.replace(/(?:^|[-—–\s])(?:start|end)\s*$/i, '')
|
|
88
|
+
.trim();
|
|
89
|
+
if (text && !seen.has(text)) {
|
|
90
|
+
seen.add(text);
|
|
91
|
+
out.push(text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
22
96
|
|
|
23
97
|
// 「取消(重新扫榜)」选项的 label。
|
|
24
98
|
// DSH 的 AskUserQuestionOption 只有 label/description 两个合法字段,
|
|
@@ -48,26 +122,17 @@ function toAskOptions(options) {
|
|
|
48
122
|
}
|
|
49
123
|
|
|
50
124
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
125
|
+
* 选题字段定义(路径 B 弹框用)。
|
|
126
|
+
*
|
|
127
|
+
* 字段清单不在这里维护 —— 全部来自 `lib/topic-fields.js`:
|
|
128
|
+
* `_novel_topic_suggestion.json`、`_novel_topic.json`、探索提示词、
|
|
129
|
+
* setup 弹框、写盘脚本、story-builder 快照共用同一份。
|
|
130
|
+
*
|
|
131
|
+
* 2026-10-30 重新设计:
|
|
132
|
+
* - `core_summary` 与「定位」合并为 `positioning`;
|
|
133
|
+
* - `direction_design` 大字符串拆成 11 个平级字段,每项一个弹框;
|
|
134
|
+
* - 字段数由 `TOPIC_FIELDS.length` 决定,提示词与通知里不再写死“10 个”。
|
|
53
135
|
*/
|
|
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
136
|
|
|
72
137
|
export const topic = {
|
|
73
138
|
PLATFORM_OPTIONS,
|
|
@@ -102,7 +167,7 @@ export const topic = {
|
|
|
102
167
|
},
|
|
103
168
|
|
|
104
169
|
/**
|
|
105
|
-
* 路径 B
|
|
170
|
+
* 路径 B:选题字段弹框
|
|
106
171
|
*
|
|
107
172
|
* 问题对象只允许 DSH 的合法字段:id / question / detail / header /
|
|
108
173
|
* options / multiSelect / intent。
|
|
@@ -172,6 +237,8 @@ export const topic = {
|
|
|
172
237
|
// 两个空格写在字符串字面量里(' \n'),源码中没有行尾空白,
|
|
173
238
|
// 不会被格式化工具或编辑器悄悄吃掉。
|
|
174
239
|
const hints = [];
|
|
240
|
+
// 字段已经拆成 11 个平级项,单个字段的值是短文本/单段说明,
|
|
241
|
+
// 直接和“当前值 / 推荐值”放同一行即可;两项之间仍用硬换行分隔。
|
|
175
242
|
if (curStr) hints.push(`📌 当前值:${curStr}`);
|
|
176
243
|
if (sugStr) hints.push(`💡 推荐值:${sugStr}`);
|
|
177
244
|
|
|
@@ -198,22 +265,47 @@ export const topic = {
|
|
|
198
265
|
* @param {object} args
|
|
199
266
|
* @param {string} args.platform - 中文平台名(番茄/起点/晋江/七猫)
|
|
200
267
|
* @param {string} args.decisionFile - topic_decision_<date>.md 文件名
|
|
268
|
+
* @param {string} [args.decisionPath] - 该文件的**绝对路径**。
|
|
269
|
+
* 只给文件名的话,模型拼不出它在哪儿(实际在 `<工作区目录>/扫榜结果/` 下),
|
|
270
|
+
* 写进 `source_decision_file` 的就是个解析不了的裸名。
|
|
201
271
|
* @param {string} args.blocks - 拼接好的内容块(多个 # start/end 块用 --- 分隔)
|
|
202
272
|
* @param {number} args.blockCount - 本次平台用到的块数
|
|
203
273
|
* @param {Array} args.sectionSummary - 按平台分组的全文件块清单
|
|
204
274
|
* [{ platform: '起点', blocks: [{ headers: [...] }, ...] }, ...]
|
|
205
275
|
* @param {string[]} args.notes - 解析备注(写进提示词,让用户看见解析情况)
|
|
276
|
+
* @param {string} args.projectRoot - 工作区目录绝对路径。
|
|
277
|
+
* 用来把建议文件写成**绝对路径** —— 只给相对路径的话,
|
|
278
|
+
* LLM 的工作目录未必是工作区目录,文件会写到别的地方,
|
|
279
|
+
* 插件读 `<工作区目录>/_novel_topic_suggestion.json` 时读到的是空。
|
|
280
|
+
* @param {string} [args.saveScriptPath] - 工作区内部署后的 save-suggestion.js
|
|
281
|
+
* 绝对路径。不传时回退插件内路径,仅用于测试/直接调用;
|
|
282
|
+
* 生产路径必须由 index.js 部署后传入。
|
|
206
283
|
*/
|
|
207
|
-
explorePrompt({ platform, decisionFile, blocks, blockCount, sectionSummary = [], notes = [] }) {
|
|
284
|
+
explorePrompt({ platform, decisionFile, decisionPath, blocks, blockCount, sectionSummary = [], notes = [], projectRoot, saveScriptPath }) {
|
|
285
|
+
// 建议文件路径:插件读的是 <工作区目录>/_novel_topic_suggestion.json,
|
|
286
|
+
// 提示词里必须给**同一个绝对路径**,否则 LLM 可能写到别处。
|
|
287
|
+
const suggestionPath = projectRoot
|
|
288
|
+
? join(projectRoot, '_novel_topic_suggestion.json')
|
|
289
|
+
: '_novel_topic_suggestion.json';
|
|
290
|
+
// 输入 JSON 放 <工作区目录>/.tmp;目标文件由脚本原子写入工作区目录。
|
|
291
|
+
// 文件名带上项目/来源文件的哈希,避免两个会话共用同一个临时文件。
|
|
292
|
+
const inputKey = createHash('sha256')
|
|
293
|
+
.update(`${projectRoot || ''}|${decisionPath || decisionFile || ''}`)
|
|
294
|
+
.digest('hex')
|
|
295
|
+
.slice(0, 10);
|
|
296
|
+
const tempDir = projectRoot ? join(projectRoot, '.tmp') : '.tmp';
|
|
297
|
+
const suggestionInputPath = join(tempDir, `fancy_topic_suggestion_input_${inputKey}.json`);
|
|
298
|
+
const saveScript = saveScriptPath || SAVE_SUGGESTION_SCRIPT;
|
|
208
299
|
const sourceLines = [];
|
|
209
300
|
if (sectionSummary.length) {
|
|
210
301
|
// 总数是**块数**(不是平台数):同一平台可能有多个 -start 块
|
|
211
302
|
const totalBlocks = sectionSummary.reduce((n, s) => n + s.blocks.length, 0);
|
|
212
303
|
sourceLines.push(`该文件按平台切分出 ${totalBlocks} 块:`);
|
|
213
304
|
for (const s of sectionSummary) {
|
|
214
|
-
sourceLines.push(`
|
|
305
|
+
sourceLines.push(` - ${s.platform}(${s.blocks.length} 块):`);
|
|
215
306
|
s.blocks.forEach((b, i) => {
|
|
216
|
-
|
|
307
|
+
const titles = summarizeBlockHeaders(b.headers);
|
|
308
|
+
sourceLines.push(` ${i + 1}. ${escapeScanSentinels(titles.join(' / ') || '(无标题)')}`);
|
|
217
309
|
});
|
|
218
310
|
}
|
|
219
311
|
sourceLines.push(`本次只使用「${platform}」的 ${blockCount} 块。`);
|
|
@@ -221,150 +313,161 @@ export const topic = {
|
|
|
221
313
|
if (notes.length) {
|
|
222
314
|
sourceLines.push('');
|
|
223
315
|
sourceLines.push('解析说明:');
|
|
224
|
-
for (const n of notes) sourceLines.push(`
|
|
316
|
+
for (const n of notes) sourceLines.push(` - ${escapeScanSentinels(n)}`);
|
|
225
317
|
}
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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');
|
|
318
|
+
return renderExplorationPrompt({
|
|
319
|
+
platform,
|
|
320
|
+
decision_path: decisionPath || decisionFile,
|
|
321
|
+
source_summary_block: sourceLines.length ? '\n' + sourceLines.join('\n') + '\n' : '',
|
|
322
|
+
data_block: escapeScanSentinels(blocks),
|
|
323
|
+
field_count: TOPIC_FIELDS.length,
|
|
324
|
+
field_list_block: topicFieldListBlock(),
|
|
325
|
+
json_template_block: topicJsonTemplateBlock(),
|
|
326
|
+
suggestion_path: suggestionPath,
|
|
327
|
+
suggestion_input_path: suggestionInputPath,
|
|
328
|
+
suggestion_input_quoted: shellSingleQuote(suggestionInputPath),
|
|
329
|
+
save_script_quoted: shellSingleQuote(saveScript),
|
|
330
|
+
project_root_quoted: shellSingleQuote(projectRoot || ''),
|
|
331
|
+
platform_quoted: shellSingleQuote(platform),
|
|
332
|
+
source_decision_quoted: shellSingleQuote(decisionPath || decisionFile),
|
|
333
|
+
});
|
|
288
334
|
},
|
|
289
335
|
|
|
290
336
|
/**
|
|
291
337
|
* 通知模板(左侧消息栏)
|
|
292
338
|
*/
|
|
293
|
-
notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes }) {
|
|
339
|
+
notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes, file, reasons, arg }) {
|
|
294
340
|
switch (type) {
|
|
341
|
+
case 'help':
|
|
342
|
+
return `📝 选题 用法
|
|
343
|
+
|
|
344
|
+
/fancy-topic 探索选题:读取扫榜结果,生成字段建议和 6 维评分
|
|
345
|
+
/fancy-topic setup 敲定选题:逐项确认字段并写入 _novel_topic.json
|
|
346
|
+
/fancy-topic help 显示本帮助
|
|
347
|
+
|
|
348
|
+
作用:生成/确认选题与创意约束,供 fancy-build 使用。`;
|
|
349
|
+
|
|
350
|
+
case 'invalid_argument':
|
|
351
|
+
return `❌ 参数无效:${arg || '(空)'}\n`
|
|
352
|
+
+ '/fancy-topic 支持以下用法:\n'
|
|
353
|
+
+ `- /fancy-topic:不带参数,进入探索模式 —— 读取扫榜结果,生成 ${TOPIC_FIELDS.length} 个字段建议和 6 维评分;\n`
|
|
354
|
+
+ '- /fancy-topic setup:带 setup 参数,进入敲定模式 —— 逐项确认选题字段并写入 _novel_topic.json;\n'
|
|
355
|
+
+ '- /fancy-topic help:查看本命令帮助。';
|
|
356
|
+
|
|
295
357
|
case 'cancelled':
|
|
296
|
-
return '🚫
|
|
358
|
+
return '🚫 操作已取消。';
|
|
297
359
|
|
|
298
360
|
case 'no_provider':
|
|
299
|
-
return '⚠️
|
|
361
|
+
return '⚠️ 当前运行环境不支持交互式提问。请在 dsh-tui 中运行该命令,或确认 Web UI 已正确加载交互组件。';
|
|
300
362
|
|
|
301
363
|
case 'no_project_root':
|
|
302
|
-
return '❌
|
|
364
|
+
return '❌ 无法确定工作区目录:' + (err || '会话未提供当前工作目录') + '。请在工作区目录下运行该命令。';
|
|
303
365
|
|
|
304
366
|
case 'ask_failed':
|
|
305
|
-
return '❌
|
|
367
|
+
return '❌ 交互提问失败:' + (err || '未知错误');
|
|
306
368
|
|
|
307
369
|
case 'not_initialized':
|
|
308
|
-
return '⚠️
|
|
370
|
+
return '⚠️ 当前目录不属于已初始化的 fancy-webnovel 工作区。请先运行 /fancy-bootstrap 初始化工作区。';
|
|
309
371
|
|
|
310
372
|
case 'phase_wrong':
|
|
311
|
-
return `⚠️
|
|
373
|
+
return `⚠️ 当前工作区阶段为「${phase || '未知'}」,不能运行 /fancy-topic。请先运行 /fancy-scan 完成扫榜。`;
|
|
312
374
|
|
|
313
375
|
case 'phase_unreadable':
|
|
314
376
|
return (
|
|
315
|
-
`❌
|
|
316
|
-
'
|
|
377
|
+
`❌ 无法读取状态文件 .fancy-deployed(${err || '内容异常'}),因此无法确定当前工作区阶段。\n` +
|
|
378
|
+
'请检查该文件是否被手动修改或损坏;如无法修复,请重新运行 /fancy-bootstrap 初始化工作区。'
|
|
317
379
|
);
|
|
318
380
|
|
|
319
381
|
case 'no_decision_file':
|
|
320
|
-
return '⚠️ 未找到 topic_decision_<date>.md 文件。请先运行 /fancy-scan
|
|
382
|
+
return '⚠️ 未找到 topic_decision_<date>.md 文件。请先运行 /fancy-scan 采集榜单,并等待 LLM 完成分析。';
|
|
383
|
+
|
|
384
|
+
case 'suggestion_corrupt':
|
|
385
|
+
return (
|
|
386
|
+
`⚠️ 无法读取 ${file || '_novel_topic_suggestion.json'}(不是合法 JSON),本次已忽略其中的推荐值。\n` +
|
|
387
|
+
'该文件通常由上一次 LLM 写入。重新运行 /fancy-topic 可生成新的建议文件。'
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
case 'current_values_corrupt':
|
|
391
|
+
return (
|
|
392
|
+
`⚠️ 无法读取 ${file || '_novel_topic.json'}(不是合法 JSON),本次按“无当前值”处理。\n` +
|
|
393
|
+
'该文件保存已确认的选题设定;损坏会导致后续阶段无法读取。请检查文件内容,或删除后重新运行 /fancy-topic setup。'
|
|
394
|
+
);
|
|
321
395
|
|
|
322
396
|
case 'no_decision_block':
|
|
323
397
|
return (
|
|
324
|
-
`⚠️ 在 ${decisionFile || '所选文件'}
|
|
325
|
-
'
|
|
398
|
+
`⚠️ 在 ${decisionFile || '所选文件'} 中未找到任何 “# 平台-榜单-日期-start/end” 内容块。\n` +
|
|
399
|
+
'通常是因为 LLM 未按约定格式写入。建议重新运行 /fancy-scan 触发分析。'
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
case 'empty_decision_file':
|
|
403
|
+
return (
|
|
404
|
+
`❌ ${decisionFile || '所选文件'} 为空,未包含任何分析内容。\n` +
|
|
405
|
+
'空文件无法用于选题。请重新运行 /fancy-scan,等待 LLM 写入报告正文。'
|
|
326
406
|
);
|
|
327
407
|
|
|
328
408
|
case 'platform_not_in_decision':
|
|
329
409
|
return (
|
|
330
|
-
`⚠️ 在 ${decisionFile || '所选文件'}
|
|
331
|
-
|
|
332
|
-
'
|
|
410
|
+
`⚠️ 在 ${decisionFile || '所选文件'} 中未找到「${platform || ''}」的内容块。\n` +
|
|
411
|
+
`文件中已有的内容块:\n${(available && available.length) ? available.map((b, i) => `${i + 1}. ${b}`).join('\n') : '(无)'}\n` +
|
|
412
|
+
'请确认是否已采集该平台。'
|
|
333
413
|
);
|
|
334
414
|
|
|
335
415
|
case 'invalid_platform':
|
|
336
|
-
return `❌
|
|
416
|
+
return `❌ 平台无效:${platform}。`;
|
|
417
|
+
|
|
418
|
+
case 'block_body_invalid':
|
|
419
|
+
return (
|
|
420
|
+
`❌ ${decisionFile || '所选文件'} 中的内容块正文缺失(标记行存在,但内容为空或仅为占位符)。\n` +
|
|
421
|
+
(reasons && reasons.length ? reasons.map(r => '- ' + r).join('\n') + '\n' : '') +
|
|
422
|
+
'基于此类内容生成的选题结论缺少依据。\n' +
|
|
423
|
+
'请重新运行 /fancy-scan,让 LLM 重新写入报告正文。'
|
|
424
|
+
);
|
|
337
425
|
|
|
338
426
|
case 'path_a_done': {
|
|
339
427
|
const noteText = (notes && notes.length)
|
|
340
428
|
? '\n' + notes.map(n => 'ℹ️ ' + n).join('\n')
|
|
341
429
|
: '';
|
|
342
430
|
return (
|
|
343
|
-
`📊
|
|
431
|
+
`📊 已请求 LLM 分析 ${blockCount || '?'} 个数据块\n` +
|
|
344
432
|
`📚 平台:${platform || ''}\n` +
|
|
345
433
|
`📁 参考:${decisionFile || ''}` +
|
|
346
434
|
noteText + '\n' +
|
|
347
|
-
`💡 LLM
|
|
435
|
+
`💡 LLM 将在对话中给出 6 维评分与 ${TOPIC_FIELDS.length} 个字段建议。确认选题后,请运行 /fancy-topic setup 写入最终决定。`
|
|
348
436
|
);
|
|
349
437
|
}
|
|
350
438
|
|
|
351
439
|
case 'path_b_done':
|
|
352
|
-
return `✅
|
|
353
|
-
|
|
354
|
-
运行 /fancy-story-builder 构建故事 (在此之前可选择运行 /fancy-analyze 拆解同题材爆款小说)` +
|
|
440
|
+
return `✅ 选题已确认。
|
|
441
|
+
下一步:运行 /fancy-build scale 构建故事(在此之前可选择运行 /fancy-analyze 拆解同题材爆款小说)。` +
|
|
355
442
|
(extra ? '\n\n' + extra : '');
|
|
356
443
|
|
|
357
444
|
case 'write_failed':
|
|
358
445
|
return '❌ 写入 _novel_topic.json 失败:' + (err || '未知错误');
|
|
359
446
|
|
|
360
|
-
case '
|
|
361
|
-
return
|
|
447
|
+
case 'tool_deploy_failed':
|
|
448
|
+
return '❌ 部署 fancy-topic 只读工具失败:' + (err || '未知错误') +
|
|
449
|
+
'\n未向 LLM 发送提示词;请修复工作区目录权限后重试。';
|
|
450
|
+
|
|
451
|
+
case 'llm_delivery_failed':
|
|
452
|
+
return '❌ 向 LLM 发送分析提示词失败:' + (err || '提示词发送失败') +
|
|
453
|
+
'\n数据文件未被修改,请重新运行 /fancy-topic。';
|
|
454
|
+
|
|
455
|
+
case 'read_failed':
|
|
456
|
+
return '❌ 读取扫榜分析失败:' + (err || '未知错误');
|
|
457
|
+
|
|
458
|
+
case 'fields_missing': {
|
|
459
|
+
const labels = (missing || []).map((id) => {
|
|
460
|
+
const label = TOPIC_FIELD_CN[id];
|
|
461
|
+
return label && label !== id ? `${label}(${id})` : id;
|
|
462
|
+
});
|
|
463
|
+
return `⚠️ 以下必填字段尚未填写,请补全:${labels.join('、')}`;
|
|
464
|
+
}
|
|
362
465
|
|
|
363
466
|
case 'path_a_rescan':
|
|
364
|
-
return '🔄 已选择重新扫榜。请运行 /fancy-scan';
|
|
467
|
+
return '🔄 已选择重新扫榜。请运行 /fancy-scan。';
|
|
365
468
|
|
|
366
469
|
case 'create_skeleton':
|
|
367
|
-
return '📝 未找到 _novel_topic.json
|
|
470
|
+
return '📝 未找到 _novel_topic.json,已创建骨架文件。';
|
|
368
471
|
|
|
369
472
|
default:
|
|
370
473
|
return String(type);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-topic 工具部署
|
|
3
|
+
*
|
|
4
|
+
* LLM 只应该执行工作区内的只读脚本,不能去 node_modules 里跑脚本:
|
|
5
|
+
* - 插件目录随安装位置变化,提示词里的路径容易抄错;
|
|
6
|
+
* - 包管理器目录不该承担运行职责;
|
|
7
|
+
* - 工作区内的脚本可以统一去掉写权限,避免模型改脚本绕过校验。
|
|
8
|
+
*
|
|
9
|
+
* 这里把 `save-suggestion.js` 及其 import 闭包复制到
|
|
10
|
+
* `<工作区目录>/.fancy-tools/fancy-topic/`,并写成 0444 只读。
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
lstatSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
statSync,
|
|
20
|
+
} from 'fs';
|
|
21
|
+
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
23
|
+
import { atomicWriteFile } from '#atomic-write';
|
|
24
|
+
import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
|
|
28
|
+
|
|
29
|
+
/** 工作区内的工具目录(相对工作区目录) */
|
|
30
|
+
export const TOPIC_TOOL_DIR = join('.fancy-tools', 'fancy-topic');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 部署清单。
|
|
34
|
+
*
|
|
35
|
+
* `src` 相对插件包根,`dest` 相对 `.fancy-tools/fancy-topic/`。
|
|
36
|
+
* `save-suggestion.js` 的 import 闭包必须完整,否则启动就是
|
|
37
|
+
* ERR_MODULE_NOT_FOUND。
|
|
38
|
+
*/
|
|
39
|
+
export const TOPIC_FILES = [
|
|
40
|
+
{ src: 'lib/error-codes.js', dest: join('lib', 'error-codes.js') },
|
|
41
|
+
{ src: 'lib/atomic-write.js', dest: join('lib', 'atomic-write.js') },
|
|
42
|
+
{ src: 'lib/deployed-state.js', dest: join('lib', 'deployed-state.js') },
|
|
43
|
+
{ src: 'lib/path-guard.js', dest: join('lib', 'path-guard.js') },
|
|
44
|
+
{ src: 'lib/direction-labels.js', dest: join('lib', 'direction-labels.js') },
|
|
45
|
+
{ src: 'lib/topic-fields.js', dest: join('lib', 'topic-fields.js') },
|
|
46
|
+
{ src: 'lib/topic-schema.js', dest: join('lib', 'topic-schema.js') },
|
|
47
|
+
{ src: 'plugins/fancy-topic/scripts/save-suggestion.js', dest: join('scripts', 'save-suggestion.js') },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** 部署目录的 package.json:ESM + import aliases */
|
|
51
|
+
export const TOPIC_PACKAGE_JSON = {
|
|
52
|
+
name: 'fancy-tools-fancy-topic',
|
|
53
|
+
private: true,
|
|
54
|
+
type: 'module',
|
|
55
|
+
imports: {
|
|
56
|
+
'#error-codes': './lib/error-codes.js',
|
|
57
|
+
'#atomic-write': './lib/atomic-write.js',
|
|
58
|
+
'#deployed-state': './lib/deployed-state.js',
|
|
59
|
+
'#path-guard': './lib/path-guard.js',
|
|
60
|
+
'#direction-labels': './lib/direction-labels.js',
|
|
61
|
+
'#topic-fields': './lib/topic-fields.js',
|
|
62
|
+
'#topic-schema': './lib/topic-schema.js',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** 确保目录存在且不是符号链接(逐级 lstat,防 symlink 穿透) */
|
|
67
|
+
function ensureRealDir(dir, problems) {
|
|
68
|
+
let st = null;
|
|
69
|
+
try { st = lstatSync(dir); } catch { /* 不存在 */ }
|
|
70
|
+
if (st) {
|
|
71
|
+
if (st.isSymbolicLink()) {
|
|
72
|
+
problems.push(`${dir} 是符号链接,拒绝部署工具(请换成真实目录)`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (!st.isDirectory()) {
|
|
76
|
+
problems.push(`${dir} 已存在但不是目录,拒绝部署工具`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dir);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
problems.push(`创建目录失败 ${dir}:${e.message}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 内容相同就跳过;目标存在但是符号链接/目录则拒绝。 */
|
|
91
|
+
function writeIfChanged(dest, content, problems, written, unchanged) {
|
|
92
|
+
let st = null;
|
|
93
|
+
try { st = lstatSync(dest); } catch { /* 不存在 */ }
|
|
94
|
+
if (st) {
|
|
95
|
+
if (st.isSymbolicLink()) {
|
|
96
|
+
problems.push(`${dest} 是符号链接,拒绝覆盖`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!st.isFile()) {
|
|
100
|
+
problems.push(`${dest} 已存在但不是普通文件,拒绝覆盖`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if (readFileSync(dest, 'utf-8') === content) {
|
|
105
|
+
// 老项目里的工具文件可能是 0644;内容没变也要去掉写权限。
|
|
106
|
+
try { chmodSync(dest, 0o444); } catch (e) {
|
|
107
|
+
problems.push(`设置只读权限失败 ${dest}:${e.message}`);
|
|
108
|
+
}
|
|
109
|
+
unchanged.push(dest);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
problems.push(`读取已有工具文件失败 ${dest}:${e.message}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
atomicWriteFile(dest, content, { mode: 0o444 });
|
|
118
|
+
written.push(dest);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 把 fancy-topic 工具部署到 `<projectRoot>/.fancy-tools/fancy-topic/`。
|
|
123
|
+
*
|
|
124
|
+
* @param {string} projectRoot 工作区目录(绝对路径)
|
|
125
|
+
* @returns {{ok:boolean,problems:string[],dir:string,saveScript:string,written:string[],unchanged:string[]}}
|
|
126
|
+
*/
|
|
127
|
+
export function deployTopicTools(projectRoot) {
|
|
128
|
+
const problems = [];
|
|
129
|
+
const written = [];
|
|
130
|
+
const unchanged = [];
|
|
131
|
+
let root = '';
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
135
|
+
return { ok: false, problems: ['缺少工作区目录'], dir: '', saveScript: '', written, unchanged };
|
|
136
|
+
}
|
|
137
|
+
if (!isAbsolute(projectRoot)) {
|
|
138
|
+
return { ok: false, problems: [`工作区目录必须是绝对路径:${projectRoot}`], dir: '', saveScript: '', written, unchanged };
|
|
139
|
+
}
|
|
140
|
+
root = resolve(projectRoot);
|
|
141
|
+
if (isSensitiveProjectPath(root) || !isValidProjectPath(root)) {
|
|
142
|
+
return { ok: false, problems: [`工作区目录路径不合法或属于受保护目录:${root}`], dir: '', saveScript: '', written, unchanged };
|
|
143
|
+
}
|
|
144
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
145
|
+
return { ok: false, problems: [`工作区目录不存在或不是目录:${root}`], dir: '', saveScript: '', written, unchanged };
|
|
146
|
+
}
|
|
147
|
+
root = realpathSync(root);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { ok: false, problems: [`检查工作区目录失败:${e.message}`], dir: '', saveScript: '', written, unchanged };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const toolsDir = join(root, '.fancy-tools');
|
|
153
|
+
const topicDir = join(toolsDir, 'fancy-topic');
|
|
154
|
+
const libDir = join(topicDir, 'lib');
|
|
155
|
+
const scriptsDir = join(topicDir, 'scripts');
|
|
156
|
+
|
|
157
|
+
for (const dir of [toolsDir, topicDir, libDir, scriptsDir]) {
|
|
158
|
+
if (!ensureRealDir(dir, problems)) {
|
|
159
|
+
return { ok: false, problems, dir: topicDir, saveScript: join(scriptsDir, 'save-suggestion.js'), written, unchanged };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// package.json 先写:避免“脚本在、包配置不在”的半成品。
|
|
164
|
+
const pkgPath = join(topicDir, 'package.json');
|
|
165
|
+
const pkgContent = JSON.stringify(TOPIC_PACKAGE_JSON, null, 2) + '\n';
|
|
166
|
+
try {
|
|
167
|
+
writeIfChanged(pkgPath, pkgContent, problems, written, unchanged);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
problems.push(`写 package.json 失败:${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const item of TOPIC_FILES) {
|
|
173
|
+
const srcPath = join(PACKAGE_ROOT, item.src);
|
|
174
|
+
const destPath = join(topicDir, item.dest);
|
|
175
|
+
try {
|
|
176
|
+
if (!existsSync(srcPath)) {
|
|
177
|
+
problems.push(`插件内缺少工具源文件:${item.src}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const content = readFileSync(srcPath, 'utf-8');
|
|
181
|
+
writeIfChanged(destPath, content, problems, written, unchanged);
|
|
182
|
+
const back = readFileSync(destPath, 'utf-8');
|
|
183
|
+
if (back !== content) problems.push(`部署后回读不一致:${item.dest}`);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
problems.push(`部署 ${item.dest} 失败:${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const saveScript = join(scriptsDir, 'save-suggestion.js');
|
|
190
|
+
try {
|
|
191
|
+
const st = lstatSync(saveScript);
|
|
192
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
193
|
+
problems.push(`部署后的 save-suggestion.js 不是普通文件:${saveScript}`);
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
problems.push(`部署后未找到 save-suggestion.js:${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { ok: problems.length === 0, problems, dir: topicDir, saveScript, written, unchanged };
|
|
200
|
+
}
|