@cloud411716/fancy-webnovel 1.0.46 → 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 +369 -0
- package/cordis.patch.yml +4 -13
- 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 +26 -38
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/chinese-num.js +111 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +141 -0
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +594 -0
- package/lib/llm-wiki.js +109 -0
- 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 +305 -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 +62 -5
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +2024 -0
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +627 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +495 -0
- package/plugins/fancy-analyze/templates.js +1179 -0
- package/plugins/fancy-bootstrap/index.js +313 -0
- package/plugins/fancy-bootstrap/scripts/init.js +177 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +71 -0
- 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-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +604 -0
- package/plugins/fancy-import/index.js +738 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
- package/plugins/fancy-import/templates.js +58 -0
- 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 +708 -0
- 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 +667 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
- 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 +694 -0
- 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/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +168 -0
- package/plugins/fancy-story-builder/index.js +6861 -0
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +83 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +594 -0
- package/plugins/fancy-story-builder/references/index.md +581 -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 +181 -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 +243 -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 +185 -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 +187 -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 +209 -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 +160 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +232 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +416 -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 +1312 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -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/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- 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/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/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 +427 -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 +168 -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 +184 -0
- 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 +131 -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 +85 -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 +103 -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 +68 -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 +142 -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 +292 -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 +266 -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 +336 -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 +308 -0
- package/plugins/fancy-story-builder/templates.js +1446 -0
- package/plugins/fancy-story-builder/wiki-references.js +540 -0
- package/plugins/fancy-story-builder/writing-driver.js +1388 -0
- package/plugins/fancy-topic/index.js +1057 -0
- 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 +317 -0
- package/plugins/fancy-topic/templates.js +476 -0
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/commands/bootstrap/index.js +0 -164
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -283
- 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,476 @@
|
|
|
1
|
+
/**
|
|
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}
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
const PLATFORM_OPTIONS = [
|
|
57
|
+
{ label: '番茄', value: 'fanqie' },
|
|
58
|
+
{ label: '起点', value: 'qidian' },
|
|
59
|
+
{ label: '晋江', value: 'jinjiang' },
|
|
60
|
+
{ label: '七猫', value: 'qimao' },
|
|
61
|
+
];
|
|
62
|
+
|
|
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
|
+
}
|
|
96
|
+
|
|
97
|
+
// 「取消(重新扫榜)」选项的 label。
|
|
98
|
+
// DSH 的 AskUserQuestionOption 只有 label/description 两个合法字段,
|
|
99
|
+
// selected 回传的是 label —— 此前这里用 value:'__rescan__' 判断,
|
|
100
|
+
// 而 value 根本不会被 DSH 回传,导致"重新扫榜"分支永远不可达。
|
|
101
|
+
const RESCAN_LABEL = '取消(重新扫榜)';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 剥离 value,转换成 DSH 合法的 AskUserQuestionOption。
|
|
105
|
+
*
|
|
106
|
+
* DSH 的 AskUserQuestionOption 只有 label / description;
|
|
107
|
+
* value 属于契约外字段,传了会被运行时静默忽略。
|
|
108
|
+
* 上面的 value 仅供插件内部做 label↔value 映射(platformCn/platformToValue)。
|
|
109
|
+
*
|
|
110
|
+
* description 是可选字段:**没有就整个键省掉**,不能写成 undefined ——
|
|
111
|
+
* DSH 的 api gateway 在远程模式下要求提问请求是"lossless JSON",
|
|
112
|
+
* 属性值不允许是 undefined(JSON.stringify 会悄悄丢掉它,但网关按属性
|
|
113
|
+
* 存在性判定),带上就会让整个请求被拒:
|
|
114
|
+
* "api gateway: Remote event request is not lossless JSON data"
|
|
115
|
+
*/
|
|
116
|
+
function toAskOptions(options) {
|
|
117
|
+
return options.map(o => {
|
|
118
|
+
const opt = { label: o.label };
|
|
119
|
+
if (o.description !== undefined) opt.description = o.description;
|
|
120
|
+
return opt;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
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 个”。
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
export const topic = {
|
|
138
|
+
PLATFORM_OPTIONS,
|
|
139
|
+
GENRE_OPTIONS,
|
|
140
|
+
TOPIC_FIELDS,
|
|
141
|
+
RESCAN_LABEL,
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 路径 A:选平台弹框
|
|
145
|
+
*/
|
|
146
|
+
askPlatform() {
|
|
147
|
+
return {
|
|
148
|
+
id: 'topic-platform',
|
|
149
|
+
question: '📚 请选择小说计划发布到哪个平台:',
|
|
150
|
+
options: toAskOptions(PLATFORM_OPTIONS),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 路径 A:选 topic_decision 文件
|
|
156
|
+
* @param {string[]} decisionFiles - 所有可用的 topic_decision_<date>.md 文件名(不含路径)
|
|
157
|
+
*/
|
|
158
|
+
askDecisionFile(decisionFiles) {
|
|
159
|
+
return {
|
|
160
|
+
id: 'topic-decision',
|
|
161
|
+
question: '📊 请选择参考的扫榜分析文件:',
|
|
162
|
+
options: [
|
|
163
|
+
...decisionFiles.map(f => ({ label: f })),
|
|
164
|
+
{ label: RESCAN_LABEL },
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 路径 B:选题字段弹框
|
|
171
|
+
*
|
|
172
|
+
* 问题对象只允许 DSH 的合法字段:id / question / detail / header /
|
|
173
|
+
* options / multiSelect / intent。
|
|
174
|
+
*
|
|
175
|
+
* placeholder / default / question 上的 description 都**不是** DSH 的字段,
|
|
176
|
+
* 传了会被运行时静默忽略(与 options.value 同类问题)—— 所以:
|
|
177
|
+
* - placeholder 里的示例、description 里的字段说明,都写进**标题行**
|
|
178
|
+
* (question 的第一行),归到标题的括号里;
|
|
179
|
+
* - "📌 当前值"和"💡 推荐值"写进 **detail**(不是 question):
|
|
180
|
+
* question 在 web 端被渲染成 <h2>,那条 CSS 没有 white-space: pre-wrap,
|
|
181
|
+
* 换行会被折成空格,标题和两个值会挤在同一行;detail 才是两端
|
|
182
|
+
* 都独立成块、都能换行的字段(web 走 markdown 硬换行,TUI 逐行渲染);
|
|
183
|
+
* - default 字段直接删掉:DSH 没有"预填输入框"的能力,
|
|
184
|
+
* 上次的值通过 detail 里的 "📌 当前值" 展示,能力不丢。
|
|
185
|
+
*
|
|
186
|
+
* @param {object} [opts]
|
|
187
|
+
* @param {object} [opts.currentValues] - _novel_topic.json 中的值(已按平台过滤)
|
|
188
|
+
* @param {object} [opts.suggestionValues] - _novel_topic_suggestion.json 中的值(已按平台过滤)
|
|
189
|
+
*/
|
|
190
|
+
askFields(opts = {}) {
|
|
191
|
+
const { currentValues = {}, suggestionValues = null } = opts;
|
|
192
|
+
return TOPIC_FIELDS.map(f => {
|
|
193
|
+
const curVal = currentValues[f.id];
|
|
194
|
+
const sugVal = suggestionValues ? suggestionValues[f.id] : undefined;
|
|
195
|
+
|
|
196
|
+
// null / undefined / '' / 空数组 / 数字 0 → 视为无值
|
|
197
|
+
// (target_words / target_chapters = 0 是骨架模板的默认值,无意义)
|
|
198
|
+
const hasValue = (v) =>
|
|
199
|
+
v !== undefined && v !== null && v !== '' &&
|
|
200
|
+
!(Array.isArray(v) && v.length === 0) &&
|
|
201
|
+
!(typeof v === 'number' && v === 0);
|
|
202
|
+
|
|
203
|
+
const fmt = (v) => (Array.isArray(v) ? v.join('、') : String(v));
|
|
204
|
+
const curStr = hasValue(curVal) ? fmt(curVal) : '';
|
|
205
|
+
const sugStr = hasValue(sugVal) ? fmt(sugVal) : '';
|
|
206
|
+
|
|
207
|
+
// 标题 = 字段名 + (必填,示例 / 说明)
|
|
208
|
+
// 示例(placeholder)与说明(description)都归到括号里。
|
|
209
|
+
const titleBits = [];
|
|
210
|
+
if (f.required) titleBits.push('必填');
|
|
211
|
+
if (f.placeholder) titleBits.push(f.placeholder);
|
|
212
|
+
if (f.description) titleBits.push(f.description);
|
|
213
|
+
const title = titleBits.length ? `(${titleBits.join(',')})` : '';
|
|
214
|
+
const titleLine = `📝 ${f.label}${title}:`;
|
|
215
|
+
|
|
216
|
+
// 当前值 / 推荐值放 **detail**,不放 question。
|
|
217
|
+
//
|
|
218
|
+
// 为什么不能放 question:两端渲染方式不一样。
|
|
219
|
+
// web 端把 question 渲染成 <h2 class="title">,那条 CSS 没有
|
|
220
|
+
// white-space: pre-wrap —— HTML 默认会把 \n 折成空格,于是
|
|
221
|
+
// "标题 + 当前值 + 推荐值"挤在同一行(用户实测过)。
|
|
222
|
+
// TUI 端是 <Text>{question.question}</Text>,\n 会换行。
|
|
223
|
+
// 同一份内容两端表现不一致,必须改用两端都支持的 detail。
|
|
224
|
+
//
|
|
225
|
+
// detail 是独立的块,两端都是:
|
|
226
|
+
// web 端 → 标题下方的 <div class="detail">(走 MarkdownText)
|
|
227
|
+
// TUI 端 → detail.split('\n') 逐行渲染(dim 文本)
|
|
228
|
+
//
|
|
229
|
+
// 但 web 端的 detail 走 markdown:**单个 \n 是"软换行"**,mdast 会把它
|
|
230
|
+
// 留在 text 节点里,HTML 照样折成空格 —— 还是一行。
|
|
231
|
+
// 必须用**硬换行**(行尾两个空格):mdast 产出 break 节点,
|
|
232
|
+
// DSH 的 MarkdownText 把 break 渲染成 <br>,这才是真换行。
|
|
233
|
+
// (依据:dsh-web-frontend 的 markdown 渲染器里有
|
|
234
|
+
// case "break": <><br/>{"\n"}</>
|
|
235
|
+
// 而 .markdown 容器没有 white-space: pre-wrap,软换行必被折叠。)
|
|
236
|
+
//
|
|
237
|
+
// 两个空格写在字符串字面量里(' \n'),源码中没有行尾空白,
|
|
238
|
+
// 不会被格式化工具或编辑器悄悄吃掉。
|
|
239
|
+
const hints = [];
|
|
240
|
+
// 字段已经拆成 11 个平级项,单个字段的值是短文本/单段说明,
|
|
241
|
+
// 直接和“当前值 / 推荐值”放同一行即可;两项之间仍用硬换行分隔。
|
|
242
|
+
if (curStr) hints.push(`📌 当前值:${curStr}`);
|
|
243
|
+
if (sugStr) hints.push(`💡 推荐值:${sugStr}`);
|
|
244
|
+
|
|
245
|
+
const def = {
|
|
246
|
+
id: f.id,
|
|
247
|
+
question: titleLine,
|
|
248
|
+
};
|
|
249
|
+
// 没有当前值/推荐值时**不要**给 detail 赋值:DSH 端按
|
|
250
|
+
// `detail !== undefined` 判断要不要渲染这个块,给空串会渲染出一个
|
|
251
|
+
// 空 div(web 端还带 8px 下边距),TUI 端会渲染一行空的 dim 文本。
|
|
252
|
+
if (hints.length) def.detail = hints.join(' \n');
|
|
253
|
+
if (f.options) def.options = toAskOptions(f.options);
|
|
254
|
+
// multiSelect 必须在问题对象上声明:DSH 的 UI 靠它决定渲染 checkbox
|
|
255
|
+
// 还是 radio(dsh-client-ui-user-questions/lib/client.js:
|
|
256
|
+
// question.multiSelect === true ? "checkbox" : "radio")。
|
|
257
|
+
// 不设就是单选,会与标题里"可多选"的说明自相矛盾。
|
|
258
|
+
if (f.kind === 'multiselect') def.multiSelect = true;
|
|
259
|
+
return def;
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 路径 A 的 LLM 提示词
|
|
265
|
+
* @param {object} args
|
|
266
|
+
* @param {string} args.platform - 中文平台名(番茄/起点/晋江/七猫)
|
|
267
|
+
* @param {string} args.decisionFile - topic_decision_<date>.md 文件名
|
|
268
|
+
* @param {string} [args.decisionPath] - 该文件的**绝对路径**。
|
|
269
|
+
* 只给文件名的话,模型拼不出它在哪儿(实际在 `<工作区目录>/扫榜结果/` 下),
|
|
270
|
+
* 写进 `source_decision_file` 的就是个解析不了的裸名。
|
|
271
|
+
* @param {string} args.blocks - 拼接好的内容块(多个 # start/end 块用 --- 分隔)
|
|
272
|
+
* @param {number} args.blockCount - 本次平台用到的块数
|
|
273
|
+
* @param {Array} args.sectionSummary - 按平台分组的全文件块清单
|
|
274
|
+
* [{ platform: '起点', blocks: [{ headers: [...] }, ...] }, ...]
|
|
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 部署后传入。
|
|
283
|
+
*/
|
|
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;
|
|
299
|
+
const sourceLines = [];
|
|
300
|
+
if (sectionSummary.length) {
|
|
301
|
+
// 总数是**块数**(不是平台数):同一平台可能有多个 -start 块
|
|
302
|
+
const totalBlocks = sectionSummary.reduce((n, s) => n + s.blocks.length, 0);
|
|
303
|
+
sourceLines.push(`该文件按平台切分出 ${totalBlocks} 块:`);
|
|
304
|
+
for (const s of sectionSummary) {
|
|
305
|
+
sourceLines.push(` - ${s.platform}(${s.blocks.length} 块):`);
|
|
306
|
+
s.blocks.forEach((b, i) => {
|
|
307
|
+
const titles = summarizeBlockHeaders(b.headers);
|
|
308
|
+
sourceLines.push(` ${i + 1}. ${escapeScanSentinels(titles.join(' / ') || '(无标题)')}`);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
sourceLines.push(`本次只使用「${platform}」的 ${blockCount} 块。`);
|
|
312
|
+
}
|
|
313
|
+
if (notes.length) {
|
|
314
|
+
sourceLines.push('');
|
|
315
|
+
sourceLines.push('解析说明:');
|
|
316
|
+
for (const n of notes) sourceLines.push(` - ${escapeScanSentinels(n)}`);
|
|
317
|
+
}
|
|
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
|
+
});
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 通知模板(左侧消息栏)
|
|
338
|
+
*/
|
|
339
|
+
notify({ type, platform, phase, decisionFile, blockCount, fieldCount, missing, err, projectRoot, extra, available, notes, file, reasons, arg }) {
|
|
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
|
+
|
|
357
|
+
case 'cancelled':
|
|
358
|
+
return '🚫 操作已取消。';
|
|
359
|
+
|
|
360
|
+
case 'no_provider':
|
|
361
|
+
return '⚠️ 当前运行环境不支持交互式提问。请在 dsh-tui 中运行该命令,或确认 Web UI 已正确加载交互组件。';
|
|
362
|
+
|
|
363
|
+
case 'no_project_root':
|
|
364
|
+
return '❌ 无法确定工作区目录:' + (err || '会话未提供当前工作目录') + '。请在工作区目录下运行该命令。';
|
|
365
|
+
|
|
366
|
+
case 'ask_failed':
|
|
367
|
+
return '❌ 交互提问失败:' + (err || '未知错误');
|
|
368
|
+
|
|
369
|
+
case 'not_initialized':
|
|
370
|
+
return '⚠️ 当前目录不属于已初始化的 fancy-webnovel 工作区。请先运行 /fancy-bootstrap 初始化工作区。';
|
|
371
|
+
|
|
372
|
+
case 'phase_wrong':
|
|
373
|
+
return `⚠️ 当前工作区阶段为「${phase || '未知'}」,不能运行 /fancy-topic。请先运行 /fancy-scan 完成扫榜。`;
|
|
374
|
+
|
|
375
|
+
case 'phase_unreadable':
|
|
376
|
+
return (
|
|
377
|
+
`❌ 无法读取状态文件 .fancy-deployed(${err || '内容异常'}),因此无法确定当前工作区阶段。\n` +
|
|
378
|
+
'请检查该文件是否被手动修改或损坏;如无法修复,请重新运行 /fancy-bootstrap 初始化工作区。'
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
case 'no_decision_file':
|
|
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
|
+
);
|
|
395
|
+
|
|
396
|
+
case 'no_decision_block':
|
|
397
|
+
return (
|
|
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 写入报告正文。'
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
case 'platform_not_in_decision':
|
|
409
|
+
return (
|
|
410
|
+
`⚠️ 在 ${decisionFile || '所选文件'} 中未找到「${platform || ''}」的内容块。\n` +
|
|
411
|
+
`文件中已有的内容块:\n${(available && available.length) ? available.map((b, i) => `${i + 1}. ${b}`).join('\n') : '(无)'}\n` +
|
|
412
|
+
'请确认是否已采集该平台。'
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
case 'invalid_platform':
|
|
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
|
+
);
|
|
425
|
+
|
|
426
|
+
case 'path_a_done': {
|
|
427
|
+
const noteText = (notes && notes.length)
|
|
428
|
+
? '\n' + notes.map(n => 'ℹ️ ' + n).join('\n')
|
|
429
|
+
: '';
|
|
430
|
+
return (
|
|
431
|
+
`📊 已请求 LLM 分析 ${blockCount || '?'} 个数据块\n` +
|
|
432
|
+
`📚 平台:${platform || ''}\n` +
|
|
433
|
+
`📁 参考:${decisionFile || ''}` +
|
|
434
|
+
noteText + '\n' +
|
|
435
|
+
`💡 LLM 将在对话中给出 6 维评分与 ${TOPIC_FIELDS.length} 个字段建议。确认选题后,请运行 /fancy-topic setup 写入最终决定。`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case 'path_b_done':
|
|
440
|
+
return `✅ 选题已确认。
|
|
441
|
+
下一步:运行 /fancy-build scale 构建故事(在此之前可选择运行 /fancy-analyze 拆解同题材爆款小说)。` +
|
|
442
|
+
(extra ? '\n\n' + extra : '');
|
|
443
|
+
|
|
444
|
+
case 'write_failed':
|
|
445
|
+
return '❌ 写入 _novel_topic.json 失败:' + (err || '未知错误');
|
|
446
|
+
|
|
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
|
+
}
|
|
465
|
+
|
|
466
|
+
case 'path_a_rescan':
|
|
467
|
+
return '🔄 已选择重新扫榜。请运行 /fancy-scan。';
|
|
468
|
+
|
|
469
|
+
case 'create_skeleton':
|
|
470
|
+
return '📝 未找到 _novel_topic.json,已创建骨架文件。';
|
|
471
|
+
|
|
472
|
+
default:
|
|
473
|
+
return String(type);
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
};
|
|
@@ -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
|
+
}
|