@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
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 两条路径:
|
|
5
5
|
* A 探索模式 /fancy-topic [参数] trim 后为空 → 选平台/选文件/LLM 评分
|
|
6
|
-
* B 敲定模式 /fancy-topic setup
|
|
6
|
+
* B 敲定模式 /fancy-topic setup 逐项弹框 → 原子写盘 → phase=topic_done
|
|
7
7
|
*
|
|
8
8
|
* 不论哪条路径,都先检查 .fancy-deployed 存在且 phase ∈ {scanned, topic_done}。
|
|
9
9
|
*/
|
|
10
|
-
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
10
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync } from 'fs';
|
|
11
|
+
import { join, resolve } from 'path';
|
|
12
12
|
import { fileURLToPath } from 'url';
|
|
13
13
|
import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
|
|
14
14
|
import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
|
|
15
|
+
import { repairTopicDecision, selectNonRedundantBlocks, inspectBlockBodies } from '#topic-decision';
|
|
16
|
+
import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
|
|
17
|
+
import { normalizeTopicData } from '#topic-schema';
|
|
18
|
+
import { resolveWorkspace } from '#workspace';
|
|
15
19
|
import { topic } from './templates.js';
|
|
20
|
+
import { deployTopicTools } from './tool-deploy.js';
|
|
16
21
|
|
|
17
22
|
export const inject = ['commands', 'userQuestions'];
|
|
18
23
|
|
|
@@ -42,26 +47,49 @@ const MULTISELECT_IDS = new Set(['hard_constraints', 'anti_tropes']);
|
|
|
42
47
|
/**
|
|
43
48
|
* 从 DSH 的一条 answer 里取值。
|
|
44
49
|
*
|
|
50
|
+
* ## custom 与 selected 谁优先(2026-10-29 审计)
|
|
51
|
+
*
|
|
52
|
+
* DSH 的契约里两者可以并存:
|
|
53
|
+
*
|
|
54
|
+
* selected: string[] 选中的选项标签
|
|
55
|
+
* custom?: string 自由输入的"其他"
|
|
56
|
+
*
|
|
57
|
+
* web 端在**单选**时会把 selected 清空(只留 custom),
|
|
58
|
+
* 但 TUI 端可能两个都传 —— `selected: ['其他']` + `custom: '玄幻'`。
|
|
59
|
+
* 这时取 `selected[0]` 会拿到"其他"这个标签,
|
|
60
|
+
* 用户真正输入的题材**被丢掉**。
|
|
61
|
+
*
|
|
62
|
+
* 所以:**custom 是用户亲手敲的,比 selected 的标签更具体,优先用它**。
|
|
63
|
+
* 多选时两者都要保留(custom 是用户在已选项之外额外加的一条)。
|
|
64
|
+
*
|
|
45
65
|
* @param {object} ans - DSH 的 AskUserQuestionAnswerItem
|
|
46
66
|
* @returns {string|string[]|undefined} undefined 表示这条 answer 没有对应字段
|
|
47
67
|
*/
|
|
48
68
|
function collectAnswerValue(ans) {
|
|
49
69
|
if (!ans || !ans.id) return undefined;
|
|
70
|
+
const custom = typeof ans.custom === 'string' ? ans.custom.trim() : '';
|
|
50
71
|
if (MULTISELECT_IDS.has(ans.id)) {
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
return
|
|
72
|
+
const out = Array.isArray(ans.selected) ? ans.selected.slice() : [];
|
|
73
|
+
if (custom && !out.includes(custom)) out.push(custom);
|
|
74
|
+
return out;
|
|
54
75
|
}
|
|
55
|
-
//
|
|
56
|
-
|
|
76
|
+
// 单选或文本:custom(用户亲手输入的)优先,否则取选中的标签
|
|
77
|
+
if (custom) return custom;
|
|
78
|
+
return ans.selected?.[0] ?? '';
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
function getProjectRoot(session) {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// 此时返回 null,由调用方给出明确报错。
|
|
82
|
+
// Scan/topic/analyze are workspace-level tools. The workspace may be found
|
|
83
|
+
// from cwd, FANCY_HOME, or the global pointer written by bootstrap.
|
|
63
84
|
const cwd = session.header?.cwd;
|
|
64
|
-
|
|
85
|
+
if (!cwd) return null;
|
|
86
|
+
try {
|
|
87
|
+
return resolveWorkspace({ cwd, required: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// Fall back to the session cwd so the caller can report "not initialized"
|
|
90
|
+
// with the right path instead of "no project root".
|
|
91
|
+
return cwd;
|
|
92
|
+
}
|
|
65
93
|
}
|
|
66
94
|
|
|
67
95
|
/**
|
|
@@ -145,18 +173,33 @@ function splitDecisionSections(content) {
|
|
|
145
173
|
const platform = title ? platformOfHeader(title) : null;
|
|
146
174
|
|
|
147
175
|
if (platform) {
|
|
148
|
-
|
|
149
|
-
const
|
|
176
|
+
// 分隔符是必需的:`# Restart` 不能被当成 `-start` 标记。
|
|
177
|
+
const isStartMark = /(?:^|[-—–\s])start\s*$/i.test(title);
|
|
178
|
+
const isEndMark = /(?:^|[-—–\s])end\s*$/i.test(title);
|
|
179
|
+
const titleKey = title.replace(/(?:^|[-—–\s])(?:start|end)\s*$/i, '').trim();
|
|
150
180
|
|
|
151
|
-
// -end
|
|
152
|
-
//
|
|
153
|
-
|
|
181
|
+
// -end 行:关闭当前块(同平台且同 key),**不开新块**。
|
|
182
|
+
// 不能只看平台:`# 起点-签约榜-end` 会把 `# 起点-全选-start`
|
|
183
|
+
// 开的块错误闭合,两块内容随后可能被合并/错切。
|
|
184
|
+
if (isEndMark && cur && cur.platform === platform && (!cur.key || cur.key === titleKey)) {
|
|
154
185
|
cur.hasEnd = true;
|
|
155
186
|
cur.headers.push(`# ${title}`);
|
|
156
187
|
raw.push(cur);
|
|
157
188
|
cur = null;
|
|
158
189
|
continue;
|
|
159
190
|
}
|
|
191
|
+
// key 对不上的 end 不关闭当前块;当普通标题保留在块内,
|
|
192
|
+
// 由 inspectTopicDecision / parseMarkerBlocks 去报告“标记不匹配”。
|
|
193
|
+
if (isEndMark && cur && cur.platform === platform) {
|
|
194
|
+
cur.headers.push(`# ${title}`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// 孤立 end / 别的平台的 end:不要凭空开一个空块。
|
|
198
|
+
if (isEndMark) {
|
|
199
|
+
if (cur) cur.bodyLines.push(`# ${title}`);
|
|
200
|
+
else preamble.push(`# ${title}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
160
203
|
|
|
161
204
|
// 块内的另一个标题:同平台、当前块开着、且它不是 -start/-end 标记。
|
|
162
205
|
// 追加进当前块,不新开块 —— LLM 常在 -start 后面紧跟一个报告大标题。
|
|
@@ -169,6 +212,7 @@ function splitDecisionSections(content) {
|
|
|
169
212
|
if (cur) raw.push(cur);
|
|
170
213
|
cur = {
|
|
171
214
|
platform,
|
|
215
|
+
key: titleKey,
|
|
172
216
|
headers: [`# ${title}`],
|
|
173
217
|
bodyLines: [],
|
|
174
218
|
hasStart: isStartMark,
|
|
@@ -279,12 +323,41 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
279
323
|
|
|
280
324
|
// 扫描 扫榜结果/ 目录下的 topic_decision_<date>.md 文件
|
|
281
325
|
const scanDir = join(projectRoot, '扫榜结果');
|
|
282
|
-
|
|
283
|
-
|
|
326
|
+
let decisionFiles = [];
|
|
327
|
+
if (existsSync(scanDir)) {
|
|
328
|
+
try {
|
|
329
|
+
const st = lstatSync(scanDir);
|
|
330
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
331
|
+
return notify(session, 'error', topic.notify({
|
|
332
|
+
type: 'read_failed',
|
|
333
|
+
err: '扫榜结果/ 不是真实目录,已拒绝读取。请将其替换为真实目录后重试。',
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return notify(session, 'error', topic.notify({
|
|
338
|
+
type: 'read_failed',
|
|
339
|
+
err: `检查扫榜结果目录失败:${e.message}`,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
decisionFiles = readdirSync(scanDir)
|
|
284
344
|
.filter(f => /^topic_decision_\d{8}\.md$/.test(f))
|
|
345
|
+
// 不跟随符号链接:决策文件必须在工作区目录内,防止读到项目外的内容。
|
|
346
|
+
.filter(f => {
|
|
347
|
+
try {
|
|
348
|
+
const st = lstatSync(join(scanDir, f));
|
|
349
|
+
return st.isFile() && !st.isSymbolicLink();
|
|
350
|
+
} catch { return false; }
|
|
351
|
+
})
|
|
285
352
|
.sort()
|
|
286
|
-
.reverse() // 最新的在前
|
|
287
|
-
|
|
353
|
+
.reverse(); // 最新的在前
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return notify(session, 'error', topic.notify({
|
|
356
|
+
type: 'read_failed',
|
|
357
|
+
err: `读取扫榜结果目录失败:${e.message}`,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
288
361
|
|
|
289
362
|
if (decisionFiles.length === 0) {
|
|
290
363
|
return notify(session, 'error', topic.notify({ type: 'no_decision_file' }));
|
|
@@ -319,12 +392,46 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
319
392
|
// 读取 topic_decision_<date>.md
|
|
320
393
|
const decisionPath = join(projectRoot, '扫榜结果', selectedFile);
|
|
321
394
|
let content;
|
|
395
|
+
|
|
396
|
+
// 2026-10-27:读之前先校验一遍有没有被覆盖。
|
|
397
|
+
//
|
|
398
|
+
// 写这个文件的是 LLM,write 工具是覆盖语义 —— 实测(tt5 项目)
|
|
399
|
+
// 第二个平台分析时模型先 write("") 清空文件再 cat >> 追加,
|
|
400
|
+
// 第一个平台的分析就此消失。fancy-scan 现在投递前会备份,
|
|
401
|
+
// 这里拿备份比对,把被覆盖掉的段落自动补回来。
|
|
402
|
+
let repairedKeys = [];
|
|
403
|
+
try {
|
|
404
|
+
const repair = repairTopicDecision(decisionPath);
|
|
405
|
+
if (repair.repaired.length) {
|
|
406
|
+
repairedKeys = repair.repaired;
|
|
407
|
+
console.error(`[fancy-topic] topic_decision 缺失 ${repairedKeys.length} 个内容块,已从备份补回:${repairedKeys.join('、')}`);
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
console.error('[fancy-topic] 校验 topic_decision 失败:' + (e && e.message ? e.message : String(e)));
|
|
411
|
+
}
|
|
412
|
+
|
|
322
413
|
try {
|
|
323
414
|
content = readFileSync(decisionPath, 'utf-8');
|
|
324
415
|
} catch (e) {
|
|
325
416
|
return notify(session, 'error', topic.notify({
|
|
326
|
-
type: '
|
|
327
|
-
err: `读取 ${selectedFile}
|
|
417
|
+
type: 'read_failed',
|
|
418
|
+
err: `读取 ${selectedFile} 失败:${e.message}`,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 空文件不能走“只扫过一个平台 → 整份文件都归它”的降级分支:
|
|
423
|
+
// 那会把空字符串当成数据发给 LLM,产出建立在空白上的选题。
|
|
424
|
+
// 只有 start/end 标记、没有正文的文件同样算空。
|
|
425
|
+
const meaningful = String(content)
|
|
426
|
+
.split('\n')
|
|
427
|
+
.filter(l => !/^\s*#\s*[^\n]*-(start|end)\s*$/i.test(l))
|
|
428
|
+
.filter(l => !/^\s*(?:`{3,}|~{3,}).*$/.test(l))
|
|
429
|
+
.join('')
|
|
430
|
+
.trim();
|
|
431
|
+
if (!meaningful) {
|
|
432
|
+
return notify(session, 'error', topic.notify({
|
|
433
|
+
type: 'empty_decision_file',
|
|
434
|
+
decisionFile: selectedFile,
|
|
328
435
|
}));
|
|
329
436
|
}
|
|
330
437
|
|
|
@@ -337,6 +444,96 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
337
444
|
let matchedSections = filterSectionsByPlatform(sections, platformValue);
|
|
338
445
|
const notes = [];
|
|
339
446
|
|
|
447
|
+
// ── 先验正文,再去冗余 ────────────────────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// 顺序反了会丢块(2026-10-29 审计):
|
|
450
|
+
//
|
|
451
|
+
// 1. 先 selectNonRedundantBlocks —— 后出现的"全选"块(范围宽)
|
|
452
|
+
// 覆盖前面的"单榜"块(范围窄),单榜块被丢弃;
|
|
453
|
+
// 2. 再 inspectBlockBodies —— 这才发现"全选"块的正文没写进去
|
|
454
|
+
// (占位符残留 / 正文为空),把它剔除;
|
|
455
|
+
// 3. usable 为空 → 报 block_body_invalid。
|
|
456
|
+
//
|
|
457
|
+
// 明明"单榜"块是好的,却因为先被去冗余丢掉而报失败。
|
|
458
|
+
// 正确顺序:**先把没写进去的块剔掉,再在剩下的块里算冗余**。
|
|
459
|
+
//
|
|
460
|
+
// 实测(2026-10-28):模型把 5.2 的 heredoc 命令原样跑了一遍,
|
|
461
|
+
// `<本次正文>` 占位符原封不动进了文件,而 start/end 两个标记都写对了。
|
|
462
|
+
// 于是 start=1 且 end=1 被判成"写入成功",这段占位文字又被原样发给 LLM
|
|
463
|
+
// 做选题 —— 选题结论建立在"字都没写"的块上。
|
|
464
|
+
//
|
|
465
|
+
// 模型看不见自己写进去的东西,所以这个判定必须由插件做(插件算得出来)。
|
|
466
|
+
// 有问题的块**不发给 LLM**:宁可少发一块,也不能让垃圾进选题。
|
|
467
|
+
{
|
|
468
|
+
const bad = inspectBlockBodies(content);
|
|
469
|
+
// 只看**本次选中平台**的块。文件里别的平台的坏块不应让本次报
|
|
470
|
+
// “block_body_invalid”,也不应出现在本次的提示里。
|
|
471
|
+
const relevantBad = bad.filter(b => {
|
|
472
|
+
if (!b.key) return false;
|
|
473
|
+
return matchedSections.some(s => String((s.headers && s.headers[0]) || '').includes(b.key));
|
|
474
|
+
});
|
|
475
|
+
// 只有**确凿**的失败(占位符残留 / 正文为空 / 只剩命令痕迹)才拦住派发;
|
|
476
|
+
// "偏短""没有结构标题"只是可疑 —— 报告本来就可能短,
|
|
477
|
+
// 把"我觉得不合格"当成"这是坏的"会误伤正常内容,所以只提示。
|
|
478
|
+
const hardBad = relevantBad.filter(b => b.reasons && b.reasons.length);
|
|
479
|
+
const softBad = relevantBad.filter(b => !b.reasons || !b.reasons.length);
|
|
480
|
+
|
|
481
|
+
if (softBad.length) {
|
|
482
|
+
notes.push(
|
|
483
|
+
`有 ${softBad.length} 个内容块看起来偏薄(${softBad.map(b => b.warnings.join(';')).join(';')}),` +
|
|
484
|
+
'已照常使用 —— 如果内容明显不对,可以手动检查一下。'
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (hardBad.length) {
|
|
489
|
+
const usable = matchedSections.filter(
|
|
490
|
+
s => !hardBad.some(b => b.key && String(s.headers[0] || '').includes(b.key))
|
|
491
|
+
);
|
|
492
|
+
if (usable.length) {
|
|
493
|
+
notes.push(
|
|
494
|
+
`有 ${hardBad.length} 个内容块的正文没写进去(${hardBad.map(b => b.reasons.join(';')).join(';')}),` +
|
|
495
|
+
'已跳过它们,只把能用的块发给 LLM;文件里的原始内容没有删除。'
|
|
496
|
+
);
|
|
497
|
+
matchedSections = usable;
|
|
498
|
+
} else {
|
|
499
|
+
// 所有块都没内容 —— 直接失败,别拿垃圾去做选题
|
|
500
|
+
return notify(session, 'error', topic.notify({
|
|
501
|
+
type: 'block_body_invalid',
|
|
502
|
+
reasons: hardBad.map(b => `「${b.key}」:${b.reasons.join(';')}`),
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 同平台的块去冗余 ——
|
|
509
|
+
//
|
|
510
|
+
// 同一平台一天里可能被扫多次,每次追加一个标记块。文件里两块都留着
|
|
511
|
+
// (数据不丢),但把两块**一起**发给 LLM 就是冗余:榜单范围窄的那块
|
|
512
|
+
// 已经被范围宽的那块覆盖,同一批数据会被统计两遍。
|
|
513
|
+
//
|
|
514
|
+
// 去冗余只影响"发给 LLM 的内容",**不删文件里的任何数据**。
|
|
515
|
+
if (matchedSections.length > 1) {
|
|
516
|
+
const { kept, dropped } = selectNonRedundantBlocks(
|
|
517
|
+
matchedSections,
|
|
518
|
+
(s) => (s.headers && s.headers[0]) || ''
|
|
519
|
+
);
|
|
520
|
+
if (dropped.length) {
|
|
521
|
+
matchedSections = kept;
|
|
522
|
+
notes.push(
|
|
523
|
+
`该平台有 ${dropped.length} 个内容块与其它块重复(${dropped.map(d => d.reason).join(';')}),` +
|
|
524
|
+
'已只发不重复的那部分给 LLM;文件里的原始数据没有删除。'
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 被覆盖过的段落已从备份补回 —— 必须告诉用户"这份文件刚才丢过东西"
|
|
530
|
+
if (repairedKeys.length) {
|
|
531
|
+
notes.push(
|
|
532
|
+
`检测到该文件曾被覆盖(LLM 写文件时用了覆盖语义),已从备份补回 ${repairedKeys.length} 个内容块:` +
|
|
533
|
+
repairedKeys.join('、') + '。'
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
340
537
|
// 一次平台标记都没写:若只扫过一个平台,整份文件都属于它(降级,不直接失败)
|
|
341
538
|
if (sections.length === 0) {
|
|
342
539
|
const scanned = scannedPlatforms(scanDir);
|
|
@@ -366,6 +563,7 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
366
563
|
}));
|
|
367
564
|
}
|
|
368
565
|
|
|
566
|
+
|
|
369
567
|
// 诊断:哪些块缺 start/end 标记(LLM 格式不规范,但已按标题隔离,不影响正确性)
|
|
370
568
|
// 分开统计 -start / -end:只缺 -end 的块被说成"标记缺失"是错的 ——
|
|
371
569
|
// 那种块明明写着 -start,却出现在告警名单里,用户会以为切错了。
|
|
@@ -382,14 +580,24 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
382
580
|
notes.push(`其中 ${parts.join(';')},已按标题行切分隔离,不影响平台归属。`);
|
|
383
581
|
}
|
|
384
582
|
|
|
385
|
-
// 拼装发给 LLM
|
|
583
|
+
// 拼装发给 LLM 的提示。
|
|
584
|
+
//
|
|
585
|
+
// 块包装**不用标题层级**(2026-10-29 提示词评审 F1):
|
|
586
|
+
// 原来用 `## 块 N`,而块正文里有 `# 平台-榜单-日期-start` 这种 H1 ——
|
|
587
|
+
// H2 里套 H1 是层级倒置,任务指令 `### 2.1` 与数据里的 `### 3.1`
|
|
588
|
+
// 也混在同一层,模型只能靠"请执行以下分析"这一句判断边界。
|
|
589
|
+
//
|
|
590
|
+
// 现在数据整体被 `<<<FANCY_SCAN_DATA>>>` 哨兵包住(见 explorePrompt),
|
|
591
|
+
// 块包装改成普通文本行 `【数据块 N】`,不引入任何标题层级。
|
|
592
|
+
// 块标题已经在 source_summary_block 里逐条列出,这里不再重复 start/end 标记;
|
|
593
|
+
// 只保留“数据块 N”这个普通文本行,正文从 `## 一、...` 开始。
|
|
386
594
|
const blocksText = matchedSections.map((s, i) =>
|
|
387
|
-
|
|
595
|
+
`【数据块 ${i + 1}】\n${s.body}`
|
|
388
596
|
).join('\n\n');
|
|
389
597
|
|
|
390
598
|
// 汇总按平台分组(保持文件里的出现顺序),并给出每个平台的块数。
|
|
391
599
|
// 之前是"每块一行",同一个平台会出现多行、总数也和块数对不上;
|
|
392
|
-
// 术语统一用"块"(与下面的 "
|
|
600
|
+
// 术语统一用"块"(与下面的 "【数据块 N】" 标记一致)。
|
|
393
601
|
const summaryByPlatform = new Map();
|
|
394
602
|
for (const s of sections) {
|
|
395
603
|
const name = platformCn(s.platform) || s.platform;
|
|
@@ -401,13 +609,31 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
401
609
|
blocks,
|
|
402
610
|
}));
|
|
403
611
|
|
|
612
|
+
// LLM 只执行工作区内的只读脚本:先把 save-suggestion.js 及其 import 闭包
|
|
613
|
+
// 部署到 `<工作区目录>/.fancy-tools/fancy-topic/`,再把部署后的路径写进提示词。
|
|
614
|
+
// 不能把插件目录(node_modules)暴露给模型。
|
|
615
|
+
const deployed = deployTopicTools(projectRoot);
|
|
616
|
+
if (!deployed.ok) {
|
|
617
|
+
return notify(session, 'error', topic.notify({
|
|
618
|
+
type: 'tool_deploy_failed',
|
|
619
|
+
err: deployed.problems.join(';'),
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
mkdirSync(join(projectRoot, '.tmp'), { recursive: true });
|
|
624
|
+
|
|
404
625
|
const prompt = topic.explorePrompt({
|
|
405
626
|
platform: platformCnName,
|
|
406
627
|
decisionFile: selectedFile,
|
|
628
|
+
// 绝对路径:提示词里"参考文件"和 source_decision_file 都用它,
|
|
629
|
+
// 免得模型拿着裸文件名拼不出它在 扫榜结果/ 下
|
|
630
|
+
decisionPath: decisionPath,
|
|
407
631
|
blocks: blocksText,
|
|
408
632
|
blockCount: matchedSections.length,
|
|
409
633
|
sectionSummary,
|
|
410
634
|
notes,
|
|
635
|
+
projectRoot,
|
|
636
|
+
saveScriptPath: deployed.saveScript,
|
|
411
637
|
});
|
|
412
638
|
|
|
413
639
|
// 触发 LLM 分析
|
|
@@ -417,15 +643,15 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
|
|
|
417
643
|
// 否则投递失败也会报"分析已完成"
|
|
418
644
|
if (!follow || follow.ok !== true) {
|
|
419
645
|
return notify(session, 'error', topic.notify({
|
|
420
|
-
type: '
|
|
421
|
-
err:
|
|
646
|
+
type: 'llm_delivery_failed',
|
|
647
|
+
err: (follow && follow.error) || '提示词投递失败',
|
|
422
648
|
}));
|
|
423
649
|
}
|
|
424
650
|
} catch (err) {
|
|
425
651
|
// llmFollowup 通常不抛错(它自己 try/catch),但保险起见
|
|
426
652
|
return notify(session, 'error', topic.notify({
|
|
427
|
-
type: '
|
|
428
|
-
err:
|
|
653
|
+
type: 'llm_delivery_failed',
|
|
654
|
+
err: err.message,
|
|
429
655
|
}));
|
|
430
656
|
}
|
|
431
657
|
|
|
@@ -495,10 +721,24 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
|
|
|
495
721
|
// 那段代码对用户完全不可见,属于死代码,已删除。
|
|
496
722
|
const suggestionPath = join(projectRoot, '_novel_topic_suggestion.json');
|
|
497
723
|
let suggestion = null;
|
|
498
|
-
if (existsSync(suggestionPath)) {
|
|
724
|
+
if (existsSync(suggestionPath) && lstatSync(suggestionPath).isSymbolicLink()) {
|
|
725
|
+
extraNotes.push(topic.notify({
|
|
726
|
+
type: 'suggestion_corrupt',
|
|
727
|
+
file: '_novel_topic_suggestion.json(符号链接,已忽略)',
|
|
728
|
+
}));
|
|
729
|
+
} else if (existsSync(suggestionPath)) {
|
|
499
730
|
try {
|
|
500
|
-
suggestion = JSON.parse(readFileSync(suggestionPath, 'utf-8'));
|
|
501
|
-
} catch (
|
|
731
|
+
suggestion = normalizeTopicData(JSON.parse(readFileSync(suggestionPath, 'utf-8')));
|
|
732
|
+
} catch (e) {
|
|
733
|
+
// 以前是静默忽略 —— 用户看到的是"没有推荐值",却不知道推荐值其实存在、
|
|
734
|
+
// 只是坏了。这跟"文件被写坏"这个事实一起消失,问题无从查起。
|
|
735
|
+
// 现在明确告诉用户,并给出补救动作。
|
|
736
|
+
console.error('[fancy-topic] _novel_topic_suggestion.json 解析失败:' + (e && e.message ? e.message : String(e)));
|
|
737
|
+
extraNotes.push(topic.notify({
|
|
738
|
+
type: 'suggestion_corrupt',
|
|
739
|
+
file: '_novel_topic_suggestion.json',
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
502
742
|
}
|
|
503
743
|
|
|
504
744
|
// 比较 suggestion 与 selectedPlatform,决定是否应用建议值
|
|
@@ -508,11 +748,27 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
|
|
|
508
748
|
// 读 _novel_topic.json,仅当其 platform 与 selectedPlatform 一致时作为"当前值"展示
|
|
509
749
|
const currentValues = (() => {
|
|
510
750
|
if (!existsSync(novelTopicPath)) return {};
|
|
751
|
+
if (lstatSync(novelTopicPath).isSymbolicLink()) {
|
|
752
|
+
extraNotes.push(topic.notify({
|
|
753
|
+
type: 'current_values_corrupt',
|
|
754
|
+
file: '_novel_topic.json(符号链接,已忽略)',
|
|
755
|
+
}));
|
|
756
|
+
return {};
|
|
757
|
+
}
|
|
511
758
|
try {
|
|
512
|
-
const data = JSON.parse(readFileSync(novelTopicPath, 'utf-8'));
|
|
759
|
+
const data = normalizeTopicData(JSON.parse(readFileSync(novelTopicPath, 'utf-8')));
|
|
513
760
|
if (!data.platform) return data; // 没有 platform 字段就当作一致(兼容旧数据)
|
|
514
761
|
return platformEqual(data.platform, selectedPlatform) ? data : {};
|
|
515
|
-
} catch (
|
|
762
|
+
} catch (e) {
|
|
763
|
+
// 同上:损坏不能静默。_novel_topic.json 存的是**已敲定的设定**,
|
|
764
|
+
// 它坏了会让后面的阶段读不到已定下来的东西。
|
|
765
|
+
console.error('[fancy-topic] _novel_topic.json 解析失败:' + (e && e.message ? e.message : String(e)));
|
|
766
|
+
extraNotes.push(topic.notify({
|
|
767
|
+
type: 'current_values_corrupt',
|
|
768
|
+
file: '_novel_topic.json',
|
|
769
|
+
}));
|
|
770
|
+
return {};
|
|
771
|
+
}
|
|
516
772
|
})();
|
|
517
773
|
|
|
518
774
|
// 只有 suggestion 平台匹配时,askFields 才显示"推荐值"
|
|
@@ -537,7 +793,7 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
|
|
|
537
793
|
return notify(session, 'error', topic.notify({ type: 'cancelled' }));
|
|
538
794
|
}
|
|
539
795
|
|
|
540
|
-
//
|
|
796
|
+
// 收集所有选题字段的值。
|
|
541
797
|
// 首次收集与必填重试轮必须走同一套取值逻辑(collectAnswerValue)——
|
|
542
798
|
// 否则多选字段会在重试轮塌成字符串、丢掉第 2 项以后的选择。
|
|
543
799
|
const fieldValues = { platform: selectedPlatform };
|
|
@@ -615,37 +871,95 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
|
|
|
615
871
|
if (signal?.aborted) {
|
|
616
872
|
return notify(session, 'error', topic.notify({ type: 'cancelled' }));
|
|
617
873
|
}
|
|
618
|
-
const
|
|
874
|
+
const writeArgs = [
|
|
619
875
|
SELECT_TOPIC_SCRIPT,
|
|
620
876
|
'--project-root', projectRoot,
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
877
|
+
];
|
|
878
|
+
// 字段清单来自 topic.TOPIC_FIELDS(与提示词/弹框/schema 同源),
|
|
879
|
+
// 新增字段不需要在这里再手写一遍 --xxx 参数。
|
|
880
|
+
for (const f of topic.TOPIC_FIELDS) {
|
|
881
|
+
const v = fieldValues[f.id];
|
|
882
|
+
writeArgs.push(
|
|
883
|
+
'--' + f.id.replace(/_/g, '-'),
|
|
884
|
+
Array.isArray(v) ? JSON.stringify(v) : String(v ?? '')
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
writeArgs.push('--platform', fieldValues.platform, 'write');
|
|
888
|
+
const writeResult = await spawnScript('node', writeArgs, { timeout: 30000 });
|
|
632
889
|
|
|
633
890
|
if (!writeResult.ok) {
|
|
634
891
|
const errMsg = writeResult.error || '未知错误';
|
|
635
892
|
return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
|
|
636
893
|
}
|
|
637
894
|
|
|
638
|
-
|
|
895
|
+
// phase 没推进成 topic_done 时,选题内容是写进去了的(所以 ok:true),
|
|
896
|
+
// 但项目状态没跟上 —— 下次运行可能还会提示"需要先选题"。
|
|
897
|
+
// 这条要说给用户听,不能闷在 JSON 里。
|
|
898
|
+
let phaseWarning = null;
|
|
899
|
+
try {
|
|
900
|
+
const parsed = JSON.parse(writeResult.output || '{}');
|
|
901
|
+
phaseWarning = parsed.phase_warning || null;
|
|
902
|
+
} catch {
|
|
903
|
+
// stdout 不是 JSON —— 没有 phase_warning 可读,忽略
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const baseDone = topic.notify({
|
|
639
907
|
type: 'path_b_done',
|
|
640
|
-
fieldCount:
|
|
641
|
-
extra: extraNotes.join('\n\n'),
|
|
642
|
-
})
|
|
908
|
+
fieldCount: topic.TOPIC_FIELDS.length,
|
|
909
|
+
extra: [extraNotes.join('\n\n'), phaseWarning].filter(Boolean).join('\n\n'),
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Lightweight roots (a single directory with .fancy-deployed, used by the
|
|
913
|
+
// shared scan/topic tests and older standalone setups) only need the topic
|
|
914
|
+
// file. Full workspaces additionally create the project directory, database
|
|
915
|
+
// and per-project user.
|
|
916
|
+
if (!existsSync(join(projectRoot, '.fancy', 'workspace.json'))) {
|
|
917
|
+
return notify(session, 'success', baseDone);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
let projectInfo = null;
|
|
921
|
+
let projectError = null;
|
|
922
|
+
let projectAction = 'created';
|
|
923
|
+
try {
|
|
924
|
+
const { createProject, getProject, updateProjectTopic } = await import('#projects');
|
|
925
|
+
const { initProjectDatabase } = await import('#db-init');
|
|
926
|
+
const finalized = normalizeTopicData(JSON.parse(readFileSync(novelTopicPath, 'utf-8')));
|
|
927
|
+
const title = finalized.book_name || finalized.title || '';
|
|
928
|
+
if (!title) throw new Error('选题缺少 book_name,无法创建项目');
|
|
929
|
+
const found = await getProject(projectRoot, title);
|
|
930
|
+
projectAction = found ? 'updated' : 'created';
|
|
931
|
+
const project = found
|
|
932
|
+
? await updateProjectTopic(projectRoot, found.project_id, finalized)
|
|
933
|
+
: await createProject(projectRoot, { title, topic: finalized });
|
|
934
|
+
await initProjectDatabase(projectRoot, project.project_id);
|
|
935
|
+
projectInfo = project;
|
|
936
|
+
} catch (err) {
|
|
937
|
+
projectError = err && err.message ? err.message : String(err);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (projectError) {
|
|
941
|
+
return notify(session, 'error', [
|
|
942
|
+
'⚠️ 选题已写入,但项目创建或更新失败:' + projectError,
|
|
943
|
+
'修复后运行 /fancy-topic setup 重试,或运行 /fancy-db init 检查数据库。',
|
|
944
|
+
extraNotes.join('\n\n'),
|
|
945
|
+
].filter(Boolean).join('\n\n'));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return notify(session, 'success', [
|
|
949
|
+
baseDone,
|
|
950
|
+
'',
|
|
951
|
+
projectAction === 'updated' ? '📚 项目已更新' : '📚 项目已创建',
|
|
952
|
+
'书名:' + projectInfo.name,
|
|
953
|
+
'UUID:' + projectInfo.project_id,
|
|
954
|
+
'目录:' + projectInfo.dir,
|
|
955
|
+
'数据库:' + projectInfo.db_name,
|
|
956
|
+
].join('\n'));
|
|
643
957
|
}
|
|
644
958
|
|
|
645
959
|
export async function apply(ctx) {
|
|
646
960
|
ctx.commands.register({
|
|
647
961
|
name: 'fancy-topic',
|
|
648
|
-
description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] )',
|
|
962
|
+
description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] | help )',
|
|
649
963
|
input: {
|
|
650
964
|
hint: '.',
|
|
651
965
|
},
|
|
@@ -654,25 +968,60 @@ export async function apply(ctx) {
|
|
|
654
968
|
const { signal } = invocation;
|
|
655
969
|
|
|
656
970
|
// 1. 解析路径:A 探索 / B 敲定
|
|
971
|
+
// 只允许两种形式:
|
|
972
|
+
// /fancy-topic → 探索
|
|
973
|
+
// /fancy-topic setup → 敲定
|
|
974
|
+
// 其他任何参数都必须报错,不能悄悄当成探索/敲定。
|
|
657
975
|
const rawInput = (invocation.rawInput || '').trim();
|
|
658
|
-
const
|
|
976
|
+
const normalizedInput = rawInput.toLowerCase();
|
|
977
|
+
if (normalizedInput === 'help') {
|
|
978
|
+
return notify(session, 'success', topic.notify({ type: 'help' }));
|
|
979
|
+
}
|
|
980
|
+
if (normalizedInput && normalizedInput !== 'setup') {
|
|
981
|
+
return notify(session, 'error', topic.notify({
|
|
982
|
+
type: 'invalid_argument',
|
|
983
|
+
arg: rawInput,
|
|
984
|
+
}));
|
|
985
|
+
}
|
|
986
|
+
const isSetupPath = normalizedInput === 'setup';
|
|
659
987
|
|
|
660
988
|
// 2. 检查项目初始化
|
|
661
989
|
const projectRoot = getProjectRoot(session);
|
|
662
|
-
|
|
990
|
+
let projectRootAbs = projectRoot ? resolve(projectRoot) : null;
|
|
991
|
+
if (!projectRootAbs) {
|
|
663
992
|
return notify(session, 'error', topic.notify({
|
|
664
993
|
type: 'no_project_root',
|
|
665
|
-
err: '
|
|
994
|
+
err: '会话未提供当前工作目录'
|
|
666
995
|
}));
|
|
667
996
|
}
|
|
668
|
-
if (!
|
|
997
|
+
if (isSensitiveProjectPath(projectRootAbs) || !isValidProjectPath(projectRootAbs)) {
|
|
998
|
+
return notify(session, 'error', topic.notify({
|
|
999
|
+
type: 'no_project_root',
|
|
1000
|
+
err: '工作区目录路径不合法或属于受保护目录'
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
projectRootAbs = realpathSync(projectRootAbs);
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
return notify(session, 'error', topic.notify({
|
|
1007
|
+
type: 'no_project_root',
|
|
1008
|
+
err: `无法读取工作区目录:${e.message}`
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
if (isSensitiveProjectPath(projectRootAbs) || !isValidProjectPath(projectRootAbs)) {
|
|
1012
|
+
return notify(session, 'error', topic.notify({
|
|
1013
|
+
type: 'no_project_root',
|
|
1014
|
+
err: '工作区目录的真实路径属于受保护目录'
|
|
1015
|
+
}));
|
|
1016
|
+
}
|
|
1017
|
+
if (!existsSync(join(projectRootAbs, '.fancy-deployed'))) {
|
|
669
1018
|
return notify(session, 'error', topic.notify({ type: 'not_initialized' }));
|
|
670
1019
|
}
|
|
671
1020
|
|
|
672
1021
|
// 3. 检查 phase
|
|
673
1022
|
// 区分"状态文件读不了"和"phase 值不对"——前者跑 scan 也没用,
|
|
674
1023
|
// 报成"请先扫榜"会把用户带偏。
|
|
675
|
-
const deployedState = readDeployedState(
|
|
1024
|
+
const deployedState = readDeployedState(projectRootAbs);
|
|
676
1025
|
if (deployedState.status === DEPLOYED_STATUS.CORRUPT
|
|
677
1026
|
|| deployedState.status === DEPLOYED_STATUS.NO_PHASE
|
|
678
1027
|
|| deployedState.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
|
|
@@ -680,7 +1029,7 @@ export async function apply(ctx) {
|
|
|
680
1029
|
// 文件**存在**时 bootstrap 会拒绝执行(它只在文件不存在时才初始化),
|
|
681
1030
|
// 让用户去跑 bootstrap 等于把人送进死胡同。
|
|
682
1031
|
return notify(session, 'error',
|
|
683
|
-
deployedStateProblemText(deployedState, { projectRoot })
|
|
1032
|
+
deployedStateProblemText(deployedState, { projectRoot: projectRootAbs })
|
|
684
1033
|
|| topic.notify({
|
|
685
1034
|
type: 'phase_unreadable',
|
|
686
1035
|
err: deployedState.status === DEPLOYED_STATUS.CORRUPT
|
|
@@ -693,11 +1042,15 @@ export async function apply(ctx) {
|
|
|
693
1042
|
return notify(session, 'error', topic.notify({ type: 'phase_wrong', phase }));
|
|
694
1043
|
}
|
|
695
1044
|
|
|
1045
|
+
if (signal?.aborted) {
|
|
1046
|
+
return notify(session, 'error', topic.notify({ type: 'cancelled' }));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
696
1049
|
// 4. 路径分发
|
|
697
1050
|
if (isSetupPath) {
|
|
698
|
-
return pathSetup(ctx, session, invocation, signal,
|
|
1051
|
+
return pathSetup(ctx, session, invocation, signal, projectRootAbs);
|
|
699
1052
|
} else {
|
|
700
|
-
return pathExplore(ctx, session, invocation, signal,
|
|
1053
|
+
return pathExplore(ctx, session, invocation, signal, projectRootAbs);
|
|
701
1054
|
}
|
|
702
1055
|
},
|
|
703
1056
|
});
|