@cloud411716/fancy-webnovel 1.0.46 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -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,3152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-story-builder plugin entry
|
|
3
|
+
*
|
|
4
|
+
* 触发命令:
|
|
5
|
+
* /fancy-story-builder skeleton - 骨架阶段
|
|
6
|
+
* /fancy-story-builder flesh - 血肉阶段
|
|
7
|
+
* /fancy-story-builder rhythm - 节奏阶段
|
|
8
|
+
* /fancy-story-builder analyze - 分析阶段
|
|
9
|
+
*
|
|
10
|
+
* 流程:
|
|
11
|
+
* 1. 检查 setup_id,不存在则调用 llm_wiki 创建"设定"项目
|
|
12
|
+
* 2. 读取 _novel_topic.json 获取选题信息
|
|
13
|
+
* 3. 读取 对标/wiki/ 获取对标参考
|
|
14
|
+
* 4. 根据子命令生成提示词
|
|
15
|
+
*
|
|
16
|
+
* 依赖:
|
|
17
|
+
* - fancy-topic (选题)
|
|
18
|
+
* - fancy-analyze (拆解对标)
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync, copyFileSync, renameSync, unlinkSync } from 'fs';
|
|
21
|
+
import { join, dirname } from 'path';
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
23
|
+
import { llmFollowup, notify, spawnScript } from '#infra';
|
|
24
|
+
import { describeFetchError, readJsonSafe } from '#llm-wiki';
|
|
25
|
+
import { storyBuilder } from './templates.js';
|
|
26
|
+
import { readWikiReferences, renderBenchmarkGuidance } from './wiki-references.js';
|
|
27
|
+
import { readInputSnapshot, readStageGate } from './scripts/graph-utils.js';
|
|
28
|
+
import {
|
|
29
|
+
inspectFleshOutput,
|
|
30
|
+
readFleshStageStatus
|
|
31
|
+
} from './flesh-output.js';
|
|
32
|
+
import {
|
|
33
|
+
inspectRhythmOutput,
|
|
34
|
+
readRhythmStageStatus,
|
|
35
|
+
buildRhythmCompletionNote
|
|
36
|
+
} from './rhythm-output.js';
|
|
37
|
+
import {
|
|
38
|
+
planFleshBatches,
|
|
39
|
+
planRhythmBatches,
|
|
40
|
+
planSkeletonBatches,
|
|
41
|
+
mergeFleshShards,
|
|
42
|
+
cleanupPartialFiles,
|
|
43
|
+
attributeErrors,
|
|
44
|
+
computeKeyChapters,
|
|
45
|
+
buildClimaxTypes,
|
|
46
|
+
RHYTHM_CROSS_FILE_OWNERS,
|
|
47
|
+
SHARD_DIR_NAME,
|
|
48
|
+
RHYTHM_SHARD_DIR_NAME,
|
|
49
|
+
SKELETON_SHARD_DIR_NAME
|
|
50
|
+
} from './scripts/stage-batches.js';
|
|
51
|
+
import { quotaPathToArray } from './scripts/skeleton-quota.js';
|
|
52
|
+
import {
|
|
53
|
+
inspectSkeletonOutput,
|
|
54
|
+
readSkeletonStageStatus,
|
|
55
|
+
readSkeletonSettledFiles,
|
|
56
|
+
buildSkeletonCompletionNote
|
|
57
|
+
} from './skeleton-output.js';
|
|
58
|
+
import {
|
|
59
|
+
collectRelevantRelations,
|
|
60
|
+
buildIdToName,
|
|
61
|
+
buildRhythmChapterRefs,
|
|
62
|
+
buildRhythmPrevTail,
|
|
63
|
+
buildRhythmSkeletonOutline
|
|
64
|
+
} from './scripts/stage-contract.js';
|
|
65
|
+
import { runStageBatches, createStageSession, makeShardAdopter } from './stage-driver.js';
|
|
66
|
+
import { phaseScriptFiles } from './deploy-manifest.js';
|
|
67
|
+
import { renderInfraBlock, classifyIssues } from './scripts/error-classes.js';
|
|
68
|
+
import {
|
|
69
|
+
buildSemanticLintPrompt,
|
|
70
|
+
parseLintBlocks,
|
|
71
|
+
reviewLintFindings,
|
|
72
|
+
collectFleshEntities,
|
|
73
|
+
collectSkeletonRelations,
|
|
74
|
+
collectEntityKeys
|
|
75
|
+
} from './scripts/semantic-lint.js';
|
|
76
|
+
import {
|
|
77
|
+
loadWritingContext,
|
|
78
|
+
runWritingStage,
|
|
79
|
+
makeWaitForTurn,
|
|
80
|
+
shardContentChanged,
|
|
81
|
+
chapterFileName,
|
|
82
|
+
WRITING_BATCH_SIZE
|
|
83
|
+
} from './writing-driver.js';
|
|
84
|
+
import {
|
|
85
|
+
deslopDirs,
|
|
86
|
+
listChapterFiles,
|
|
87
|
+
runDeslopStage,
|
|
88
|
+
renderChapterReport,
|
|
89
|
+
renderOverviewReport,
|
|
90
|
+
writeReport
|
|
91
|
+
} from './deslop-driver.js';
|
|
92
|
+
|
|
93
|
+
export const inject = ['commands', 'userQuestions'];
|
|
94
|
+
|
|
95
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
96
|
+
|
|
97
|
+
// llm-wiki HTTP 调用超时(毫秒)
|
|
98
|
+
const LLM_WIKI_TIMEOUT_MS = 30000;
|
|
99
|
+
|
|
100
|
+
// 血肉阶段单批产物的目标字节上限。
|
|
101
|
+
// 2026-10 tt5 实测:8 个文件一次性派活(真实规模 340KB)在写到最后一个文件时
|
|
102
|
+
// 被 provider 中断(output new_sensitive 1027),且因为全量校验从没跑过,
|
|
103
|
+
// 整个失败对用户完全不可见。改成逐批派活后,每批必须远小于单回合输出预算。
|
|
104
|
+
const FLESH_BATCH_MAX_BYTES = 40 * 1024;
|
|
105
|
+
|
|
106
|
+
// 骨架阶段单批产物的目标字节上限。
|
|
107
|
+
// 骨架的实体条目很小(id + name + 枚举字段),关系条目也很小,
|
|
108
|
+
// 唯一的大对象是框架.json(整文件型,含 30 条 derivations)。
|
|
109
|
+
const SKELETON_BATCH_MAX_BYTES = 40 * 1024;
|
|
110
|
+
|
|
111
|
+
// 骨架关系+层级段:每生成多少个批次做一次中途校验。
|
|
112
|
+
//
|
|
113
|
+
// 为什么需要(tt5 实测):那一段有 27+4=31 个批次,阶段级校验要**全部写完**才跑。
|
|
114
|
+
// 第 8 批(faction_relations)写错行数 → 要等 23 批都写完才发现 → 整段返工。
|
|
115
|
+
// 而模型在没有反馈通道的那 31 轮里,自己造了 45 个检查脚本在猜校验器的判据
|
|
116
|
+
// (_chk2.mjs … _chk46.mjs),还去读了校验器源码 14 次。
|
|
117
|
+
//
|
|
118
|
+
// 8 是折中:太密会反复合并+起进程(每次校验都是一次 node 启动),
|
|
119
|
+
// 太稀又会把反馈推得太晚。tt5 的错在第 8 批,8 刚好当场抓住。
|
|
120
|
+
const SKELETON_CHECKPOINT_EVERY = 8;
|
|
121
|
+
|
|
122
|
+
// 节奏阶段单批产物的目标字节上限。
|
|
123
|
+
// 节奏的真实体量比血肉还大(tt5 实测 T=800 时全量约 1.5MB:章节规划 20 批 +
|
|
124
|
+
// 时序映射 34 批 + 章节细纲 32 批),逐批派活后单批仍必须远小于单回合输出预算。
|
|
125
|
+
const RHYTHM_BATCH_MAX_BYTES = 40 * 1024;
|
|
126
|
+
|
|
127
|
+
function getProjectRoot(session) {
|
|
128
|
+
// 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
|
|
129
|
+
// 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
|
|
130
|
+
// 此时返回 null,由调用方给出明确报错。
|
|
131
|
+
const cwd = session.header?.cwd;
|
|
132
|
+
return cwd || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 读取 .fancy-deployed 配置
|
|
137
|
+
*
|
|
138
|
+
* 返回 { ok, config, reason }:
|
|
139
|
+
* ok=false 时 reason 区分"文件不存在"与"文件损坏",
|
|
140
|
+
* 避免把损坏的配置文件报成"尚未初始化"(而 bootstrap 又因文件存在拒绝重复 init),
|
|
141
|
+
* 形成无自愈路径的死锁。
|
|
142
|
+
*/
|
|
143
|
+
/**
|
|
144
|
+
* 读 .fancy-deployed 配置。
|
|
145
|
+
*
|
|
146
|
+
* 导出给 fancy-import 用:导入阶段也要读同一份配置(llm-wiki 地址 / token),
|
|
147
|
+
* 各写一份解析的话,配置格式一变就会漏一处。
|
|
148
|
+
*/
|
|
149
|
+
export function readDeployedConfig(projectRoot) {
|
|
150
|
+
const deployedPath = join(projectRoot, '.fancy-deployed');
|
|
151
|
+
if (!existsSync(deployedPath)) {
|
|
152
|
+
return { ok: false, config: null, reason: 'not_found' };
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(readFileSync(deployedPath, 'utf-8'));
|
|
156
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
157
|
+
return { ok: false, config: null, reason: 'corrupt: 顶层不是对象' };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, config: parsed, reason: null };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { ok: false, config: null, reason: 'corrupt: ' + e.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 更新 .fancy-deployed 中的指定字段
|
|
167
|
+
*
|
|
168
|
+
* 采用"临时文件 + rename"做原子写,避免进程被杀/磁盘满时留下半截 JSON。
|
|
169
|
+
*/
|
|
170
|
+
function updateDeployedField(projectRoot, key, value) {
|
|
171
|
+
const deployedPath = join(projectRoot, '.fancy-deployed');
|
|
172
|
+
const res = readDeployedConfig(projectRoot);
|
|
173
|
+
if (!res.ok) return false;
|
|
174
|
+
const config = res.config;
|
|
175
|
+
config[key] = value;
|
|
176
|
+
const tmpPath = deployedPath + '.tmp-' + process.pid;
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
179
|
+
renameSync(tmpPath, deployedPath);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error(`[fancy-story-builder] 更新 .fancy-deployed 失败: ${e.message}`);
|
|
183
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 读取 _novel_topic.json
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 拷贝阶段对应的参考文件到项目根的 references/ 目录
|
|
194
|
+
*
|
|
195
|
+
* 新版设计:references/ 下含分类子目录(骨架指导/血肉指导/节奏指导/写作指导/)
|
|
196
|
+
* 和顶层文件(glossary.md / index.md / formulas-*.json)。
|
|
197
|
+
*
|
|
198
|
+
* 各阶段拷贝:阶段相关的指导分类目录 + 顶层文件(词表/公式)。
|
|
199
|
+
*/
|
|
200
|
+
function copyPhaseReferences(projectRoot, phase) {
|
|
201
|
+
const PLUGIN_REFERENCES_DIR = join(__dirname, 'references');
|
|
202
|
+
const PROJECT_REFERENCES_DIR = join(projectRoot, 'references');
|
|
203
|
+
|
|
204
|
+
// 各阶段对应的指导分类目录
|
|
205
|
+
const PHASE_GUIDE_DIRS = {
|
|
206
|
+
skeleton: ['骨架指导'],
|
|
207
|
+
flesh: ['血肉指导'],
|
|
208
|
+
rhythm: ['节奏指导'],
|
|
209
|
+
writing: ['写作指导'],
|
|
210
|
+
deslop: ['写作指导'],
|
|
211
|
+
analyze: ['骨架指导', '血肉指导', '节奏指导', '写作指导']
|
|
212
|
+
};
|
|
213
|
+
// 各阶段顶层文件(glossary.md 总是需要;formulas 各阶段不同)
|
|
214
|
+
const PHASE_TOP_FILES = {
|
|
215
|
+
skeleton: ['glossary.md', 'formulas-skeleton.json', 'index.md'],
|
|
216
|
+
flesh: ['glossary.md', 'index.md'],
|
|
217
|
+
rhythm: ['glossary.md', 'formulas-rhythm.json', 'index.md'],
|
|
218
|
+
writing: ['glossary.md', 'index.md'],
|
|
219
|
+
deslop: ['glossary.md', 'index.md'],
|
|
220
|
+
analyze: ['glossary.md', 'formulas-skeleton.json', 'formulas-rhythm.json', 'index.md']
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const guideDirs = PHASE_GUIDE_DIRS[phase] || [];
|
|
224
|
+
const topFiles = PHASE_TOP_FILES[phase] || ['glossary.md'];
|
|
225
|
+
|
|
226
|
+
// 确保项目 references 目录存在
|
|
227
|
+
if (!existsSync(PROJECT_REFERENCES_DIR)) {
|
|
228
|
+
try {
|
|
229
|
+
mkdirSync(PROJECT_REFERENCES_DIR, { recursive: true });
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let allOk = true;
|
|
236
|
+
|
|
237
|
+
// 拷贝指导分类目录(递归)
|
|
238
|
+
for (const guideDir of guideDirs) {
|
|
239
|
+
const srcDir = join(PLUGIN_REFERENCES_DIR, guideDir);
|
|
240
|
+
const destDir = join(PROJECT_REFERENCES_DIR, guideDir);
|
|
241
|
+
if (!copyDirRecursive(srcDir, destDir)) allOk = false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 拷贝顶层文件
|
|
245
|
+
for (const f of topFiles) {
|
|
246
|
+
const src = join(PLUGIN_REFERENCES_DIR, f);
|
|
247
|
+
const dest = join(PROJECT_REFERENCES_DIR, f);
|
|
248
|
+
if (existsSync(src)) {
|
|
249
|
+
try {
|
|
250
|
+
copyFileSync(src, dest);
|
|
251
|
+
} catch {
|
|
252
|
+
allOk = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return allOk;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 递归拷贝目录(含子目录)
|
|
262
|
+
*/
|
|
263
|
+
function copyDirRecursive(srcDir, destDir) {
|
|
264
|
+
try {
|
|
265
|
+
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) return false;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
mkdirSync(destDir, { recursive: true });
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
let allOk = true;
|
|
275
|
+
let entries;
|
|
276
|
+
try {
|
|
277
|
+
entries = readdirSync(srcDir);
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
for (const f of entries) {
|
|
282
|
+
const srcPath = join(srcDir, f);
|
|
283
|
+
const destPath = join(destDir, f);
|
|
284
|
+
let st;
|
|
285
|
+
try {
|
|
286
|
+
st = statSync(srcPath);
|
|
287
|
+
} catch {
|
|
288
|
+
// 断链符号链接 / 无权限 / readdir 与 stat 之间的竞态删除
|
|
289
|
+
allOk = false;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (st.isDirectory()) {
|
|
293
|
+
if (!copyDirRecursive(srcPath, destPath)) allOk = false;
|
|
294
|
+
} else if (st.isFile()) {
|
|
295
|
+
try {
|
|
296
|
+
copyFileSync(srcPath, destPath);
|
|
297
|
+
} catch {
|
|
298
|
+
allOk = false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return allOk;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 拷贝阶段对应的校验脚本到项目根的 references/ 目录
|
|
307
|
+
*
|
|
308
|
+
* 清单**不是手写的**:从该阶段的入口校验脚本出发,递归收集它的
|
|
309
|
+
* 同目录 import 闭包(graph-utils / content-quality / skeleton-quota …)。
|
|
310
|
+
*
|
|
311
|
+
* 手写清单的坑:清单里的文件都拷成功了,部署阶段看不出问题,
|
|
312
|
+
* 但少拷一个被 import 的文件,插件跑起来时校验脚本直接
|
|
313
|
+
* ERR_MODULE_NOT_FOUND(真实踩过:抽出 skeleton-quota.js 后
|
|
314
|
+
* 骨架阶段报 "references/skeleton-quota.js is missing")。
|
|
315
|
+
*/
|
|
316
|
+
function copyPhaseScripts(projectRoot, phase) {
|
|
317
|
+
const PROJECT_REFERENCES_DIR = join(projectRoot, 'references');
|
|
318
|
+
const PLUGIN_SCRIPTS_DIR = join(__dirname, 'scripts');
|
|
319
|
+
|
|
320
|
+
// 该阶段不需要校验脚本(analyze / wiki)→ 无事可做,不算失败
|
|
321
|
+
const manifest = phaseScriptFiles(
|
|
322
|
+
phase,
|
|
323
|
+
PLUGIN_SCRIPTS_DIR,
|
|
324
|
+
join(__dirname, 'prompts'),
|
|
325
|
+
join(__dirname, 'references')
|
|
326
|
+
);
|
|
327
|
+
if (!manifest) return true;
|
|
328
|
+
|
|
329
|
+
// import 闭包里引了但磁盘上找不到的文件:这种清单不可能跑通,
|
|
330
|
+
// 直接判失败,别等运行时才炸。
|
|
331
|
+
if (manifest.missing.length) {
|
|
332
|
+
console.error(
|
|
333
|
+
`[fancy-story-builder] 阶段 ${phase} 的校验脚本依赖缺失:${manifest.missing.join('、')}`
|
|
334
|
+
);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 确保 references 目录存在
|
|
339
|
+
if (!existsSync(PROJECT_REFERENCES_DIR)) {
|
|
340
|
+
try {
|
|
341
|
+
mkdirSync(PROJECT_REFERENCES_DIR, { recursive: true });
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let allOk = true;
|
|
348
|
+
for (const scriptFile of manifest.files) {
|
|
349
|
+
const src = join(PLUGIN_SCRIPTS_DIR, scriptFile);
|
|
350
|
+
if (!existsSync(src)) {
|
|
351
|
+
allOk = false;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const dest = join(PROJECT_REFERENCES_DIR, scriptFile);
|
|
355
|
+
try {
|
|
356
|
+
copyFileSync(src, dest);
|
|
357
|
+
} catch {
|
|
358
|
+
allOk = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return allOk;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 拷贝阶段对应的模板文件到项目根的 模板/ 目录
|
|
367
|
+
*
|
|
368
|
+
* 新版设计:templates/ 下含分类子目录(骨架/血肉/节奏/),每个子目录含阶段模板。
|
|
369
|
+
* 每个阶段拷贝对应分类目录的所有模板到项目的 模板/<分类>/。
|
|
370
|
+
*/
|
|
371
|
+
function copyPhaseTemplates(projectRoot, phase) {
|
|
372
|
+
const PLUGIN_TEMPLATES_DIR = join(__dirname, 'templates');
|
|
373
|
+
const PROJECT_TEMPLATES_DIR = join(projectRoot, '模板');
|
|
374
|
+
|
|
375
|
+
// 各阶段对应的模板分类目录
|
|
376
|
+
const PHASE_TEMPLATE_DIRS = {
|
|
377
|
+
skeleton: '骨架',
|
|
378
|
+
flesh: '血肉',
|
|
379
|
+
rhythm: '节奏'
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const templateSubDir = PHASE_TEMPLATE_DIRS[phase];
|
|
383
|
+
// 该阶段不需要模板(writing / analyze / wiki)→ 无事可做,不算失败
|
|
384
|
+
if (!templateSubDir) return true;
|
|
385
|
+
|
|
386
|
+
const srcDir = join(PLUGIN_TEMPLATES_DIR, templateSubDir);
|
|
387
|
+
const destDir = join(PROJECT_TEMPLATES_DIR, templateSubDir);
|
|
388
|
+
|
|
389
|
+
if (!existsSync(srcDir)) return false;
|
|
390
|
+
|
|
391
|
+
// 确保项目模板分类目录存在
|
|
392
|
+
if (!existsSync(destDir)) {
|
|
393
|
+
try {
|
|
394
|
+
mkdirSync(destDir, { recursive: true });
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return copyDirRecursive(srcDir, destDir);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 把 schema-setting.md / purpose-template.md 拷进"设定"目录。
|
|
405
|
+
*
|
|
406
|
+
* "设定"目录本身**由 llm-wiki 创建**,不由插件创建。理由(llm-wiki 源码核实):
|
|
407
|
+
* - commands::project::create_project_impl 只在目录**不存在**时才会建
|
|
408
|
+
* raw/sources、raw/assets,并写 schema.md、wiki/index.md、wiki/log.md;
|
|
409
|
+
* - 目录若被抢先创建,handle_create_project 会走"目录已存在"分支,
|
|
410
|
+
* 以上全部不建 —— 项目就残缺了。
|
|
411
|
+
* 所以插件在调用 /projects/create 之前绝不能自己 mkdir 设定/。
|
|
412
|
+
*
|
|
413
|
+
* 本函数只做两件事:
|
|
414
|
+
* 1. 确保 设定/raw/sources 存在(这是三个阶段产物的落点;
|
|
415
|
+
* llm-wiki 走"目录已存在"分支时不会建它);
|
|
416
|
+
* 2. 拷 schema.md / purpose.md(覆盖 llm-wiki 写的默认 schema.md,这是有意的)。
|
|
417
|
+
*
|
|
418
|
+
* @param {string} projectRoot 项目根目录
|
|
419
|
+
* @param {{includePurpose?: boolean}} [opts] includePurpose=false 时只拷 schema.md
|
|
420
|
+
* (wiki 阶段只认 schema.md,不需要 purpose.md)
|
|
421
|
+
* @returns {{ ok: boolean, reason: string|null, detail: string|null }}
|
|
422
|
+
*/
|
|
423
|
+
async function copySchemaToProject(projectRoot, opts = {}) {
|
|
424
|
+
const includePurpose = opts.includePurpose !== false;
|
|
425
|
+
const pluginDir = __dirname;
|
|
426
|
+
const schemaSrc = join(pluginDir, 'schema-setting.md');
|
|
427
|
+
const purposeSrc = join(pluginDir, 'purpose-template.md');
|
|
428
|
+
|
|
429
|
+
const schemaDestDir = join(projectRoot, '设定');
|
|
430
|
+
const schemaDest = join(schemaDestDir, 'schema.md');
|
|
431
|
+
const purposeDest = join(schemaDestDir, 'purpose.md');
|
|
432
|
+
const sourcesDir = join(schemaDestDir, 'raw', 'sources');
|
|
433
|
+
|
|
434
|
+
// "设定"目录应当已由 llm-wiki 建好。缺失说明 llm-wiki 那一步没走通,
|
|
435
|
+
// 此时插件自己补建会让 llm-wiki 的项目结构残缺,所以只报告不补建。
|
|
436
|
+
if (!existsSync(schemaDestDir)) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false, reason: 'setting_dir_missing',
|
|
439
|
+
detail: '设定目录不存在(应由 llm-wiki 的 /projects/create 创建)'
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 产物落点必须存在
|
|
444
|
+
try {
|
|
445
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
446
|
+
} catch (e) {
|
|
447
|
+
return { ok: false, reason: 'mkdir_failed', detail: '创建 ' + sourcesDir + ' 失败:' + e.message };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 拷贝 schema-setting.md
|
|
451
|
+
let copied = 0;
|
|
452
|
+
if (existsSync(schemaSrc)) {
|
|
453
|
+
try {
|
|
454
|
+
copyFileSync(schemaSrc, schemaDest);
|
|
455
|
+
copied++;
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return { ok: false, reason: 'copy_failed', detail: '拷贝 schema.md 失败:' + e.message };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 拷贝 purpose-template.md 并重命名为 purpose.md
|
|
462
|
+
if (includePurpose && existsSync(purposeSrc)) {
|
|
463
|
+
try {
|
|
464
|
+
copyFileSync(purposeSrc, purposeDest);
|
|
465
|
+
copied++;
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return { ok: false, reason: 'copy_failed', detail: '拷贝 purpose.md 失败:' + e.message };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { ok: true, reason: null, detail: copied > 0 ? null : '插件目录下没有 schema-setting.md / purpose-template.md' };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function readNovelTopic(projectRoot) {
|
|
475
|
+
const topicPath = join(projectRoot, '_novel_topic.json');
|
|
476
|
+
if (!existsSync(topicPath)) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
let parsed;
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(readFileSync(topicPath, 'utf-8'));
|
|
482
|
+
} catch (e) {
|
|
483
|
+
// 解析失败与"文件不存在"要能区分:前者是文件损坏,错误提示不能误导成"未选题"
|
|
484
|
+
console.error(`[fancy-story-builder] _novel_topic.json 解析失败: ${e.message}`);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
// 必须是对象;数组/字符串/数字会让下游的字段解构拿到 undefined
|
|
488
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
489
|
+
console.error('[fancy-story-builder] _novel_topic.json 顶层必须是对象');
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return parsed;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 调用 llm_wiki API
|
|
497
|
+
*/
|
|
498
|
+
async function callLlmWikiApi(baseUrl, token, path, method = 'POST', body = null, signal = null) {
|
|
499
|
+
const url = `${baseUrl}${path}`;
|
|
500
|
+
const headers = {
|
|
501
|
+
'Content-Type': 'application/json',
|
|
502
|
+
'Authorization': `Bearer ${token}`,
|
|
503
|
+
};
|
|
504
|
+
const options = { method, headers };
|
|
505
|
+
if (body) {
|
|
506
|
+
options.body = JSON.stringify(body);
|
|
507
|
+
}
|
|
508
|
+
// 超时保护:否则 llm-wiki 挂起时命令会一直挂到 Node fetch 的默认超时。
|
|
509
|
+
const timeout = AbortSignal.timeout(LLM_WIKI_TIMEOUT_MS);
|
|
510
|
+
options.signal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
511
|
+
const response = await fetch(url, options);
|
|
512
|
+
// 响应体可能不是 JSON(例如网关返回 HTML 错误页)——
|
|
513
|
+
// 直接 response.json() 会抛 SyntaxError,把真正的 HTTP 状态盖掉,
|
|
514
|
+
// 用户看到的是 "Unexpected token '<'" 这种看不懂的东西。
|
|
515
|
+
const data = await readJsonSafe(response);
|
|
516
|
+
return { status: response.status, data };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 确保 setup_id 存在。
|
|
521
|
+
* 返回 { id, reused }:reused=true 表示从 llm-wiki 本地缓存复用(提示用户)
|
|
522
|
+
*/
|
|
523
|
+
function readExistingProjectId(projectRoot) {
|
|
524
|
+
const cachePaths = [
|
|
525
|
+
join(projectRoot, '设定', '.llm-wiki', 'project.json'),
|
|
526
|
+
join(projectRoot, '.llm-wiki', 'project.json')
|
|
527
|
+
];
|
|
528
|
+
for (const p of cachePaths) {
|
|
529
|
+
if (existsSync(p)) {
|
|
530
|
+
try {
|
|
531
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
532
|
+
if (data && typeof data.id === 'string' && data.id.length > 0) {
|
|
533
|
+
return data.id;
|
|
534
|
+
}
|
|
535
|
+
} catch { /* ignore */ }
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return '';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 确保"设定"目录在 llm-wiki 侧是一个真实存在的项目。返回 { id, reused, source, reason, detail }:
|
|
543
|
+
* - id: setup_id;空字符串表示拿不到
|
|
544
|
+
* - reused: true 表示复用了已有的 id
|
|
545
|
+
* - source: 'cache'(llm-wiki 写的 设定/.llm-wiki/project.json)/ 'created'(本次新建)
|
|
546
|
+
* - reason/detail: 拿不到时的可诊断分类与具体原因
|
|
547
|
+
*
|
|
548
|
+
* 为什么这一步对四个阶段都是硬前提(骨架/血肉/节奏/写作):
|
|
549
|
+
* "设定"目录必须被 llm-wiki 登记成项目,llm-wiki 侧才认它。
|
|
550
|
+
* 核实依据(llm-wiki 源码 api_server.rs):
|
|
551
|
+
* - handle_create_project 会写 <root>/.llm-wiki/project.json(存 id)
|
|
552
|
+
* 并调 upsert_project_in_registry 登记进 app-state.json 的 projectRegistry;
|
|
553
|
+
* - resolve_project → load_projects **只从 projectRegistry 枚举项目**,
|
|
554
|
+
* 没登记过的目录一律 "Unknown project"。
|
|
555
|
+
* 而三个阶段的全部产物都会被 llm-wiki ingest 纳管(实测对照项目:
|
|
556
|
+
* 骨架 7 个 + 血肉 13 个 + 节奏 55 个文件全部进了 ingest-cache)。
|
|
557
|
+
* 所以设定项目没建成 → 产物进不了 llm-wiki 知识库 → 后续 wiki / analyze 必然失败。
|
|
558
|
+
* 这种情况下"降级继续"是假成功,不做。
|
|
559
|
+
*
|
|
560
|
+
* 权威判据是 llm-wiki 自己写的 设定/.llm-wiki/project.json:
|
|
561
|
+
* 它存在 = llm-wiki 确实把这个目录建成了项目(且 id 与 llm-wiki 内部一致)。
|
|
562
|
+
* .fancy-deployed 里的 setup_id 只是副本,可能指向已被删除/从未建成的项目
|
|
563
|
+
* (实测用户项目 tt3 就是这样:setup_id 有,设定/ 目录整个不存在)。
|
|
564
|
+
*
|
|
565
|
+
* 本函数不抛异常:失败时返回可诊断的 reason/detail,由调用方报给用户。
|
|
566
|
+
*/
|
|
567
|
+
/**
|
|
568
|
+
* 确保 llm-wiki 里有"设定"项目(返回 setup_id)。
|
|
569
|
+
*
|
|
570
|
+
* 导出给 fancy-import 用:导入阶段也要往 `设定/raw/sources/` 里写产物,
|
|
571
|
+
* 那个目录必须先是 llm-wiki 的项目,产物才会被 ingest 纳管。
|
|
572
|
+
* 各写一份的话,llm-wiki 的建项目流程("目录不存在时才建")改一处就会漏一处。
|
|
573
|
+
*/
|
|
574
|
+
export async function ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal = null) {
|
|
575
|
+
// 1) llm-wiki 自己写的 project.json —— 权威判据
|
|
576
|
+
const cachedId = readExistingProjectId(projectRoot);
|
|
577
|
+
if (cachedId) {
|
|
578
|
+
// 与 .fancy-deployed 的记录保持一致(以 llm-wiki 的为准)
|
|
579
|
+
const storedId = deployedConfig && typeof deployedConfig.setup_id === 'string' ? deployedConfig.setup_id.trim() : '';
|
|
580
|
+
if (cachedId !== storedId) {
|
|
581
|
+
updateDeployedField(projectRoot, 'setup_id', cachedId);
|
|
582
|
+
}
|
|
583
|
+
return { id: cachedId, reused: true, source: 'cache', reason: null, detail: null };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// 2) 还没有 project.json —— 让 llm-wiki 建项目。
|
|
587
|
+
// 注意:调用前**不要**自己先建 设定/ 目录。
|
|
588
|
+
// llm-wiki 的 create_project_impl 只在目录不存在时才会建
|
|
589
|
+
// raw/sources、raw/assets 并写 wiki/index.md、wiki/log.md;
|
|
590
|
+
// 目录若已被抢先创建,它会走"目录已存在"分支,以上全部不建。
|
|
591
|
+
try {
|
|
592
|
+
const result = await callLlmWikiApi(
|
|
593
|
+
llmWikiUrl,
|
|
594
|
+
llmWikiToken,
|
|
595
|
+
'/projects/create',
|
|
596
|
+
'POST',
|
|
597
|
+
{
|
|
598
|
+
name: '设定',
|
|
599
|
+
path: projectRoot,
|
|
600
|
+
},
|
|
601
|
+
signal
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
if (result.status === 401 || result.status === 403) {
|
|
605
|
+
return {
|
|
606
|
+
id: '', reused: false, source: null, reason: 'unauthorized',
|
|
607
|
+
detail: 'llm-wiki 拒绝了请求(HTTP ' + result.status + '):服务端没有配置 API token,或本项目 .fancy-deployed 里的 llm_wiki_token 与它不一致'
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (result.status >= 400) {
|
|
612
|
+
const msg = (result.data && (result.data.error || result.data.message)) || ('HTTP ' + result.status);
|
|
613
|
+
return { id: '', reused: false, source: null, reason: 'api_error', detail: String(msg) };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (result.data && result.data.error) {
|
|
617
|
+
return { id: '', reused: false, source: null, reason: 'api_error', detail: String(result.data.error) };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (result.data && result.data.ok === true && result.data.project && result.data.project.id) {
|
|
621
|
+
const setupId = result.data.project.id;
|
|
622
|
+
updateDeployedField(projectRoot, 'setup_id', setupId);
|
|
623
|
+
return { id: setupId, reused: false, source: 'created', reason: null, detail: null };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
id: '', reused: false, source: null, reason: 'unexpected_response',
|
|
628
|
+
detail: 'llm-wiki 返回了非预期结构(缺少 ok / project.id)'
|
|
629
|
+
};
|
|
630
|
+
} catch (err) {
|
|
631
|
+
return {
|
|
632
|
+
id: '', reused: false, source: null, reason: 'network',
|
|
633
|
+
// Node 的 fetch 失败只抛一句 "fetch failed",真正的原因在 err.cause 里。
|
|
634
|
+
// 不翻译的话用户既不知道是服务没启动、地址写错,还是超时。
|
|
635
|
+
detail: describeFetchError(err, LLM_WIKI_TIMEOUT_MS)
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function apply(ctx) {
|
|
641
|
+
ctx.commands.register({
|
|
642
|
+
name: 'fancy-story-builder',
|
|
643
|
+
description: '📖 故事构建 ( usage: /fancy-story-builder skeleton | flesh | rhythm | writing | deslop | wiki | analyze )',
|
|
644
|
+
input: {
|
|
645
|
+
hint: '.',
|
|
646
|
+
},
|
|
647
|
+
handler: async (invocation) => {
|
|
648
|
+
const session = invocation.agent.session;
|
|
649
|
+
const { signal } = invocation;
|
|
650
|
+
|
|
651
|
+
const projectRoot = getProjectRoot(session);
|
|
652
|
+
if (!projectRoot) {
|
|
653
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
654
|
+
type: 'no_project_root',
|
|
655
|
+
err: '无法确定项目目录(会话未提供 cwd)。请在项目目录下运行该命令。'
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// 1. 检查 .fancy-deployed
|
|
660
|
+
if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
|
|
661
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'not_initialized' }));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 2. 读取配置
|
|
665
|
+
const deployedRes = readDeployedConfig(projectRoot);
|
|
666
|
+
if (!deployedRes.ok) {
|
|
667
|
+
if (deployedRes.reason === 'not_found') {
|
|
668
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'not_initialized' }));
|
|
669
|
+
}
|
|
670
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
671
|
+
type: 'config_corrupt',
|
|
672
|
+
err: '.fancy-deployed 解析失败(' + deployedRes.reason + ')。请手工修复或删除该文件后重新运行 /fancy-bootstrap。'
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
const deployedConfig = deployedRes.config;
|
|
676
|
+
if (!deployedConfig) {
|
|
677
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'not_initialized' }));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 3. 检查 llm_wiki 配置
|
|
681
|
+
const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
|
|
682
|
+
const llmWikiToken = deployedConfig.llm_wiki_token || '';
|
|
683
|
+
|
|
684
|
+
if (!llmWikiUrl || !llmWikiToken) {
|
|
685
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'llm_wiki_not_configured' }));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 4. 读取选题信息
|
|
689
|
+
const topicData = readNovelTopic(projectRoot);
|
|
690
|
+
if (!topicData) {
|
|
691
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_topic' }));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 5. 解析子命令
|
|
695
|
+
const rawInput = (invocation.rawInput || '').trim().toLowerCase();
|
|
696
|
+
const subcommand = rawInput.split(/\s+/)[0];
|
|
697
|
+
|
|
698
|
+
if (!subcommand) {
|
|
699
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_subcommand' }));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// 5.1 校验子命令合法性:非法子命令不应该往项目目录里写文件
|
|
703
|
+
const VALID_SUBCOMMANDS = ['wiki', 'skeleton', 'flesh', 'rhythm', 'analyze', 'writing', 'deslop'];
|
|
704
|
+
if (!VALID_SUBCOMMANDS.includes(subcommand)) {
|
|
705
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_subcommand' }));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 6. 拷贝阶段对应的参考文件到项目根的 references/ 目录
|
|
709
|
+
// 三个拷贝函数的返回值必须检查:失败时模板/参考文件没部署,
|
|
710
|
+
// 但 prompt 仍会指示 LLM 去读这些不存在的文件,产出垃圾内容。
|
|
711
|
+
const refsOk = copyPhaseReferences(projectRoot, subcommand);
|
|
712
|
+
const scriptsOk = copyPhaseScripts(projectRoot, subcommand);
|
|
713
|
+
const tplOk = copyPhaseTemplates(projectRoot, subcommand);
|
|
714
|
+
if (!refsOk || !scriptsOk || !tplOk) {
|
|
715
|
+
const failed = [];
|
|
716
|
+
if (!refsOk) failed.push('references/');
|
|
717
|
+
if (!scriptsOk) failed.push('校验脚本');
|
|
718
|
+
if (!tplOk) failed.push('模板/');
|
|
719
|
+
console.error(`[fancy-story-builder] 部署失败:${failed.join('、')}`);
|
|
720
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
721
|
+
type: 'deploy_failed',
|
|
722
|
+
err: '以下文件部署失败:' + failed.join('、') + '(请检查磁盘空间与目录权限)'
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 9. 获取 wiki 参考(根据阶段筛选目录)
|
|
727
|
+
const wikiReferences = readWikiReferences(projectRoot, subcommand);
|
|
728
|
+
|
|
729
|
+
// 9. 分发到对应处理函数
|
|
730
|
+
switch (subcommand) {
|
|
731
|
+
case 'wiki':
|
|
732
|
+
return handleWiki(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken);
|
|
733
|
+
|
|
734
|
+
case 'skeleton':
|
|
735
|
+
return handleSkeleton(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences);
|
|
736
|
+
|
|
737
|
+
case 'flesh':
|
|
738
|
+
// --semantic:血肉产出后再跑一次 LLM 语义检查(对应 llm_wiki 的 runSemanticLint)
|
|
739
|
+
return handleFlesh(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences, {
|
|
740
|
+
semantic: /\s--semantic(\s|$)/.test(rawInput)
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
case 'rhythm':
|
|
744
|
+
return handleRhythm(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences);
|
|
745
|
+
|
|
746
|
+
case 'analyze':
|
|
747
|
+
return handleAnalyze(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences);
|
|
748
|
+
|
|
749
|
+
case 'writing':
|
|
750
|
+
return handleWriting(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences);
|
|
751
|
+
|
|
752
|
+
case 'deslop':
|
|
753
|
+
return handleDeslop(ctx, session, invocation, projectRoot, deployedConfig, topicData);
|
|
754
|
+
|
|
755
|
+
default:
|
|
756
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_subcommand' }));
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 处理骨架阶段
|
|
764
|
+
*/
|
|
765
|
+
/**
|
|
766
|
+
* 处理骨架阶段(插件驱动版)
|
|
767
|
+
*
|
|
768
|
+
* 骨架是第一个阶段,没有上游可读,但**内部有硬依赖**:
|
|
769
|
+
*
|
|
770
|
+
* 1. 实体写几条,由框架的 quota 决定(校验器逐类核对,QUOTA_MISMATCH)
|
|
771
|
+
* 2. 关系里填的 id,只能来自实体清单(SKELETON_REF_MISSING)
|
|
772
|
+
* 3. 层级里填的卷 id / 主线 id / 支线 id,分别来自框架与实体清单
|
|
773
|
+
*
|
|
774
|
+
* 所以骨架分三段驱动:
|
|
775
|
+
* 段一 框架 —— 整文件型(1 批),定 quota / volume_ratio / derivations
|
|
776
|
+
* 段二 实体 —— 12 个数组,每类条数按段一的 quota 写
|
|
777
|
+
* 段三 关系+层级 —— 关系与层级引用段二的 id;用**全量校验**判
|
|
778
|
+
* (引用完整性、图连通性、配额一致性都要跨文件才能查)
|
|
779
|
+
*
|
|
780
|
+
* 校验粒度:前两段用单文件校验(模板结构 / 枚举 / id 唯一 / derivations 完整性),
|
|
781
|
+
* 最后一段用全量 --all。
|
|
782
|
+
*/
|
|
783
|
+
async function handleSkeleton(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences) {
|
|
784
|
+
// 参考分层说明(插件指导 vs 对标样本)+ 本阶段可读的对标文件清单
|
|
785
|
+
const benchmarkRefs = renderBenchmarkGuidance(projectRoot, 'skeleton');
|
|
786
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
787
|
+
const { signal } = invocation;
|
|
788
|
+
// 确保 setup_id 存在(幂等调用)
|
|
789
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
790
|
+
const setupId = setupResult.id;
|
|
791
|
+
// 说明文字要并入最终返回的消息:notify 的中间返回值会被丢弃,
|
|
792
|
+
// 单独发一条 info 用户根本看不到。
|
|
793
|
+
let setupNote = '';
|
|
794
|
+
if (!setupId) {
|
|
795
|
+
// "设定"目录必须先是 llm-wiki 里的项目,本阶段的产物才会被 ingest 纳管
|
|
796
|
+
// (实测:骨架 7 + 血肉 13 + 节奏 55 个文件全部进了 llm-wiki 的 ingest-cache)。
|
|
797
|
+
// 拿不到 setup_id = 设定项目没建成 → 继续跑只会产出不被纳管的半成品,
|
|
798
|
+
// 而且后续 wiki / analyze 阶段必然失败。必须硬失败。
|
|
799
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
800
|
+
type: 'setup_id_unavailable',
|
|
801
|
+
err: setupResult.reason,
|
|
802
|
+
detail: setupResult.detail
|
|
803
|
+
}));
|
|
804
|
+
}
|
|
805
|
+
if (setupResult.reused) {
|
|
806
|
+
setupNote = storyBuilder.notify({ type: 'setup_id_reused', id: setupId, source: setupResult.source });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// 准备"设定"目录并拷贝 schema 文件。
|
|
810
|
+
// 拷失败必须**硬失败**:设定目录不存在时,后面的分片目录(设定/raw/sources/…)
|
|
811
|
+
// 会把"设定"目录自己建出来 —— 而这个目录本该由 llm-wiki 的 /projects/create 建,
|
|
812
|
+
// 插件抢建会让 llm-wiki 以为项目已存在,不再补 raw/assets、wiki/index.md,
|
|
813
|
+
// 项目就残缺了(llm-wiki 侧 ingest 会缺文件)。
|
|
814
|
+
const schemaCopy = await copySchemaToProject(projectRoot);
|
|
815
|
+
if (!schemaCopy.ok) {
|
|
816
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
817
|
+
type: 'schema_copy_failed',
|
|
818
|
+
detail: schemaCopy.detail
|
|
819
|
+
}));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const stageDir = join(projectRoot, '设定', 'raw', 'sources', '骨架');
|
|
823
|
+
const buildDir = join(stageDir, SKELETON_SHARD_DIR_NAME);
|
|
824
|
+
try {
|
|
825
|
+
mkdirSync(buildDir, { recursive: true });
|
|
826
|
+
} catch (e) {
|
|
827
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '建分片目录失败:' + e.message }));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const validator = join(projectRoot, 'references', 'skeleton-validate.js');
|
|
831
|
+
const projectTemplateDir = join(projectRoot, '模板', '骨架');
|
|
832
|
+
const pluginTemplateDir = join(__dirname, 'templates', '骨架');
|
|
833
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
834
|
+
timeoutMs: 60 * 60 * 1000,
|
|
835
|
+
idleGraceMs: 60000,
|
|
836
|
+
signal,
|
|
837
|
+
// 重投场景里分片文件还在(不能删 —— 删了模型 write 会被 DSH 的版本守卫拦死),
|
|
838
|
+
// 完成判据必须看内容有没有变。
|
|
839
|
+
changed: shardContentChanged
|
|
840
|
+
});
|
|
841
|
+
const canWait = invocation && invocation.agent && typeof invocation.agent.status === 'string';
|
|
842
|
+
const progressNotes = [];
|
|
843
|
+
|
|
844
|
+
// 目标章节:数量公式的输入 T(骨架阶段没有上游快照,直接用选题)
|
|
845
|
+
const targetChapters = Number(topicData && topicData.target_chapters) || 0;
|
|
846
|
+
|
|
847
|
+
// 批次规划:**分段规划**,因为规划本身也有依赖:
|
|
848
|
+
// 实体批次的规模来自框架的 quota(段一产出后才有)
|
|
849
|
+
// 关系批次的 id 表来自实体清单(段二产出后才有)
|
|
850
|
+
// 所以只先规划框架那一段,后两段等上一段落盘后再规划。
|
|
851
|
+
const planBase = {
|
|
852
|
+
skeletonDir: stageDir,
|
|
853
|
+
templateDir: pluginTemplateDir,
|
|
854
|
+
buildDir: buildDir,
|
|
855
|
+
chapters: targetChapters,
|
|
856
|
+
maxBytes: SKELETON_BATCH_MAX_BYTES
|
|
857
|
+
};
|
|
858
|
+
const planA = planSkeletonBatches(Object.assign({}, planBase, { only: ['框架'] }));
|
|
859
|
+
if (!planA.ok) {
|
|
860
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '骨架批次规划失败:' + planA.error }));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// 批次 key -> 所属文件
|
|
864
|
+
const fileByBatchKey = new Map();
|
|
865
|
+
const knownShards = [];
|
|
866
|
+
const rememberPlan = (plan) => {
|
|
867
|
+
for (const f of plan.files) {
|
|
868
|
+
for (const b of f.batches) {
|
|
869
|
+
fileByBatchKey.set(b.key, f);
|
|
870
|
+
knownShards.push(b.shard);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
rememberPlan(planA);
|
|
875
|
+
|
|
876
|
+
// ---- 提示词构建(三段共用) ----
|
|
877
|
+
const buildBatchPrompt = (batch, attempt, failures) => buildSkeletonBatchPrompt({
|
|
878
|
+
batch: batch,
|
|
879
|
+
attempt: attempt,
|
|
880
|
+
failures: failures,
|
|
881
|
+
stageDir: stageDir,
|
|
882
|
+
topicData: topicData,
|
|
883
|
+
benchmarkRefs: benchmarkRefs
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// ---- 驱动(三段共用同一会话,批次状态跨段保留) ----
|
|
887
|
+
const stageSession = createStageSession();
|
|
888
|
+
const driverCommon = {
|
|
889
|
+
session: stageSession,
|
|
890
|
+
buildPrompt: buildBatchPrompt,
|
|
891
|
+
sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
|
|
892
|
+
waitIdle: async (files, before) => waitForTurn(files, before),
|
|
893
|
+
readShard: (batch) => readShardFile(batch.shardPath),
|
|
894
|
+
adoptShard: makeShardAdopter(buildDir, knownShards),
|
|
895
|
+
// 模型这轮没动分片时(NO_CHANGE),由**脚本**判这个分片是不是本来就对:
|
|
896
|
+
// 局部口径只判这一批涉及的数组,不查跨文件一致性。
|
|
897
|
+
validateBatch: async (batch) => runSkeletonValidatorPartial({
|
|
898
|
+
validator: validator,
|
|
899
|
+
input: batch.shardPath,
|
|
900
|
+
template: join(projectTemplateDir, batch.template),
|
|
901
|
+
fileType: batch.type,
|
|
902
|
+
upstream: stageDir,
|
|
903
|
+
arrays: batch.arrays || [],
|
|
904
|
+
projectRoot: projectRoot
|
|
905
|
+
}),
|
|
906
|
+
applyFile: async (file) => mergeFleshShards({
|
|
907
|
+
stageDir: stageDir,
|
|
908
|
+
buildDir: buildDir,
|
|
909
|
+
batches: file.batches
|
|
910
|
+
}),
|
|
911
|
+
attribute: (errors, layout) => attributeErrors(errors, layout, {
|
|
912
|
+
pathKeys: skeletonPathKey
|
|
913
|
+
}),
|
|
914
|
+
onProgress: (msg) => { progressNotes.push(msg); }
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const phaseReports = [];
|
|
918
|
+
// planB / planC 要提到外层:批次统计要用它们
|
|
919
|
+
// (而且它们是**分段规划**出来的 —— 段二要等框架落盘,段三要等实体落盘)
|
|
920
|
+
let planB = null;
|
|
921
|
+
let planC = null;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* 造一个"用分片把已定稿产物覆盖回去"的恢复器。
|
|
925
|
+
*
|
|
926
|
+
* 分片是真源(产物 = 分片合并出来的),所以拿分片覆盖磁盘上的产物,
|
|
927
|
+
* 模型的越权修改就自动失效了。
|
|
928
|
+
*
|
|
929
|
+
* @param {Array} plans 已定稿的阶段规划(每个含 files)
|
|
930
|
+
* @returns {() => Promise<string[]>} 返回"确实被改动过、已覆盖回去"的文件名
|
|
931
|
+
*/
|
|
932
|
+
const makeSettledRestorer = (plans) => async () => {
|
|
933
|
+
const restored = [];
|
|
934
|
+
for (const plan of plans || []) {
|
|
935
|
+
for (const f of (plan && plan.files) || []) {
|
|
936
|
+
if (!f || !f.batches || !f.batches.length) continue;
|
|
937
|
+
const target = join(stageDir, f.file);
|
|
938
|
+
let before = null;
|
|
939
|
+
try {
|
|
940
|
+
before = existsSync(target) ? readFileSync(target, 'utf-8') : null;
|
|
941
|
+
} catch (e) {
|
|
942
|
+
before = null;
|
|
943
|
+
}
|
|
944
|
+
const r = await mergeFleshShards({
|
|
945
|
+
stageDir: stageDir,
|
|
946
|
+
buildDir: buildDir,
|
|
947
|
+
batches: f.batches,
|
|
948
|
+
allowMissing: false,
|
|
949
|
+
outPath: target
|
|
950
|
+
});
|
|
951
|
+
if (!r || r.ok !== true) continue;
|
|
952
|
+
let after = null;
|
|
953
|
+
try {
|
|
954
|
+
after = existsSync(target) ? readFileSync(target, 'utf-8') : null;
|
|
955
|
+
} catch (e) {
|
|
956
|
+
after = null;
|
|
957
|
+
}
|
|
958
|
+
if (before !== null && after !== null && before !== after) restored.push(f.file);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return restored;
|
|
962
|
+
};
|
|
963
|
+
let report = { ok: false, error: null, files: [], failedFiles: [], errors: [], warnings: [] };
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
if (!canWait) {
|
|
967
|
+
report = {
|
|
968
|
+
ok: false,
|
|
969
|
+
error: '当前运行时没有暴露 agent,无法逐批派活骨架阶段(派活需要能等待每一轮结束)',
|
|
970
|
+
files: [],
|
|
971
|
+
failedFiles: [],
|
|
972
|
+
errors: [],
|
|
973
|
+
warnings: []
|
|
974
|
+
};
|
|
975
|
+
} else {
|
|
976
|
+
// ---- 段一:框架 ----
|
|
977
|
+
// 已定稿的文件(单文件校验已通过、之后没被改过)不派活重做:
|
|
978
|
+
// 直接沿用磁盘上的产物 —— 段二的批次规划要读它的 quota。
|
|
979
|
+
// tt5 实测(2026-10-25):骨架一次要派 40+ 批,中途任何一次失败都会
|
|
980
|
+
// 让框架 + 实体这 13 个 turn 白烧额度重做一遍。
|
|
981
|
+
const settledFiles = readSkeletonSettledFiles(stageDir, { buildDir: buildDir });
|
|
982
|
+
let rA;
|
|
983
|
+
if (settledFiles.has('框架.json')) {
|
|
984
|
+
rA = skippedFilesReport(['框架.json']);
|
|
985
|
+
progressNotes.push('框架.json 已定稿(单文件校验已通过、之后未改动),本轮跳过重做');
|
|
986
|
+
} else {
|
|
987
|
+
rA = await runStageBatches(Object.assign({}, driverCommon, {
|
|
988
|
+
files: planA.files,
|
|
989
|
+
validateScope: 'stage',
|
|
990
|
+
validateStage: (files) => validateSkeletonFiles(files, {
|
|
991
|
+
validator: validator,
|
|
992
|
+
templateDir: projectTemplateDir,
|
|
993
|
+
stageDir: stageDir,
|
|
994
|
+
projectRoot: projectRoot
|
|
995
|
+
})
|
|
996
|
+
}));
|
|
997
|
+
}
|
|
998
|
+
phaseReports.push(rA);
|
|
999
|
+
|
|
1000
|
+
// ---- 段二:实体(条数按段一的 quota 写;规划也要等段一落盘) ----
|
|
1001
|
+
let rB = null;
|
|
1002
|
+
if (phaseFileOk(rA, '框架.json')) {
|
|
1003
|
+
planB = planSkeletonBatches(Object.assign({}, planBase, { only: ['实体'] }));
|
|
1004
|
+
if (!planB.ok) {
|
|
1005
|
+
rB = {
|
|
1006
|
+
ok: false,
|
|
1007
|
+
error: '实体批次规划失败:' + planB.error,
|
|
1008
|
+
files: [],
|
|
1009
|
+
failedFiles: ['实体.json'],
|
|
1010
|
+
errors: [],
|
|
1011
|
+
warnings: []
|
|
1012
|
+
};
|
|
1013
|
+
} else {
|
|
1014
|
+
rememberPlan(planB);
|
|
1015
|
+
if (settledFiles.has('实体.json')) {
|
|
1016
|
+
rB = skippedFilesReport(['实体.json']);
|
|
1017
|
+
progressNotes.push('实体.json 已定稿(单文件校验已通过、之后未改动),本轮跳过重做');
|
|
1018
|
+
} else {
|
|
1019
|
+
rB = await runStageBatches(Object.assign({}, driverCommon, {
|
|
1020
|
+
files: planB.files,
|
|
1021
|
+
// 框架.json 在这一段开始前已经 PASSED:模型越权改它就用分片覆盖回去
|
|
1022
|
+
restoreSettled: makeSettledRestorer([planA]),
|
|
1023
|
+
validateScope: 'stage',
|
|
1024
|
+
validateStage: (files) => validateSkeletonFiles(files, {
|
|
1025
|
+
validator: validator,
|
|
1026
|
+
templateDir: projectTemplateDir,
|
|
1027
|
+
stageDir: stageDir,
|
|
1028
|
+
projectRoot: projectRoot
|
|
1029
|
+
})
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1032
|
+
phaseReports.push(rB);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ---- 段三:关系 + 层级(全量校验;规划要等段二落盘) ----
|
|
1037
|
+
let rC = null;
|
|
1038
|
+
if (rB && phaseFileOk(rB, '实体.json')) {
|
|
1039
|
+
planC = planSkeletonBatches(Object.assign({}, planBase, { only: ['关系', '层级'] }));
|
|
1040
|
+
if (!planC.ok) {
|
|
1041
|
+
rC = {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
error: '关系与层级批次规划失败:' + planC.error,
|
|
1044
|
+
files: [],
|
|
1045
|
+
failedFiles: ['关系.json', '层级.json'],
|
|
1046
|
+
errors: [],
|
|
1047
|
+
warnings: []
|
|
1048
|
+
};
|
|
1049
|
+
} else {
|
|
1050
|
+
rememberPlan(planC);
|
|
1051
|
+
// 关系 / 层级分开定稿:已定稿的不派活,只跑还没定稿的那个。
|
|
1052
|
+
const pendingFiles = planC.files.filter((f) => !settledFiles.has(f.file));
|
|
1053
|
+
const settledHere = planC.files
|
|
1054
|
+
.filter((f) => settledFiles.has(f.file))
|
|
1055
|
+
.map((f) => f.file);
|
|
1056
|
+
if (settledHere.length) {
|
|
1057
|
+
progressNotes.push(settledHere.join('、') +
|
|
1058
|
+
' 已定稿(单文件校验已通过、之后未改动),本轮跳过重做');
|
|
1059
|
+
}
|
|
1060
|
+
if (!pendingFiles.length) {
|
|
1061
|
+
// 全都定稿:不派活,但仍然要确认跨文件口径(--all)还成立。
|
|
1062
|
+
const all = await runSkeletonValidatorAll({
|
|
1063
|
+
validator: validator,
|
|
1064
|
+
stageDir: stageDir,
|
|
1065
|
+
templateDir: projectTemplateDir,
|
|
1066
|
+
projectRoot: projectRoot
|
|
1067
|
+
});
|
|
1068
|
+
const allOk = all.status === 0;
|
|
1069
|
+
rC = {
|
|
1070
|
+
ok: allOk,
|
|
1071
|
+
error: allOk ? null : '全量校验(--all)没通过',
|
|
1072
|
+
files: settledHere.map((f) => ({ file: f, ok: allOk })),
|
|
1073
|
+
failedFiles: allOk ? [] : settledHere.slice(),
|
|
1074
|
+
batches: [],
|
|
1075
|
+
errors: all.errors || [],
|
|
1076
|
+
warnings: all.warnings || [],
|
|
1077
|
+
unattributed: [],
|
|
1078
|
+
infraErrors: [],
|
|
1079
|
+
totalBatches: 0,
|
|
1080
|
+
totalRounds: 0
|
|
1081
|
+
};
|
|
1082
|
+
} else {
|
|
1083
|
+
rC = await runStageBatches(Object.assign({}, driverCommon, {
|
|
1084
|
+
files: pendingFiles,
|
|
1085
|
+
// 框架.json / 实体.json 在这一段开始前都已 PASSED:
|
|
1086
|
+
// 模型越权改它们就用分片覆盖回去(tt5 实测:模型改了 框架.json 的 quota)
|
|
1087
|
+
restoreSettled: makeSettledRestorer([planA, planB]),
|
|
1088
|
+
validateScope: 'stage',
|
|
1089
|
+
// 中途校验:关系+层级段有 27+4=31 个批次(tt5 实测),
|
|
1090
|
+
// 全写完才判一次的话,第 8 批写错要等 23 批都写完才发现。
|
|
1091
|
+
// 每 8 批局部判一次(局部口径:只判已写完的数组,不误报"缺数组/图不连通")。
|
|
1092
|
+
checkpointEvery: SKELETON_CHECKPOINT_EVERY,
|
|
1093
|
+
applyPartial: async (file) => mergeFleshShards({
|
|
1094
|
+
stageDir: stageDir,
|
|
1095
|
+
buildDir: buildDir,
|
|
1096
|
+
batches: file.batches,
|
|
1097
|
+
// 中途的半成品不能覆盖正式产物,也缺还没生成的分片
|
|
1098
|
+
allowMissing: true,
|
|
1099
|
+
outPath: join(stageDir, '.partial-' + file.file)
|
|
1100
|
+
}),
|
|
1101
|
+
validatePartial: async (file, part) => runSkeletonValidatorPartial({
|
|
1102
|
+
validator: validator,
|
|
1103
|
+
input: part.path,
|
|
1104
|
+
template: join(projectTemplateDir, file.template),
|
|
1105
|
+
fileType: file.type,
|
|
1106
|
+
upstream: stageDir,
|
|
1107
|
+
arrays: part.writtenArrays || [],
|
|
1108
|
+
projectRoot: projectRoot
|
|
1109
|
+
}),
|
|
1110
|
+
// 跨文件口径(引用完整性 / 图连通性 / 配额一致性)只能全量判
|
|
1111
|
+
validateStage: () => runSkeletonValidatorAll({
|
|
1112
|
+
validator: validator,
|
|
1113
|
+
stageDir: stageDir,
|
|
1114
|
+
templateDir: projectTemplateDir,
|
|
1115
|
+
projectRoot: projectRoot
|
|
1116
|
+
})
|
|
1117
|
+
}));
|
|
1118
|
+
// 跳过的文件也要出现在段报告里(段与段之间靠它判依赖门禁)
|
|
1119
|
+
if (settledHere.length) {
|
|
1120
|
+
rC = Object.assign({}, rC, {
|
|
1121
|
+
files: (rC.files || []).concat(settledHere.map((f) => ({ file: f, ok: true, skipped: true })))
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
phaseReports.push(rC);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
report = mergePhaseReports(phaseReports);
|
|
1130
|
+
// 清掉中途校验留下的半成品(点号开头,不会被 regenerateMd 渲染成 .md,
|
|
1131
|
+
// 但留在产物目录里会让人以为那是产物的一部分)。
|
|
1132
|
+
cleanupPartialFiles(stageDir);
|
|
1133
|
+
if (!phaseFileOk(rA, '框架.json')) {
|
|
1134
|
+
report.blocked = '框架没通过校验,实体的条数由框架的 quota 决定,本轮没有继续派活实体';
|
|
1135
|
+
} else if (rB && !phaseFileOk(rB, '实体.json')) {
|
|
1136
|
+
report.blocked = '实体没通过校验,关系与层级要引用实体清单里的 id,本轮没有继续派活关系与层级';
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1141
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg }));
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---- 产出盘点 ----
|
|
1145
|
+
const inspect = inspectSkeletonOutput(stageDir);
|
|
1146
|
+
const status = readSkeletonStageStatus(stageDir);
|
|
1147
|
+
const completionNote = buildSkeletonBatchNote({
|
|
1148
|
+
report: report,
|
|
1149
|
+
progressNotes: progressNotes,
|
|
1150
|
+
plans: [planA, planB, planC].filter(Boolean)
|
|
1151
|
+
});
|
|
1152
|
+
const inspectNote = buildSkeletonCompletionNote(inspect, status);
|
|
1153
|
+
|
|
1154
|
+
const allPassed = report.ok && inspect.complete && status.allCheck === 'PASSED';
|
|
1155
|
+
const payloadType = allPassed ? 'skeleton_complete' : 'skeleton_incomplete';
|
|
1156
|
+
|
|
1157
|
+
const parts = [setupNote, completionNote, inspectNote].filter(Boolean);
|
|
1158
|
+
const extra = parts.join('\n\n');
|
|
1159
|
+
return notify(session, 'success', storyBuilder.notify({ type: payloadType, phase: '骨架', extra }));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* 骨架阶段的路径解析:quota.<字段> → 真正该改的批次。
|
|
1164
|
+
*
|
|
1165
|
+
* QUOTA_MISMATCH 的 path 形如 "quota.character_counts.protagonist":
|
|
1166
|
+
* 声明在框架里,但根因常在实体清单(少写了一个主角)。
|
|
1167
|
+
* 按通用规则会归到框架批次,模型就会去改 quota —— 下一轮还是同样的错。
|
|
1168
|
+
* 这里把它导向对应的实体数组批次。
|
|
1169
|
+
*
|
|
1170
|
+
* 两个特例:
|
|
1171
|
+
* quota.volume_count → 层级.json 的 volumes(卷数对不上就补卷)
|
|
1172
|
+
* quota.faction_relation_count → 关系.json 的 faction_relations(补双向行)
|
|
1173
|
+
*/
|
|
1174
|
+
export function skeletonPathKey(p) {
|
|
1175
|
+
if (typeof p !== 'string' || p.indexOf('quota.') !== 0) return null;
|
|
1176
|
+
const rest = p.slice('quota.'.length);
|
|
1177
|
+
if (rest === 'volume_count') return 'volumes';
|
|
1178
|
+
if (rest === 'faction_relation_count') return 'faction_relations';
|
|
1179
|
+
return quotaPathToArray(rest);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* 骨架单批的提示词组装(驱动层与测试共用同一条路径)。
|
|
1184
|
+
*
|
|
1185
|
+
* 两块额外上下文都是**派活信息**,不属于产物契约:
|
|
1186
|
+
* idTables —— 关系批次能引用的实体 id 表(从段二的实体清单读)
|
|
1187
|
+
* extraContext —— 框架批次算数量要用的 T(数量公式的输入)
|
|
1188
|
+
*
|
|
1189
|
+
* 注意框架批次的判据是 batch.type,不是 batch.groupKey:
|
|
1190
|
+
* 框架那一批是整文件型(wholeObject),groupKey 是 null。
|
|
1191
|
+
*
|
|
1192
|
+
* @param {Object} opts
|
|
1193
|
+
* @param {Object} opts.batch 批次描述
|
|
1194
|
+
* @param {number} [opts.attempt] 第几次尝试
|
|
1195
|
+
* @param {Array} [opts.failures] 上一轮校验判出来的错误
|
|
1196
|
+
* @param {string} opts.stageDir 骨架目录(绝对路径,读实体清单用)
|
|
1197
|
+
* @param {Object} [opts.topicData] 选题数据(提供 T / 目标字数)
|
|
1198
|
+
* @returns {string} 提示词全文
|
|
1199
|
+
*/
|
|
1200
|
+
export function buildSkeletonBatchPrompt(opts) {
|
|
1201
|
+
const o = opts || {};
|
|
1202
|
+
const batch = o.batch || {};
|
|
1203
|
+
const topicData = o.topicData || {};
|
|
1204
|
+
const attempt = o.attempt || 1;
|
|
1205
|
+
|
|
1206
|
+
let idTables = null;
|
|
1207
|
+
let extraContext = null;
|
|
1208
|
+
|
|
1209
|
+
if (batch.refs && batch.refs.length) {
|
|
1210
|
+
idTables = buildSkeletonIdTables(batch.refs, o.stageDir);
|
|
1211
|
+
}
|
|
1212
|
+
if (batch.type === '框架') {
|
|
1213
|
+
// 框架那一批要把 T 摆出来(数量公式的输入)
|
|
1214
|
+
extraContext = [
|
|
1215
|
+
'目标章节 T = ' + (Number(topicData.target_chapters) || 0),
|
|
1216
|
+
'目标字数 = ' + (topicData.target_words || '(任务输入里没有)'),
|
|
1217
|
+
'数量公式读 references/formulas-skeleton.json,按 6.3 的拓扑顺序算区间。'
|
|
1218
|
+
];
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return storyBuilder.generateSkeletonBatchPrompt({
|
|
1222
|
+
batch: batch,
|
|
1223
|
+
attempt: attempt,
|
|
1224
|
+
failures: o.failures,
|
|
1225
|
+
idTables: idTables,
|
|
1226
|
+
extraContext: extraContext,
|
|
1227
|
+
stageDir: o.stageDirRel || '设定/raw/sources/骨架',
|
|
1228
|
+
buildDir: o.buildDirRel || ('设定/raw/sources/骨架/' + SKELETON_SHARD_DIR_NAME),
|
|
1229
|
+
topicData: topicData
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* 渲染"可引用的 id 表"(关系批次用)。
|
|
1235
|
+
*
|
|
1236
|
+
* 模型填关系时要引用别的实体的 id;不给表它就凭空编,
|
|
1237
|
+
* 编出来的 id 一个都不存在(SKELETON_REF_MISSING)。
|
|
1238
|
+
*/
|
|
1239
|
+
function buildSkeletonIdTables(refs, stageDir) {
|
|
1240
|
+
const entities = readJsonFileSafe(join(stageDir, '实体.json'));
|
|
1241
|
+
if (!entities || typeof entities !== 'object') return null;
|
|
1242
|
+
const lines = [];
|
|
1243
|
+
for (const key of refs || []) {
|
|
1244
|
+
const list = Array.isArray(entities[key]) ? entities[key] : [];
|
|
1245
|
+
if (!list.length) continue;
|
|
1246
|
+
lines.push('### ' + key + '(' + list.length + ' 个)');
|
|
1247
|
+
for (const e of list) {
|
|
1248
|
+
const tag = e.type || e.category || e.scale || e.level || '';
|
|
1249
|
+
lines.push('- ' + e.id + ' ' + e.name + (tag ? '(' + tag + ')' : ''));
|
|
1250
|
+
}
|
|
1251
|
+
lines.push('');
|
|
1252
|
+
}
|
|
1253
|
+
return lines.length ? lines : null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* 逐个文件跑骨架校验器的单文件模式,汇总成阶段级结果。
|
|
1258
|
+
*/
|
|
1259
|
+
async function validateSkeletonFiles(files, opts) {
|
|
1260
|
+
const errors = [];
|
|
1261
|
+
const warnings = [];
|
|
1262
|
+
const fileStatus = {};
|
|
1263
|
+
let status = 0;
|
|
1264
|
+
for (const file of files || []) {
|
|
1265
|
+
const r = await runSkeletonValidator({
|
|
1266
|
+
validator: opts.validator,
|
|
1267
|
+
input: join(opts.stageDir, file.file),
|
|
1268
|
+
template: join(opts.templateDir, file.template),
|
|
1269
|
+
fileType: file.type,
|
|
1270
|
+
projectRoot: opts.projectRoot
|
|
1271
|
+
});
|
|
1272
|
+
for (const e of r.errors || []) errors.push(e);
|
|
1273
|
+
for (const w of r.warnings || []) warnings.push(w);
|
|
1274
|
+
fileStatus[file.file] = r.status;
|
|
1275
|
+
if (r.status !== 0) {
|
|
1276
|
+
status = Math.max(status, r.status === 2 ? 2 : 1);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return { status: status, errors: errors, warnings: warnings, fileStatus: fileStatus };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* 调 skeleton-validate.js 的单文件模式(判定权在脚本,插件只读结果)。
|
|
1284
|
+
*/
|
|
1285
|
+
export async function runSkeletonValidator(opts) {
|
|
1286
|
+
return runValidatorScript({
|
|
1287
|
+
validator: opts.validator,
|
|
1288
|
+
args: [
|
|
1289
|
+
'--input=' + opts.input,
|
|
1290
|
+
'--template=' + opts.template,
|
|
1291
|
+
'--file=' + opts.fileType
|
|
1292
|
+
],
|
|
1293
|
+
timeout: 120000,
|
|
1294
|
+
projectRoot: opts.projectRoot,
|
|
1295
|
+
label: opts.fileType,
|
|
1296
|
+
crashMessage: '校验脚本异常',
|
|
1297
|
+
invalidMessage: '校验脚本输出'
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* 调 skeleton-validate.js 的全量模式(--all)。
|
|
1303
|
+
*/
|
|
1304
|
+
export async function runSkeletonValidatorAll(opts) {
|
|
1305
|
+
return runValidatorScript({
|
|
1306
|
+
validator: opts.validator,
|
|
1307
|
+
args: [
|
|
1308
|
+
'--stage-dir=' + opts.stageDir,
|
|
1309
|
+
'--template-dir=' + opts.templateDir,
|
|
1310
|
+
'--all'
|
|
1311
|
+
],
|
|
1312
|
+
timeout: 300000,
|
|
1313
|
+
projectRoot: opts.projectRoot,
|
|
1314
|
+
label: 'ALL',
|
|
1315
|
+
crashMessage: '全量校验脚本异常',
|
|
1316
|
+
invalidMessage: '全量校验输出'
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* 调 skeleton-validate.js 的**局部模式**(--arrays)。
|
|
1322
|
+
*
|
|
1323
|
+
* 中途校验用:文件还没写完,全量校验会误报(缺数组 → REQUIRED、图还不全 → G_ISOLATED),
|
|
1324
|
+
* 所以只判指定数组上当场可判的错(模板子树 / 引用完整性 / 重复 / 自环 / 对称性 / 行数对账)。
|
|
1325
|
+
*/
|
|
1326
|
+
export async function runSkeletonValidatorPartial(opts) {
|
|
1327
|
+
return runValidatorScript({
|
|
1328
|
+
validator: opts.validator,
|
|
1329
|
+
args: [
|
|
1330
|
+
'--input=' + opts.input,
|
|
1331
|
+
'--template=' + opts.template,
|
|
1332
|
+
'--file=' + opts.fileType,
|
|
1333
|
+
'--upstream=' + opts.upstream,
|
|
1334
|
+
'--arrays=' + (opts.arrays || []).join(',')
|
|
1335
|
+
],
|
|
1336
|
+
timeout: 120000,
|
|
1337
|
+
projectRoot: opts.projectRoot,
|
|
1338
|
+
label: opts.fileType + '(局部)',
|
|
1339
|
+
crashMessage: '局部校验脚本异常',
|
|
1340
|
+
invalidMessage: '局部校验输出'
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* 骨架阶段结论:把"派了多少批、哪些文件没过、错在哪"讲清楚。
|
|
1346
|
+
*/
|
|
1347
|
+
function buildSkeletonBatchNote(opts) {
|
|
1348
|
+
const o = opts || {};
|
|
1349
|
+
const report = o.report || {};
|
|
1350
|
+
const plans = o.plans || [];
|
|
1351
|
+
const lines = [];
|
|
1352
|
+
|
|
1353
|
+
const totalKeys = new Set();
|
|
1354
|
+
for (const p of plans) {
|
|
1355
|
+
for (const b of (p.batches || [])) totalKeys.add(b.key);
|
|
1356
|
+
}
|
|
1357
|
+
const okKeys = new Set();
|
|
1358
|
+
for (const b of (report.batches || [])) {
|
|
1359
|
+
if (b.ok) okKeys.add(b.key);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (report.error) {
|
|
1363
|
+
lines.push('❌ 骨架阶段未完成:' + report.error);
|
|
1364
|
+
} else if (report.ok) {
|
|
1365
|
+
lines.push('✅ 骨架阶段逐批派活完成:' + okKeys.size + '/' + totalKeys.size + ' 批通过校验。');
|
|
1366
|
+
} else {
|
|
1367
|
+
lines.push('❌ 骨架阶段未完成:' + ((report.failedFiles || []).join('、') || '有批次没通过校验') + '。');
|
|
1368
|
+
lines.push('(已通过 ' + okKeys.size + '/' + totalKeys.size + ' 批)');
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (report.blocked) {
|
|
1372
|
+
lines.push('');
|
|
1373
|
+
lines.push('⛔ ' + report.blocked);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const errs = report.errors || [];
|
|
1377
|
+
if (errs.length) {
|
|
1378
|
+
lines.push('');
|
|
1379
|
+
lines.push('**校验错误(' + errs.length + ' 条)**');
|
|
1380
|
+
for (const e of errs.slice(0, 15)) {
|
|
1381
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || '') + (e.hint ? '(' + e.hint + ')' : ''));
|
|
1382
|
+
}
|
|
1383
|
+
if (errs.length > 15) lines.push('- ……还有 ' + (errs.length - 15) + ' 条');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// 脚本/环境问题单独成段:它们没有重投给模型,混在错误列表里会误导
|
|
1387
|
+
for (const l of renderInfraBlock(report.infraErrors)) lines.push(l);
|
|
1388
|
+
|
|
1389
|
+
const un = report.unattributed || [];
|
|
1390
|
+
if (un.length) {
|
|
1391
|
+
lines.push('');
|
|
1392
|
+
lines.push('**归因不到具体批次的错误(' + un.length + ' 条,重投时不会自动修)**');
|
|
1393
|
+
for (const e of un.slice(0, 8)) {
|
|
1394
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || ''));
|
|
1395
|
+
}
|
|
1396
|
+
if (un.length > 8) lines.push('- ……还有 ' + (un.length - 8) + ' 条');
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const notes = o.progressNotes || [];
|
|
1400
|
+
if (notes.length) {
|
|
1401
|
+
lines.push('');
|
|
1402
|
+
lines.push('进度(共 ' + notes.length + ' 条):' + notes.slice(-3).join(';'));
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return lines.join('\n');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* 处理血肉阶段
|
|
1410
|
+
*/
|
|
1411
|
+
async function handleFlesh(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences, opts) {
|
|
1412
|
+
// 参考分层说明(插件指导 vs 对标样本)+ 本阶段可读的对标文件清单
|
|
1413
|
+
const benchmarkRefs = renderBenchmarkGuidance(projectRoot, 'flesh');
|
|
1414
|
+
const options = opts || {};
|
|
1415
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
1416
|
+
const { signal } = invocation;
|
|
1417
|
+
// 确保 setup_id 存在(幂等调用)
|
|
1418
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
1419
|
+
const setupId = setupResult.id;
|
|
1420
|
+
// 说明文字要并入最终返回的消息:notify 的中间返回值会被丢弃,
|
|
1421
|
+
// 单独发一条 info 用户根本看不到。
|
|
1422
|
+
let setupNote = '';
|
|
1423
|
+
if (!setupId) {
|
|
1424
|
+
// "设定"目录必须先是 llm-wiki 里的项目,本阶段的产物才会被 ingest 纳管
|
|
1425
|
+
// (实测:骨架 7 + 血肉 13 + 节奏 55 个文件全部进了 llm-wiki 的 ingest-cache)。
|
|
1426
|
+
// 拿不到 setup_id = 设定项目没建成 → 继续跑只会产出不被纳管的半成品,
|
|
1427
|
+
// 而且后续 wiki / analyze 阶段必然失败。必须硬失败。
|
|
1428
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
1429
|
+
type: 'setup_id_unavailable',
|
|
1430
|
+
err: setupResult.reason,
|
|
1431
|
+
detail: setupResult.detail
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
if (setupResult.reused) {
|
|
1435
|
+
setupNote = storyBuilder.notify({ type: 'setup_id_reused', id: setupId, source: setupResult.source });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// 准备"设定"目录并拷贝 schema 文件。
|
|
1439
|
+
// 拷失败必须**硬失败**:设定目录不存在时,后面的分片目录(设定/raw/sources/…)
|
|
1440
|
+
// 会把"设定"目录自己建出来 —— 而这个目录本该由 llm-wiki 的 /projects/create 建,
|
|
1441
|
+
// 插件抢建会让 llm-wiki 以为项目已存在,不再补 raw/assets、wiki/index.md,
|
|
1442
|
+
// 项目就残缺了(llm-wiki 侧 ingest 会缺文件)。
|
|
1443
|
+
const schemaCopy = await copySchemaToProject(projectRoot);
|
|
1444
|
+
if (!schemaCopy.ok) {
|
|
1445
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
1446
|
+
type: 'schema_copy_failed',
|
|
1447
|
+
detail: schemaCopy.detail
|
|
1448
|
+
}));
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// 骨架目录:任务输入快照与 id+name 摘要都从这儿取
|
|
1452
|
+
const skeletonDir = join(projectRoot, '设定', 'raw', 'sources', '骨架');
|
|
1453
|
+
|
|
1454
|
+
// 任务输入快照:读骨架阶段的 框架.json(input_snapshot + capacity)。
|
|
1455
|
+
// 骨架阶段已经把这次选题的完整输入固化成了可校验的快照,血肉阶段直接用它,
|
|
1456
|
+
// 比在提示词里另拼一份(过去只有书名/题材/目标章节/一句话故事 4 项)更权威也更完整。
|
|
1457
|
+
const inputSnapshot = readInputSnapshot(skeletonDir);
|
|
1458
|
+
|
|
1459
|
+
// 血肉目录 + 分片目录(分片是插件与 LLM 之间的中间态,不算最终产物)
|
|
1460
|
+
const fleshStageDir = join(projectRoot, '设定', 'raw', 'sources', '血肉');
|
|
1461
|
+
const buildDir = join(fleshStageDir, SHARD_DIR_NAME);
|
|
1462
|
+
|
|
1463
|
+
const canWait = invocation && invocation.agent && typeof invocation.agent.status === 'string';
|
|
1464
|
+
|
|
1465
|
+
// ---- 批次规划(先算出要写哪些批,再开始派活) ----
|
|
1466
|
+
const plan = planFleshBatches({
|
|
1467
|
+
skeletonDir: skeletonDir,
|
|
1468
|
+
templateDir: join(__dirname, 'templates', '血肉'),
|
|
1469
|
+
buildDir: buildDir,
|
|
1470
|
+
maxBytes: FLESH_BATCH_MAX_BYTES
|
|
1471
|
+
});
|
|
1472
|
+
if (!plan.ok) {
|
|
1473
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '血肉批次规划失败:' + plan.error }));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// 分片目录:模型写分片、插件合并成最终产物
|
|
1477
|
+
try {
|
|
1478
|
+
mkdirSync(buildDir, { recursive: true });
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '建分片目录失败:' + e.message }));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// 骨架关系:批次提示词里要把"本批相关的骨架关系"原样给模型,
|
|
1484
|
+
// 否则模型只能自己猜 relationship_type,REL_TYPE_MISMATCH 必炸。
|
|
1485
|
+
const skeletonEntities = readJsonFileSafe(join(skeletonDir, '实体.json'));
|
|
1486
|
+
const skeletonRelations = readJsonFileSafe(join(skeletonDir, '关系.json'));
|
|
1487
|
+
const idToName = buildIdToName(skeletonEntities);
|
|
1488
|
+
|
|
1489
|
+
const validator = join(projectRoot, 'references', 'flesh-validate.js');
|
|
1490
|
+
const projectTemplateDir = join(projectRoot, '模板', '血肉');
|
|
1491
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
1492
|
+
timeoutMs: 60 * 60 * 1000,
|
|
1493
|
+
idleGraceMs: 60000,
|
|
1494
|
+
signal,
|
|
1495
|
+
// 重投场景里分片文件还在(不能删 —— 删了模型 write 会被 DSH 的版本守卫拦死),
|
|
1496
|
+
// 完成判据必须看内容有没有变。
|
|
1497
|
+
changed: shardContentChanged
|
|
1498
|
+
});
|
|
1499
|
+
const knownShards = plan.batches.map((b) => b.shard);
|
|
1500
|
+
const progressNotes = [];
|
|
1501
|
+
|
|
1502
|
+
// ---- 插件驱动:派活 -> 等 LLM -> 合并 -> 跑校验 -> 归因 -> 重投 ----
|
|
1503
|
+
const driverOptions = {
|
|
1504
|
+
files: plan.files,
|
|
1505
|
+
buildPrompt: (batch, attempt, failures) => {
|
|
1506
|
+
const ids = (batch.items || []).map((i) => i.id).filter(Boolean);
|
|
1507
|
+
const rel = collectRelevantRelations(skeletonRelations, idToName, ids);
|
|
1508
|
+
return storyBuilder.generateFleshBatchPrompt({
|
|
1509
|
+
batch: batch,
|
|
1510
|
+
attempt: attempt,
|
|
1511
|
+
failures: failures,
|
|
1512
|
+
relations: rel.lines,
|
|
1513
|
+
stageDir: '设定/raw/sources/血肉',
|
|
1514
|
+
buildDir: '设定/raw/sources/血肉/' + SHARD_DIR_NAME,
|
|
1515
|
+
inputSnapshot: inputSnapshot,
|
|
1516
|
+
topicData: topicData,
|
|
1517
|
+
benchmarkRefs: benchmarkRefs
|
|
1518
|
+
});
|
|
1519
|
+
},
|
|
1520
|
+
sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
|
|
1521
|
+
waitIdle: async (files, before) => waitForTurn(files, before),
|
|
1522
|
+
readShard: (batch) => readShardFile(batch.shardPath),
|
|
1523
|
+
adoptShard: makeShardAdopter(buildDir, knownShards),
|
|
1524
|
+
applyFile: async (file) => mergeFleshShards({
|
|
1525
|
+
stageDir: fleshStageDir,
|
|
1526
|
+
buildDir: buildDir,
|
|
1527
|
+
batches: file.batches
|
|
1528
|
+
}),
|
|
1529
|
+
validateFile: async (file) => runFleshValidator({
|
|
1530
|
+
validator: validator,
|
|
1531
|
+
input: join(fleshStageDir, file.file),
|
|
1532
|
+
template: join(projectTemplateDir, file.template),
|
|
1533
|
+
fileType: file.type,
|
|
1534
|
+
upstream: skeletonDir,
|
|
1535
|
+
projectRoot: projectRoot
|
|
1536
|
+
}),
|
|
1537
|
+
attribute: (errors, layout) => attributeErrors(errors, layout),
|
|
1538
|
+
onProgress: (msg) => { progressNotes.push(msg); }
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
let report = { ok: false, error: null, files: [], failedFiles: [] };
|
|
1542
|
+
try {
|
|
1543
|
+
if (!canWait) {
|
|
1544
|
+
// 运行时没暴露 agent:派活必须能等回合结束,没法等就只能明确报错,
|
|
1545
|
+
// 不能装作驱动过了(2026-10 tt5 的教训就是"装作成功"最伤人)。
|
|
1546
|
+
report = {
|
|
1547
|
+
ok: false,
|
|
1548
|
+
error: '当前运行时没有暴露 agent,无法逐批派活血肉阶段(派活需要能等待每一轮结束)',
|
|
1549
|
+
files: [],
|
|
1550
|
+
failedFiles: []
|
|
1551
|
+
};
|
|
1552
|
+
} else {
|
|
1553
|
+
report = await runStageBatches(driverOptions);
|
|
1554
|
+
}
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1557
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg }));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// ---- 全量校验(插件自己跑,判定权在脚本) ----
|
|
1561
|
+
let allResult = null;
|
|
1562
|
+
if (report.ok) {
|
|
1563
|
+
allResult = await runFleshValidatorAll({
|
|
1564
|
+
validator: validator,
|
|
1565
|
+
stageDir: fleshStageDir,
|
|
1566
|
+
templateDir: projectTemplateDir,
|
|
1567
|
+
upstream: skeletonDir,
|
|
1568
|
+
projectRoot: projectRoot
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const inspect = inspectFleshOutput(fleshStageDir);
|
|
1573
|
+
const status = readFleshStageStatus(fleshStageDir);
|
|
1574
|
+
const completionNote = buildFleshBatchNote({
|
|
1575
|
+
report: report,
|
|
1576
|
+
allResult: allResult,
|
|
1577
|
+
inspect: inspect,
|
|
1578
|
+
status: status,
|
|
1579
|
+
progressNotes: progressNotes,
|
|
1580
|
+
plan: plan
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
const allPassed = !!(allResult && allResult.status === 0);
|
|
1584
|
+
const payloadType = (report.ok && allPassed) ? 'flesh_complete' : 'flesh_incomplete';
|
|
1585
|
+
|
|
1586
|
+
// 语义检查(可选,--semantic):确定性脚本查不了的内容矛盾,靠 LLM 查。
|
|
1587
|
+
// 对照 llm_wiki 的 runSemanticLint:独立一轮、结果只提示不阻断。
|
|
1588
|
+
let semanticNote = '';
|
|
1589
|
+
if (options.semantic) {
|
|
1590
|
+
semanticNote = await runSemanticLint({
|
|
1591
|
+
invocation: invocation,
|
|
1592
|
+
session: session,
|
|
1593
|
+
projectRoot: projectRoot,
|
|
1594
|
+
fleshStageDir: fleshStageDir,
|
|
1595
|
+
skeletonDir: skeletonDir,
|
|
1596
|
+
inspect: inspect,
|
|
1597
|
+
signal: signal,
|
|
1598
|
+
canWait: canWait
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const parts = [setupNote, completionNote, semanticNote].filter(Boolean);
|
|
1603
|
+
const extra = parts.join('\n\n');
|
|
1604
|
+
return notify(session, 'success', storyBuilder.notify({ type: payloadType, phase: '血肉', extra }));
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* 读一个 JSON 文件,失败返回 null(不抛)。
|
|
1609
|
+
*/
|
|
1610
|
+
function readJsonFileSafe(file) {
|
|
1611
|
+
try {
|
|
1612
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
1613
|
+
} catch (e) {
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* 读一个分片文件,校验它是合法 JSON 且顶层是对象。
|
|
1620
|
+
*/
|
|
1621
|
+
function readShardFile(file) {
|
|
1622
|
+
if (!file || !existsSync(file)) {
|
|
1623
|
+
return { ok: false, error: '分片文件不存在:' + file };
|
|
1624
|
+
}
|
|
1625
|
+
let data;
|
|
1626
|
+
try {
|
|
1627
|
+
data = JSON.parse(readFileSync(file, 'utf-8'));
|
|
1628
|
+
} catch (e) {
|
|
1629
|
+
return { ok: false, error: '分片不是合法 JSON:' + e.message };
|
|
1630
|
+
}
|
|
1631
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1632
|
+
return { ok: false, error: '分片顶层必须是对象' };
|
|
1633
|
+
}
|
|
1634
|
+
return { ok: true, data: data };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* 跑一个校验脚本,把结果解析成结构化结论。
|
|
1639
|
+
*
|
|
1640
|
+
* 退出码语义(血肉 / 节奏 / 骨架 / 写作的校验器一致):
|
|
1641
|
+
* 0 = 通过 1 = 内容有错 2 = 脚本异常
|
|
1642
|
+
*
|
|
1643
|
+
* 关键:**不能只看退出码**。内容有错也是退出码 1,
|
|
1644
|
+
* 而校验器已经把错误清单写在 stdout 的 JSON 里了 ——
|
|
1645
|
+
* 必须先解析输出再判,否则错误清单会被整体丢掉。
|
|
1646
|
+
* (早期版本把退出码非 0 当成"脚本异常",每次校验失败都报 VALIDATOR_CRASH,
|
|
1647
|
+
* "哪里有问题、该怎么改"就完全看不到了。)
|
|
1648
|
+
*
|
|
1649
|
+
* 血肉与节奏的封装逻辑原本各写了一份,逐字节相同;
|
|
1650
|
+
* 抽到这里以后,退出码语义只有一处定义。
|
|
1651
|
+
*/
|
|
1652
|
+
async function runValidatorScript(opts) {
|
|
1653
|
+
const r = await spawnScript('node', [opts.validator].concat(opts.args || []), {
|
|
1654
|
+
timeout: opts.timeout || 120000,
|
|
1655
|
+
cwd: opts.projectRoot
|
|
1656
|
+
});
|
|
1657
|
+
let parsed = null;
|
|
1658
|
+
try {
|
|
1659
|
+
parsed = JSON.parse(r.output);
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
parsed = null;
|
|
1662
|
+
}
|
|
1663
|
+
if (parsed && typeof parsed.status === 'number') {
|
|
1664
|
+
return { status: parsed.status, errors: parsed.errors || [], warnings: parsed.warnings || [] };
|
|
1665
|
+
}
|
|
1666
|
+
if (!r.ok) {
|
|
1667
|
+
return {
|
|
1668
|
+
status: 2,
|
|
1669
|
+
errors: [{
|
|
1670
|
+
code: 'VALIDATOR_CRASH',
|
|
1671
|
+
path: opts.label || '',
|
|
1672
|
+
message: (opts.crashMessage || '校验脚本异常') + ':' + (r.error || r.stderr || '未知错误'),
|
|
1673
|
+
hint: null
|
|
1674
|
+
}],
|
|
1675
|
+
warnings: []
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
return {
|
|
1679
|
+
status: 2,
|
|
1680
|
+
errors: [{
|
|
1681
|
+
code: 'VALIDATOR_OUTPUT_INVALID',
|
|
1682
|
+
path: opts.label || '',
|
|
1683
|
+
message: (opts.invalidMessage || '校验脚本输出') + '不是合法 JSON',
|
|
1684
|
+
hint: null
|
|
1685
|
+
}],
|
|
1686
|
+
warnings: []
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* 调 flesh-validate.js 的单文件模式(判定权在脚本,插件只读结果)。
|
|
1692
|
+
*/
|
|
1693
|
+
export async function runFleshValidator(opts) {
|
|
1694
|
+
return runValidatorScript({
|
|
1695
|
+
validator: opts.validator,
|
|
1696
|
+
args: [
|
|
1697
|
+
'--input=' + opts.input,
|
|
1698
|
+
'--template=' + opts.template,
|
|
1699
|
+
'--file=' + opts.fileType,
|
|
1700
|
+
'--upstream=' + opts.upstream
|
|
1701
|
+
],
|
|
1702
|
+
timeout: 120000,
|
|
1703
|
+
projectRoot: opts.projectRoot,
|
|
1704
|
+
label: opts.fileType,
|
|
1705
|
+
crashMessage: '校验脚本异常',
|
|
1706
|
+
invalidMessage: '校验脚本输出'
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* 调 flesh-validate.js 的全量模式(--all)。
|
|
1712
|
+
*/
|
|
1713
|
+
export async function runFleshValidatorAll(opts) {
|
|
1714
|
+
return runValidatorScript({
|
|
1715
|
+
validator: opts.validator,
|
|
1716
|
+
args: [
|
|
1717
|
+
'--stage-dir=' + opts.stageDir,
|
|
1718
|
+
'--template-dir=' + opts.templateDir,
|
|
1719
|
+
'--upstream=' + opts.upstream,
|
|
1720
|
+
'--all'
|
|
1721
|
+
],
|
|
1722
|
+
timeout: 300000,
|
|
1723
|
+
projectRoot: opts.projectRoot,
|
|
1724
|
+
label: 'ALL',
|
|
1725
|
+
crashMessage: '全量校验脚本异常',
|
|
1726
|
+
invalidMessage: '全量校验输出'
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* 调 rhythm-validate.js 的单文件模式(判定权在脚本,插件只读结果)。
|
|
1732
|
+
*/
|
|
1733
|
+
export async function runRhythmValidator(opts) {
|
|
1734
|
+
return runValidatorScript({
|
|
1735
|
+
validator: opts.validator,
|
|
1736
|
+
args: [
|
|
1737
|
+
'--input=' + opts.input,
|
|
1738
|
+
'--template=' + opts.template,
|
|
1739
|
+
'--file=' + opts.fileType,
|
|
1740
|
+
'--upstream=' + opts.upstream
|
|
1741
|
+
],
|
|
1742
|
+
timeout: 120000,
|
|
1743
|
+
projectRoot: opts.projectRoot,
|
|
1744
|
+
label: opts.fileType,
|
|
1745
|
+
crashMessage: '校验脚本异常',
|
|
1746
|
+
invalidMessage: '校验脚本输出'
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* 调 rhythm-validate.js 的全量模式(--all)。
|
|
1752
|
+
*/
|
|
1753
|
+
export async function runRhythmValidatorAll(opts) {
|
|
1754
|
+
return runValidatorScript({
|
|
1755
|
+
validator: opts.validator,
|
|
1756
|
+
args: [
|
|
1757
|
+
'--stage-dir=' + opts.stageDir,
|
|
1758
|
+
'--template-dir=' + opts.templateDir,
|
|
1759
|
+
'--upstream=' + opts.upstream,
|
|
1760
|
+
'--all'
|
|
1761
|
+
],
|
|
1762
|
+
timeout: 300000,
|
|
1763
|
+
projectRoot: opts.projectRoot,
|
|
1764
|
+
label: 'ALL',
|
|
1765
|
+
crashMessage: '全量校验脚本异常',
|
|
1766
|
+
invalidMessage: '全量校验输出'
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* 血肉阶段结论:把"派了多少批、哪些文件没过、错在哪"讲清楚。
|
|
1772
|
+
*
|
|
1773
|
+
* 2026-10 tt5 的教训:失败必须可见。插件驱动之后,
|
|
1774
|
+
* "阶段开始"这种话只在真的开始时才能说,失败必须带清单。
|
|
1775
|
+
*/
|
|
1776
|
+
function buildFleshBatchNote(opts) {
|
|
1777
|
+
const o = opts || {};
|
|
1778
|
+
const report = o.report || {};
|
|
1779
|
+
const allResult = o.allResult || null;
|
|
1780
|
+
const plan = o.plan || { batches: [] };
|
|
1781
|
+
const lines = [];
|
|
1782
|
+
|
|
1783
|
+
const totalBatches = (plan.batches || []).length;
|
|
1784
|
+
const doneBatches = (report.files || []).reduce((n, f) => n + (f.batches || []).filter((b) => b.ok).length, 0);
|
|
1785
|
+
|
|
1786
|
+
if (report.error) {
|
|
1787
|
+
lines.push('❌ 血肉阶段中断:' + report.error);
|
|
1788
|
+
} else if (report.ok) {
|
|
1789
|
+
lines.push('✅ 血肉阶段逐批派活完成:' + doneBatches + '/' + totalBatches + ' 批,8 个文件全部单检通过。');
|
|
1790
|
+
} else {
|
|
1791
|
+
lines.push('❌ 血肉阶段未完成:' + (report.failedFiles || []).join('、') + ' 没通过校验。');
|
|
1792
|
+
lines.push('(已完成 ' + doneBatches + '/' + totalBatches + ' 批)');
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
for (const f of report.files || []) {
|
|
1796
|
+
if (f.ok) continue;
|
|
1797
|
+
lines.push('');
|
|
1798
|
+
lines.push('**' + f.file + '**(' + f.rounds + ' 轮)');
|
|
1799
|
+
const errs = f.errors || [];
|
|
1800
|
+
if (!errs.length) {
|
|
1801
|
+
lines.push('- ' + (f.error || '未通过校验,但没有拿到错误清单'));
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
for (const e of errs.slice(0, 12)) {
|
|
1805
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || '') + (e.hint ? '(' + e.hint + ')' : ''));
|
|
1806
|
+
}
|
|
1807
|
+
if (errs.length > 12) lines.push('- ……还有 ' + (errs.length - 12) + ' 条');
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// 脚本/环境问题单独成段:它们没有重投给模型,混在错误列表里会误导
|
|
1811
|
+
for (const l of renderInfraBlock(report.infraErrors)) lines.push(l);
|
|
1812
|
+
|
|
1813
|
+
if (allResult) {
|
|
1814
|
+
lines.push('');
|
|
1815
|
+
// 全量校验的错误也要分两类:脚本/环境问题不是产物内容的问题,
|
|
1816
|
+
// 不能混在"未通过,N 条错误"里 —— 那会让人以为重跑一轮就能好。
|
|
1817
|
+
const split = classifyIssues(allResult.errors || []);
|
|
1818
|
+
if (allResult.status === 0) {
|
|
1819
|
+
lines.push('全量校验(--all):通过' + ((allResult.warnings || []).length ? ',warning ' + allResult.warnings.length + ' 条' : '') + '。');
|
|
1820
|
+
} else if (allResult.status === 1) {
|
|
1821
|
+
lines.push('全量校验(--all):未通过,' + split.content.length + ' 条内容错误。');
|
|
1822
|
+
for (const e of split.content.slice(0, 12)) {
|
|
1823
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || '') + (e.hint ? '(' + e.hint + ')' : ''));
|
|
1824
|
+
}
|
|
1825
|
+
if (split.content.length > 12) lines.push('- ……还有 ' + (split.content.length - 12) + ' 条');
|
|
1826
|
+
} else {
|
|
1827
|
+
lines.push('全量校验(--all):脚本异常。');
|
|
1828
|
+
for (const e of (allResult.errors || []).slice(0, 5)) {
|
|
1829
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.message || ''));
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
for (const l of renderInfraBlock(split.infra)) lines.push(l);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const notes = o.progressNotes || [];
|
|
1836
|
+
if (notes.length) {
|
|
1837
|
+
lines.push('');
|
|
1838
|
+
lines.push('进度(共 ' + notes.length + ' 条):' + notes.slice(-3).join(';'));
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// 产出盘点:哪些文件压根没落地(批次生成失败时会缺)
|
|
1842
|
+
const missing = (o.inspect && o.inspect.missing) || [];
|
|
1843
|
+
if (missing.length) {
|
|
1844
|
+
lines.push('');
|
|
1845
|
+
lines.push('缺产物文件:' + missing.join('、'));
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
return lines.join('\n');
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* 血肉阶段的语义检查(LLM 层)
|
|
1853
|
+
*
|
|
1854
|
+
* 对照 llm_wiki 的 runSemanticLint(src/lib/lint.ts:205):
|
|
1855
|
+
* - 独立一轮投递,不与血肉产出混在一起
|
|
1856
|
+
* - 结果只提示,不阻断阶段(血肉阶段的成败由确定性脚本说了算)
|
|
1857
|
+
* - LLM 的结果必须本地复核后才展示(semantic-lint.js reviewLintFindings)
|
|
1858
|
+
*
|
|
1859
|
+
* 为什么不直接在 flesh-validate.js 里做:校验器是纯 node 脚本,没有 LLM 通道。
|
|
1860
|
+
*/
|
|
1861
|
+
async function runSemanticLint(opts) {
|
|
1862
|
+
const o = opts || {};
|
|
1863
|
+
const fleshStageDir = o.fleshStageDir;
|
|
1864
|
+
const skeletonDir = o.skeletonDir;
|
|
1865
|
+
|
|
1866
|
+
if (!o.canWait) {
|
|
1867
|
+
return '语义检查未执行:当前运行时没有暴露 agent,无法等待 LLM 完成这一轮。';
|
|
1868
|
+
}
|
|
1869
|
+
if (!o.inspect || !o.inspect.complete) {
|
|
1870
|
+
return '语义检查未执行:血肉 8 个 JSON 还没齐。先把血肉阶段补齐,再跑 `--semantic`。';
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const fleshEntities = collectFleshEntities(fleshStageDir);
|
|
1874
|
+
if (!fleshEntities.length) {
|
|
1875
|
+
return '语义检查未执行:血肉目录里没读到实体。';
|
|
1876
|
+
}
|
|
1877
|
+
const skeletonRelations = collectSkeletonRelations(skeletonDir);
|
|
1878
|
+
const keys = collectEntityKeys(skeletonDir);
|
|
1879
|
+
|
|
1880
|
+
const prompt = buildSemanticLintPrompt({
|
|
1881
|
+
fleshEntities: fleshEntities,
|
|
1882
|
+
skeletonRelations: skeletonRelations,
|
|
1883
|
+
entityIds: keys.entityIds,
|
|
1884
|
+
entityNames: keys.entityNames
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
const outFile = join(fleshStageDir, '.semantic-lint.txt');
|
|
1888
|
+
// 旧结果先删掉:不然上一轮的残留会被当成本轮结果
|
|
1889
|
+
try {
|
|
1890
|
+
if (existsSync(outFile)) fs.unlinkSync(outFile);
|
|
1891
|
+
} catch (e) {
|
|
1892
|
+
// 删不掉就继续(下面的等待会超时并如实报告)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const fullPrompt = prompt + [
|
|
1896
|
+
'',
|
|
1897
|
+
'## 输出方式',
|
|
1898
|
+
'',
|
|
1899
|
+
'把上面的 LINT 块**原样**写进这个文件(不要加解释、不要用 markdown 代码块包住):',
|
|
1900
|
+
'',
|
|
1901
|
+
outFile,
|
|
1902
|
+
'',
|
|
1903
|
+
'如果没有值得报的问题,就写一个空文件(0 字节)。'
|
|
1904
|
+
].join('\n');
|
|
1905
|
+
|
|
1906
|
+
let follow;
|
|
1907
|
+
try {
|
|
1908
|
+
follow = await llmFollowup(o.invocation, fullPrompt);
|
|
1909
|
+
} catch (e) {
|
|
1910
|
+
return '语义检查未执行:提示词投递失败(' + (e && e.message ? e.message : String(e)) + ')。';
|
|
1911
|
+
}
|
|
1912
|
+
if (!follow || follow.ok !== true) {
|
|
1913
|
+
return '语义检查未执行:提示词投递失败(' + ((follow && follow.error) || '未知原因') + ')。';
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const waitForTurn = makeWaitForTurn(o.invocation, { timeoutMs: 30 * 60 * 1000, idleGraceMs: 60000, signal: o.signal });
|
|
1917
|
+
await waitForTurn([outFile]);
|
|
1918
|
+
|
|
1919
|
+
if (!existsSync(outFile)) {
|
|
1920
|
+
return '语义检查未产出结果文件(' + outFile + '),这一轮没有拿到结论。';
|
|
1921
|
+
}
|
|
1922
|
+
let raw = '';
|
|
1923
|
+
try {
|
|
1924
|
+
raw = fs.readFileSync(outFile, 'utf-8');
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
return '语义检查结果文件读不出来:' + e.message;
|
|
1927
|
+
}
|
|
1928
|
+
if (!raw.trim()) {
|
|
1929
|
+
return '语义检查:LLM 审校后没有发现值得报的问题。';
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const findings = parseLintBlocks(raw);
|
|
1933
|
+
if (!findings.length) {
|
|
1934
|
+
return '语义检查:结果文件里没有可解析的 LINT 块(' + outFile + '),这一轮没有拿到结论。';
|
|
1935
|
+
}
|
|
1936
|
+
const reviewed = reviewLintFindings(findings, {
|
|
1937
|
+
entityIds: keys.entityIds,
|
|
1938
|
+
entityNames: keys.entityNames
|
|
1939
|
+
});
|
|
1940
|
+
return formatSemanticLintResult(reviewed.kept, reviewed.dropped, outFile);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* 把语义检查结果渲染成给人看的说明。
|
|
1945
|
+
*
|
|
1946
|
+
* 分级(对应 llm_wiki 的 finding severity):
|
|
1947
|
+
* error —— 设定自相矛盾,必须改
|
|
1948
|
+
* warning —— 大概率要改
|
|
1949
|
+
* info —— 可以斟酌
|
|
1950
|
+
*/
|
|
1951
|
+
function formatSemanticLintResult(kept, dropped, outFile) {
|
|
1952
|
+
const lines = [];
|
|
1953
|
+
const bySeverity = { error: [], warning: [], info: [] };
|
|
1954
|
+
for (const f of kept) {
|
|
1955
|
+
(bySeverity[f.severity] || bySeverity.warning).push(f);
|
|
1956
|
+
}
|
|
1957
|
+
lines.push('语义检查(LLM 审校):' + kept.length + ' 条。');
|
|
1958
|
+
lines.push('');
|
|
1959
|
+
if (!kept.length) {
|
|
1960
|
+
lines.push('- 复核后没有可信的发现。');
|
|
1961
|
+
}
|
|
1962
|
+
for (const sev of ['error', 'warning', 'info']) {
|
|
1963
|
+
const list = bySeverity[sev];
|
|
1964
|
+
if (!list.length) continue;
|
|
1965
|
+
lines.push('### ' + (sev === 'error' ? '必须改' : sev === 'warning' ? '大概率要改' : '可以斟酌') + '(' + list.length + ')');
|
|
1966
|
+
lines.push('');
|
|
1967
|
+
for (const f of list) {
|
|
1968
|
+
lines.push('- **' + f.title + '**(' + f.type + ')');
|
|
1969
|
+
if (f.description) lines.push(' ' + f.description.replace(/\n+/g, ' '));
|
|
1970
|
+
if (f.pages.length) lines.push(' 涉及:' + f.pages.join('、'));
|
|
1971
|
+
}
|
|
1972
|
+
lines.push('');
|
|
1973
|
+
}
|
|
1974
|
+
if (dropped.length) {
|
|
1975
|
+
lines.push('(另有 ' + dropped.length + ' 条经本地复核后丢弃:' + dropped.map((d) => d.reason).join(';') + ')');
|
|
1976
|
+
lines.push('');
|
|
1977
|
+
}
|
|
1978
|
+
lines.push('原始结果:' + outFile);
|
|
1979
|
+
lines.push('语义检查**不阻断**血肉阶段:上面这些是 LLM 的判断,要不要改、改哪条由你决定。');
|
|
1980
|
+
return lines.join('\n');
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* 处理节奏阶段(插件驱动版)
|
|
1985
|
+
*
|
|
1986
|
+
* 与血肉阶段同构(插件当司机、脚本当裁判),但节奏多两条硬约束:
|
|
1987
|
+
*
|
|
1988
|
+
* 1. **生成顺序有依赖**:章节规划 → 节奏参数 → 时序映射 → 章节细纲。
|
|
1989
|
+
* 时序映射受节奏参数约束(主线 key_nodes 数 = main_progress_chapter_count、
|
|
1990
|
+
* 支线章数之和 = branch_chapter_total),先定参数再写映射才能一次填对。
|
|
1991
|
+
* 所以节奏拆成三段驱动,每段生成完立刻合并,下一段的提示词才读得到。
|
|
1992
|
+
*
|
|
1993
|
+
* 2. **重点章集合是动态的**:章节细纲只写重点章,而重点章(大高潮章 / 结局章 /
|
|
1994
|
+
* 钩子章)全都在时序映射里,必须等时序映射生成后才知道。
|
|
1995
|
+
* 所以细纲那一段是"算完重点章再规划批次",不能预先静态规划。
|
|
1996
|
+
*
|
|
1997
|
+
* 校验粒度:前两段用**单文件校验**(章节规划覆盖 1..T、时序映射引用完整性、
|
|
1998
|
+
* 节奏参数可行性都能单文件判),最后一段用**全量校验 --all**
|
|
1999
|
+
* (跨文件口径:主线/支线/参数三者吻合、结局覆盖、钩子下限)。
|
|
2000
|
+
*/
|
|
2001
|
+
async function handleRhythm(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences) {
|
|
2002
|
+
// 参考分层说明(插件指导 vs 对标样本)+ 本阶段可读的对标文件清单
|
|
2003
|
+
const benchmarkRefs = renderBenchmarkGuidance(projectRoot, 'rhythm');
|
|
2004
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
2005
|
+
const { signal } = invocation;
|
|
2006
|
+
// 确保 setup_id 存在(幂等调用)
|
|
2007
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
2008
|
+
const setupId = setupResult.id;
|
|
2009
|
+
// 说明文字要并入最终返回的消息:notify 的中间返回值会被丢弃,
|
|
2010
|
+
// 单独发一条 info 用户根本看不到。
|
|
2011
|
+
let setupNote = '';
|
|
2012
|
+
if (!setupId) {
|
|
2013
|
+
// "设定"目录必须先是 llm-wiki 里的项目,本阶段的产物才会被 ingest 纳管
|
|
2014
|
+
// (实测:骨架 7 + 血肉 13 + 节奏 55 个文件全部进了 llm-wiki 的 ingest-cache)。
|
|
2015
|
+
// 拿不到 setup_id = 设定项目没建成 → 继续跑只会产出不被纳管的半成品,
|
|
2016
|
+
// 而且后续 wiki / analyze 阶段必然失败。必须硬失败。
|
|
2017
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2018
|
+
type: 'setup_id_unavailable',
|
|
2019
|
+
err: setupResult.reason,
|
|
2020
|
+
detail: setupResult.detail
|
|
2021
|
+
}));
|
|
2022
|
+
}
|
|
2023
|
+
if (setupResult.reused) {
|
|
2024
|
+
setupNote = storyBuilder.notify({ type: 'setup_id_reused', id: setupId, source: setupResult.source });
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// 准备"设定"目录并拷贝 schema 文件。
|
|
2028
|
+
// 拷失败必须**硬失败**:设定目录不存在时,后面的分片目录(设定/raw/sources/…)
|
|
2029
|
+
// 会把"设定"目录自己建出来 —— 而这个目录本该由 llm-wiki 的 /projects/create 建,
|
|
2030
|
+
// 插件抢建会让 llm-wiki 以为项目已存在,不再补 raw/assets、wiki/index.md,
|
|
2031
|
+
// 项目就残缺了(llm-wiki 侧 ingest 会缺文件)。
|
|
2032
|
+
const schemaCopy = await copySchemaToProject(projectRoot);
|
|
2033
|
+
if (!schemaCopy.ok) {
|
|
2034
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2035
|
+
type: 'schema_copy_failed',
|
|
2036
|
+
detail: schemaCopy.detail
|
|
2037
|
+
}));
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// 三个阶段目录
|
|
2041
|
+
const skeletonDir = join(projectRoot, '设定', 'raw', 'sources', '骨架');
|
|
2042
|
+
const fleshDir = join(projectRoot, '设定', 'raw', 'sources', '血肉');
|
|
2043
|
+
const rhythmStageDir = join(projectRoot, '设定', 'raw', 'sources', '节奏');
|
|
2044
|
+
// 分片目录(插件与 LLM 之间的中间态,不算最终产物)
|
|
2045
|
+
const buildDir = join(rhythmStageDir, RHYTHM_SHARD_DIR_NAME);
|
|
2046
|
+
|
|
2047
|
+
// ---- 前置门禁:骨架 + 血肉两个上游都必须全量校验通过 ----
|
|
2048
|
+
// 这个判定不能只写在提示词里(模型看不见 .stage-status.json 的真实状态,
|
|
2049
|
+
// 上游没通过时照样会开写,产出全是废的)。
|
|
2050
|
+
const gate = checkRhythmUpstreamGates(skeletonDir, fleshDir);
|
|
2051
|
+
if (!gate.ok) {
|
|
2052
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'upstream_not_ready', err: gate.reason }));
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// 分片目录:模型写分片、插件合并成最终产物
|
|
2056
|
+
try {
|
|
2057
|
+
mkdirSync(buildDir, { recursive: true });
|
|
2058
|
+
} catch (e) {
|
|
2059
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '建分片目录失败:' + e.message }));
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// 任务输入快照:与血肉阶段同源(骨架 框架.json 的 input_snapshot + capacity)
|
|
2063
|
+
const inputSnapshot = readInputSnapshot(skeletonDir);
|
|
2064
|
+
|
|
2065
|
+
// 骨架上下文:id->name(时序映射的 id 必须原样抄)、高潮类型、卷/幕/配额
|
|
2066
|
+
const skeletonEntities = readJsonFileSafe(join(skeletonDir, '实体.json'));
|
|
2067
|
+
const hierarchy = readJsonFileSafe(join(skeletonDir, '层级.json'));
|
|
2068
|
+
const framework = readJsonFileSafe(join(skeletonDir, '框架.json'));
|
|
2069
|
+
const idToName = buildIdToName(skeletonEntities);
|
|
2070
|
+
const climaxTypes = buildClimaxTypes(skeletonEntities);
|
|
2071
|
+
const skeletonOutline = buildRhythmSkeletonOutline({ hierarchy: hierarchy, framework: framework });
|
|
2072
|
+
|
|
2073
|
+
const validator = join(projectRoot, 'references', 'rhythm-validate.js');
|
|
2074
|
+
const projectTemplateDir = join(projectRoot, '模板', '节奏');
|
|
2075
|
+
const pluginTemplateDir = join(__dirname, 'templates', '节奏');
|
|
2076
|
+
// 节奏校验器要求上游同时给骨架和血肉两个目录
|
|
2077
|
+
const upstream = skeletonDir + ',' + fleshDir;
|
|
2078
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
2079
|
+
timeoutMs: 60 * 60 * 1000,
|
|
2080
|
+
idleGraceMs: 60000,
|
|
2081
|
+
signal,
|
|
2082
|
+
// 重投场景里分片文件还在(不能删 —— 删了模型 write 会被 DSH 的版本守卫拦死),
|
|
2083
|
+
// 完成判据必须看内容有没有变。
|
|
2084
|
+
changed: shardContentChanged
|
|
2085
|
+
});
|
|
2086
|
+
const canWait = invocation && invocation.agent && typeof invocation.agent.status === 'string';
|
|
2087
|
+
const progressNotes = [];
|
|
2088
|
+
|
|
2089
|
+
// ---- 批次规划(三段) ----
|
|
2090
|
+
const planBase = {
|
|
2091
|
+
skeletonDir: skeletonDir,
|
|
2092
|
+
templateDir: pluginTemplateDir,
|
|
2093
|
+
buildDir: buildDir,
|
|
2094
|
+
maxBytes: RHYTHM_BATCH_MAX_BYTES
|
|
2095
|
+
};
|
|
2096
|
+
// 第一段:章节规划 + 节奏参数(两者互相无依赖)
|
|
2097
|
+
const planA = planRhythmBatches(Object.assign({}, planBase, { only: ['章节规划', '节奏参数'] }));
|
|
2098
|
+
if (!planA.ok) {
|
|
2099
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '节奏批次规划失败:' + planA.error }));
|
|
2100
|
+
}
|
|
2101
|
+
// 第二段:时序映射(依赖第一段的节奏参数)
|
|
2102
|
+
const planB = planRhythmBatches(Object.assign({}, planBase, { only: ['时序映射'] }));
|
|
2103
|
+
if (!planB.ok) {
|
|
2104
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: '节奏批次规划失败:' + planB.error }));
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// 批次 key -> 所属文件(提示词构建时要按批次找到所在文件,算上一批收尾)
|
|
2108
|
+
const fileByBatchKey = new Map();
|
|
2109
|
+
const knownShards = [];
|
|
2110
|
+
for (const f of planA.files.concat(planB.files)) {
|
|
2111
|
+
for (const b of f.batches) {
|
|
2112
|
+
fileByBatchKey.set(b.key, f);
|
|
2113
|
+
knownShards.push(b.shard);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// ---- 提示词构建(三段共用) ----
|
|
2118
|
+
const buildBatchPrompt = (batch, attempt, failures) => {
|
|
2119
|
+
const chapters = (batch.items || []).map((it) => it.ch).filter((c) => typeof c === 'number');
|
|
2120
|
+
let chapterRefs = null;
|
|
2121
|
+
let prevTail = null;
|
|
2122
|
+
let extraContext = null;
|
|
2123
|
+
|
|
2124
|
+
if (batch.type === '章节规划') {
|
|
2125
|
+
// 卷/幕/配额是受约束的枚举,模型看不到就只会瞎编
|
|
2126
|
+
extraContext = skeletonOutline.lines;
|
|
2127
|
+
} else if (batch.type === '时序映射') {
|
|
2128
|
+
// 时序映射受节奏参数约束:必须让模型看到三个参数的真实值
|
|
2129
|
+
extraContext = buildRhythmParamContext(rhythmStageDir);
|
|
2130
|
+
} else if (batch.type === '章节细纲') {
|
|
2131
|
+
// 本批章号在时序映射里的真源信息(钩子/高潮/结局/主线节点)
|
|
2132
|
+
const timeline = readJsonFileSafe(join(rhythmStageDir, '时序映射.json'));
|
|
2133
|
+
chapterRefs = buildRhythmChapterRefs({
|
|
2134
|
+
timeline: timeline || {},
|
|
2135
|
+
chapters: chapters,
|
|
2136
|
+
idToName: idToName,
|
|
2137
|
+
climaxTypes: climaxTypes
|
|
2138
|
+
}).lines;
|
|
2139
|
+
prevTail = buildRhythmPrevTailFor(fileByBatchKey.get(batch.key), batch, buildDir);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
return storyBuilder.generateRhythmBatchPrompt({
|
|
2143
|
+
batch: batch,
|
|
2144
|
+
attempt: attempt,
|
|
2145
|
+
failures: failures,
|
|
2146
|
+
chapterRefs: chapterRefs,
|
|
2147
|
+
prevTail: prevTail,
|
|
2148
|
+
extraContext: extraContext,
|
|
2149
|
+
stageDir: '设定/raw/sources/节奏',
|
|
2150
|
+
buildDir: '设定/raw/sources/节奏/' + RHYTHM_SHARD_DIR_NAME,
|
|
2151
|
+
inputSnapshot: inputSnapshot,
|
|
2152
|
+
topicData: topicData,
|
|
2153
|
+
benchmarkRefs: benchmarkRefs
|
|
2154
|
+
});
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
// ---- 驱动(三段共用同一会话,批次状态跨段保留) ----
|
|
2158
|
+
const stageSession = createStageSession();
|
|
2159
|
+
const driverCommon = {
|
|
2160
|
+
session: stageSession,
|
|
2161
|
+
buildPrompt: buildBatchPrompt,
|
|
2162
|
+
sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
|
|
2163
|
+
waitIdle: async (files, before) => waitForTurn(files, before),
|
|
2164
|
+
readShard: (batch) => readShardFile(batch.shardPath),
|
|
2165
|
+
adoptShard: makeShardAdopter(buildDir, knownShards),
|
|
2166
|
+
applyFile: async (file) => mergeFleshShards({
|
|
2167
|
+
stageDir: rhythmStageDir,
|
|
2168
|
+
buildDir: buildDir,
|
|
2169
|
+
batches: file.batches
|
|
2170
|
+
}),
|
|
2171
|
+
attribute: (errors, layout) => attributeErrors(errors, layout, { codeOwners: RHYTHM_CROSS_FILE_OWNERS }),
|
|
2172
|
+
onProgress: (msg) => { progressNotes.push(msg); }
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
const phaseReports = [];
|
|
2176
|
+
// 第三段的批次规划要提到外面:批次统计要用到它,
|
|
2177
|
+
// 而且它的分片名也要在派活前登记进 knownShards。
|
|
2178
|
+
let planC = null;
|
|
2179
|
+
let keyChapters = null;
|
|
2180
|
+
let report = { ok: false, error: null, files: [], failedFiles: [], errors: [], warnings: [] };
|
|
2181
|
+
|
|
2182
|
+
try {
|
|
2183
|
+
if (!canWait) {
|
|
2184
|
+
// 运行时没暴露 agent:派活必须能等回合结束,没法等就只能明确报错,
|
|
2185
|
+
// 不能装作驱动过了(2026-10 tt5 的教训就是"装作成功"最伤人)。
|
|
2186
|
+
report = {
|
|
2187
|
+
ok: false,
|
|
2188
|
+
error: '当前运行时没有暴露 agent,无法逐批派活节奏阶段(派活需要能等待每一轮结束)',
|
|
2189
|
+
files: [],
|
|
2190
|
+
failedFiles: [],
|
|
2191
|
+
errors: [],
|
|
2192
|
+
warnings: []
|
|
2193
|
+
};
|
|
2194
|
+
} else {
|
|
2195
|
+
// ---- 第一段:章节规划 + 节奏参数(单文件校验) ----
|
|
2196
|
+
const rA = await runStageBatches(Object.assign({}, driverCommon, {
|
|
2197
|
+
files: planA.files,
|
|
2198
|
+
validateScope: 'stage',
|
|
2199
|
+
validateStage: (files) => validateRhythmFiles(files, {
|
|
2200
|
+
validator: validator,
|
|
2201
|
+
templateDir: projectTemplateDir,
|
|
2202
|
+
stageDir: rhythmStageDir,
|
|
2203
|
+
upstream: upstream,
|
|
2204
|
+
projectRoot: projectRoot
|
|
2205
|
+
})
|
|
2206
|
+
}));
|
|
2207
|
+
phaseReports.push(rA);
|
|
2208
|
+
|
|
2209
|
+
// 段间门禁只看**下游真正依赖**的文件:
|
|
2210
|
+
// 时序映射依赖节奏参数 → 节奏参数没过就停(写出来的映射必然对不上)
|
|
2211
|
+
// 章节细纲依赖时序映射 → 时序映射没过就停(重点章算不出来)
|
|
2212
|
+
// 章节规划没过不拦下一段:它不影响时序映射,
|
|
2213
|
+
// 而且最后一段的全量校验会把它的错误重新归因回它的批次再修。
|
|
2214
|
+
let rB = null;
|
|
2215
|
+
if (phaseFileOk(rA, '节奏参数.json')) {
|
|
2216
|
+
rB = await runStageBatches(Object.assign({}, driverCommon, {
|
|
2217
|
+
files: planB.files,
|
|
2218
|
+
validateScope: 'stage',
|
|
2219
|
+
validateStage: (files) => validateRhythmFiles(files, {
|
|
2220
|
+
validator: validator,
|
|
2221
|
+
templateDir: projectTemplateDir,
|
|
2222
|
+
stageDir: rhythmStageDir,
|
|
2223
|
+
upstream: upstream,
|
|
2224
|
+
projectRoot: projectRoot
|
|
2225
|
+
})
|
|
2226
|
+
}));
|
|
2227
|
+
phaseReports.push(rB);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// ---- 第三段:章节细纲(重点章动态规划 + 全量校验) ----
|
|
2231
|
+
let rC = null;
|
|
2232
|
+
if (rB && phaseFileOk(rB, '时序映射.json')) {
|
|
2233
|
+
const chapterPlan = readJsonFileSafe(join(rhythmStageDir, '章节规划.json'));
|
|
2234
|
+
const timeline = readJsonFileSafe(join(rhythmStageDir, '时序映射.json'));
|
|
2235
|
+
keyChapters = computeKeyChapters({
|
|
2236
|
+
chapters: (chapterPlan && chapterPlan.chapters) || [],
|
|
2237
|
+
timeline: timeline || {},
|
|
2238
|
+
climaxTypes: climaxTypes
|
|
2239
|
+
});
|
|
2240
|
+
if (!keyChapters.length) {
|
|
2241
|
+
rC = {
|
|
2242
|
+
ok: false,
|
|
2243
|
+
error: '算不出重点章(章节规划或时序映射没产出有效章节号),章节细纲无法开工',
|
|
2244
|
+
files: [],
|
|
2245
|
+
failedFiles: ['章节细纲.json'],
|
|
2246
|
+
errors: [],
|
|
2247
|
+
warnings: []
|
|
2248
|
+
};
|
|
2249
|
+
} else {
|
|
2250
|
+
planC = planRhythmBatches(Object.assign({}, planBase, {
|
|
2251
|
+
only: ['章节细纲'],
|
|
2252
|
+
keyChapters: keyChapters
|
|
2253
|
+
}));
|
|
2254
|
+
if (!planC.ok) {
|
|
2255
|
+
rC = {
|
|
2256
|
+
ok: false,
|
|
2257
|
+
error: '章节细纲批次规划失败:' + planC.error,
|
|
2258
|
+
files: [],
|
|
2259
|
+
failedFiles: ['章节细纲.json'],
|
|
2260
|
+
errors: [],
|
|
2261
|
+
warnings: []
|
|
2262
|
+
};
|
|
2263
|
+
} else {
|
|
2264
|
+
for (const f of planC.files) {
|
|
2265
|
+
for (const b of f.batches) {
|
|
2266
|
+
fileByBatchKey.set(b.key, f);
|
|
2267
|
+
knownShards.push(b.shard);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
rC = await runStageBatches(Object.assign({}, driverCommon, {
|
|
2271
|
+
files: planC.files,
|
|
2272
|
+
validateScope: 'stage',
|
|
2273
|
+
// 最后一段只能全量判:跨文件口径的错误单文件查不出来
|
|
2274
|
+
validateStage: () => runRhythmValidatorAll({
|
|
2275
|
+
validator: validator,
|
|
2276
|
+
stageDir: rhythmStageDir,
|
|
2277
|
+
templateDir: projectTemplateDir,
|
|
2278
|
+
upstream: upstream,
|
|
2279
|
+
projectRoot: projectRoot
|
|
2280
|
+
})
|
|
2281
|
+
}));
|
|
2282
|
+
phaseReports.push(rC);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
report = mergePhaseReports(phaseReports);
|
|
2288
|
+
if (keyChapters) report.keyChapters = keyChapters;
|
|
2289
|
+
// 段间门禁挡住的原因要说清楚,不能只报"未完成"
|
|
2290
|
+
if (!phaseFileOk(rA, '节奏参数.json')) {
|
|
2291
|
+
report.blocked = '节奏参数没通过校验,时序映射的布局必须与它对齐,本轮没有继续派活时序映射';
|
|
2292
|
+
} else if (rB && !phaseFileOk(rB, '时序映射.json')) {
|
|
2293
|
+
report.blocked = '时序映射没通过校验,章节细纲的重点章依赖它,本轮没有继续派活章节细纲';
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
} catch (err) {
|
|
2297
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2298
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg }));
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// ---- 产出盘点 ----
|
|
2302
|
+
const inspect = inspectRhythmOutput(rhythmStageDir);
|
|
2303
|
+
const status = readRhythmStageStatus(rhythmStageDir);
|
|
2304
|
+
const completionNote = buildRhythmBatchNote({
|
|
2305
|
+
report: report,
|
|
2306
|
+
inspect: inspect,
|
|
2307
|
+
status: status,
|
|
2308
|
+
progressNotes: progressNotes,
|
|
2309
|
+
plans: [planA, planB, planC].filter(Boolean)
|
|
2310
|
+
});
|
|
2311
|
+
const inspectNote = buildRhythmCompletionNote(inspect, status);
|
|
2312
|
+
|
|
2313
|
+
const allPassed = report.ok && inspect.complete && status.allCheck === 'PASSED';
|
|
2314
|
+
const payloadType = allPassed ? 'rhythm_complete' : 'rhythm_incomplete';
|
|
2315
|
+
|
|
2316
|
+
const parts = [setupNote, completionNote, inspectNote].filter(Boolean);
|
|
2317
|
+
const extra = parts.join('\n\n');
|
|
2318
|
+
return notify(session, 'success', storyBuilder.notify({ type: payloadType, phase: '节奏', extra }));
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* 节奏阶段的上游门禁:骨架 + 血肉两个上游都要全量校验通过。
|
|
2323
|
+
*
|
|
2324
|
+
* 判定口径与 rhythm-validate.js 的 checkUpstreamGates 一致
|
|
2325
|
+
* (读上游 .stage-status.json 的 all_check.status)。
|
|
2326
|
+
* 插件侧提前判是为了**不浪费**:上游没过时模型的每一轮产出都是废的。
|
|
2327
|
+
*/
|
|
2328
|
+
function checkRhythmUpstreamGates(skeletonDir, fleshDir) {
|
|
2329
|
+
const dirs = [['骨架', skeletonDir], ['血肉', fleshDir]];
|
|
2330
|
+
for (const pair of dirs) {
|
|
2331
|
+
const name = pair[0];
|
|
2332
|
+
const dir = pair[1];
|
|
2333
|
+
if (!dir || !existsSync(dir)) {
|
|
2334
|
+
return { ok: false, reason: name + '阶段目录不存在:' + (dir || '(未指定)') };
|
|
2335
|
+
}
|
|
2336
|
+
const g = readStageGate(dir);
|
|
2337
|
+
if (!g.ok) {
|
|
2338
|
+
return { ok: false, reason: name + '阶段还没通过全量校验:' + g.reason };
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
return { ok: true, reason: null };
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* 逐个文件跑节奏校验器的单文件模式,把结果汇总成阶段级结果。
|
|
2346
|
+
*
|
|
2347
|
+
* 单文件模式能查出来的:章节规划覆盖 1..T / 卷存在 / 幕合法、
|
|
2348
|
+
* 时序映射的 id 引用完整性 / 章节号区间 / 递增关系、
|
|
2349
|
+
* 节奏参数的 7 条约束可行性 / derivations 键名。
|
|
2350
|
+
* 这些都不依赖其它文件,所以前两段生成完就能立刻判,不用等全部产物齐。
|
|
2351
|
+
*/
|
|
2352
|
+
async function validateRhythmFiles(files, opts) {
|
|
2353
|
+
const errors = [];
|
|
2354
|
+
const warnings = [];
|
|
2355
|
+
const fileStatus = {};
|
|
2356
|
+
let status = 0;
|
|
2357
|
+
for (const file of files || []) {
|
|
2358
|
+
const r = await runRhythmValidator({
|
|
2359
|
+
validator: opts.validator,
|
|
2360
|
+
input: join(opts.stageDir, file.file),
|
|
2361
|
+
template: join(opts.templateDir, file.template),
|
|
2362
|
+
fileType: file.type,
|
|
2363
|
+
upstream: opts.upstream,
|
|
2364
|
+
projectRoot: opts.projectRoot
|
|
2365
|
+
});
|
|
2366
|
+
for (const e of r.errors || []) errors.push(e);
|
|
2367
|
+
for (const w of r.warnings || []) warnings.push(w);
|
|
2368
|
+
fileStatus[file.file] = r.status;
|
|
2369
|
+
if (r.status !== 0) {
|
|
2370
|
+
// 2 = 脚本异常(比内容错误更严重),取最大值
|
|
2371
|
+
status = Math.max(status, r.status === 2 ? 2 : 1);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return { status: status, errors: errors, warnings: warnings, fileStatus: fileStatus };
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* 某个文件在一次驱动里过没过。
|
|
2379
|
+
*
|
|
2380
|
+
* 阶段级校验把多个文件的错误混在一起返回,但段间门禁只关心
|
|
2381
|
+
* "下游依赖的那个文件"过没过——不能因为章节规划没过就把整条链掐断。
|
|
2382
|
+
*/
|
|
2383
|
+
/**
|
|
2384
|
+
* 造一份"这一段的文件都已定稿,本轮没有派活"的段报告。
|
|
2385
|
+
*
|
|
2386
|
+
* 续跑用:已定稿的文件不派活重做,但它们必须出现在段报告里 ——
|
|
2387
|
+
* 段与段之间的依赖门禁(phaseFileOk)靠它判断能不能继续往下走。
|
|
2388
|
+
*
|
|
2389
|
+
* @param {Array<string>} files 已定稿的文件名
|
|
2390
|
+
* @returns {Object} 段报告
|
|
2391
|
+
*/
|
|
2392
|
+
function skippedFilesReport(files) {
|
|
2393
|
+
return {
|
|
2394
|
+
ok: true,
|
|
2395
|
+
error: null,
|
|
2396
|
+
files: (files || []).map((f) => ({ file: f, ok: true, skipped: true })),
|
|
2397
|
+
failedFiles: [],
|
|
2398
|
+
batches: [],
|
|
2399
|
+
errors: [],
|
|
2400
|
+
warnings: [],
|
|
2401
|
+
unattributed: [],
|
|
2402
|
+
infraErrors: [],
|
|
2403
|
+
totalBatches: 0,
|
|
2404
|
+
totalRounds: 0
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function phaseFileOk(report, file) {
|
|
2409
|
+
if (!report) return false;
|
|
2410
|
+
const fr = (report.files || []).find((f) => f.file === file);
|
|
2411
|
+
if (!fr) return false;
|
|
2412
|
+
return !!fr.ok;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/**
|
|
2416
|
+
* 把三段的驱动报告合成一份。
|
|
2417
|
+
*
|
|
2418
|
+
* 结论以**最后一段**为准:后面的段可能已经把前面的问题修掉了
|
|
2419
|
+
* (比如第一段章节规划没过,第三段的全量校验又把它修好了)。
|
|
2420
|
+
* 如果按"任一段没过就整体没过"来判,会出现"实际全过了却报未完成"。
|
|
2421
|
+
*/
|
|
2422
|
+
function mergePhaseReports(reports) {
|
|
2423
|
+
const out = {
|
|
2424
|
+
ok: true, error: null, files: [], failedFiles: [],
|
|
2425
|
+
errors: [], warnings: [], unattributed: [], batches: [],
|
|
2426
|
+
// 脚本/环境问题:跨段累积(不重投给模型,但必须一路带到最终报告)
|
|
2427
|
+
infraErrors: []
|
|
2428
|
+
};
|
|
2429
|
+
for (const r of reports || []) {
|
|
2430
|
+
if (!r) continue;
|
|
2431
|
+
for (const f of r.files || []) {
|
|
2432
|
+
const exist = out.files.find((x) => x.file === f.file);
|
|
2433
|
+
if (!exist) {
|
|
2434
|
+
out.files.push(Object.assign({}, f));
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
exist.rounds = (exist.rounds || 0) + (f.rounds || 0);
|
|
2438
|
+
if (f.ok) {
|
|
2439
|
+
exist.ok = true;
|
|
2440
|
+
exist.error = null;
|
|
2441
|
+
} else if (!exist.ok && f.error) {
|
|
2442
|
+
exist.error = f.error;
|
|
2443
|
+
}
|
|
2444
|
+
if ((f.errors || []).length) exist.errors = f.errors;
|
|
2445
|
+
if ((f.warnings || []).length) exist.warnings = f.warnings;
|
|
2446
|
+
exist.batches = (exist.batches || []).concat(f.batches || []);
|
|
2447
|
+
}
|
|
2448
|
+
for (const b of r.batches || []) out.batches.push(b);
|
|
2449
|
+
for (const e of r.errors || []) out.errors.push(e);
|
|
2450
|
+
for (const w of r.warnings || []) out.warnings.push(w);
|
|
2451
|
+
for (const e of r.unattributed || []) out.unattributed.push(e);
|
|
2452
|
+
for (const e of r.infraErrors || []) out.infraErrors.push(e);
|
|
2453
|
+
|
|
2454
|
+
// 最后一段说了算
|
|
2455
|
+
out.ok = !!r.ok;
|
|
2456
|
+
out.failedFiles = (r.failedFiles || []).slice();
|
|
2457
|
+
out.error = r.ok ? null : (r.error || null);
|
|
2458
|
+
}
|
|
2459
|
+
if (!out.ok && !out.error) {
|
|
2460
|
+
out.error = '有阶段没通过校验:' + out.failedFiles.join('、');
|
|
2461
|
+
}
|
|
2462
|
+
return out;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/**
|
|
2466
|
+
* 渲染"节奏参数"的真实值,给时序映射批次照抄。
|
|
2467
|
+
* 节奏参数.json 是小对象(3 个整数 + 1 个比率 + 7 条 derivations),可以直接读。
|
|
2468
|
+
*/
|
|
2469
|
+
function buildRhythmParamContext(rhythmStageDir) {
|
|
2470
|
+
const params = readJsonFileSafe(join(rhythmStageDir, '节奏参数.json'));
|
|
2471
|
+
if (!params || typeof params !== 'object') return null;
|
|
2472
|
+
const lines = ['节奏参数(已由上一段产出,下面是真实值,时序映射必须与它对齐):'];
|
|
2473
|
+
const keys = ['branch_chapter_total', 'overlap_chapter_count', 'main_progress_chapter_count'];
|
|
2474
|
+
for (const k of keys) {
|
|
2475
|
+
if (params[k] === undefined) continue;
|
|
2476
|
+
lines.push('- ' + k + ' = ' + JSON.stringify(params[k]));
|
|
2477
|
+
}
|
|
2478
|
+
if (params.chapter_hook_rate && typeof params.chapter_hook_rate === 'object') {
|
|
2479
|
+
lines.push('- chapter_hook_rate = ' + JSON.stringify(params.chapter_hook_rate));
|
|
2480
|
+
lines.push(' hook_timeline 的章数下限 = ceil(T * rate)'
|
|
2481
|
+
+ (params.chapter_hook_rate.is_minimum ? '(is_minimum=true,是硬下限)' : '(is_minimum=false,只是参考)'));
|
|
2482
|
+
}
|
|
2483
|
+
return lines;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* 找同一文件里上一批的收尾(章节按时间推进,本批要承接上一批)。
|
|
2488
|
+
*/
|
|
2489
|
+
function buildRhythmPrevTailFor(file, batch, buildDir) {
|
|
2490
|
+
if (!file || !file.batches || !file.batches.length) return null;
|
|
2491
|
+
const idx = file.batches.findIndex((b) => b.key === batch.key);
|
|
2492
|
+
if (idx <= 0) return null;
|
|
2493
|
+
const prev = file.batches[idx - 1];
|
|
2494
|
+
const shard = readJsonFileSafe(join(buildDir, prev.shard));
|
|
2495
|
+
return buildRhythmPrevTail({ shard: shard, arrayKeys: prev.arrays });
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* 节奏阶段结论:把"派了多少批、哪些文件没过、错在哪"讲清楚。
|
|
2500
|
+
*
|
|
2501
|
+
* 与血肉同款:失败必须可见,不能只报"阶段开始"。
|
|
2502
|
+
*/
|
|
2503
|
+
function buildRhythmBatchNote(opts) {
|
|
2504
|
+
const o = opts || {};
|
|
2505
|
+
const report = o.report || {};
|
|
2506
|
+
const plans = o.plans || [];
|
|
2507
|
+
const lines = [];
|
|
2508
|
+
|
|
2509
|
+
// 批次数按**去重后的批次**算:重投会让 report.batches 里出现同一批的多条记录,
|
|
2510
|
+
// 直接 length 会算出 "18/13" 这种自相矛盾的数。
|
|
2511
|
+
const totalKeys = new Set();
|
|
2512
|
+
for (const p of plans) {
|
|
2513
|
+
for (const b of (p.batches || [])) totalKeys.add(b.key);
|
|
2514
|
+
}
|
|
2515
|
+
const okKeys = new Set();
|
|
2516
|
+
for (const b of (report.batches || [])) {
|
|
2517
|
+
if (b.ok) okKeys.add(b.key);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
if (report.error) {
|
|
2521
|
+
lines.push('❌ 节奏阶段未完成:' + report.error);
|
|
2522
|
+
} else if (report.ok) {
|
|
2523
|
+
lines.push('✅ 节奏阶段逐批派活完成:' + okKeys.size + '/' + totalKeys.size + ' 批通过校验。');
|
|
2524
|
+
} else {
|
|
2525
|
+
lines.push('❌ 节奏阶段未完成:' + ((report.failedFiles || []).join('、') || '有批次没通过校验') + '。');
|
|
2526
|
+
lines.push('(已通过 ' + okKeys.size + '/' + totalKeys.size + ' 批)');
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if (report.blocked) {
|
|
2530
|
+
lines.push('');
|
|
2531
|
+
lines.push('⛔ ' + report.blocked);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
const errs = report.errors || [];
|
|
2535
|
+
if (errs.length) {
|
|
2536
|
+
lines.push('');
|
|
2537
|
+
lines.push('**校验错误(' + errs.length + ' 条)**');
|
|
2538
|
+
for (const e of errs.slice(0, 15)) {
|
|
2539
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || '') + (e.hint ? '(' + e.hint + ')' : ''));
|
|
2540
|
+
}
|
|
2541
|
+
if (errs.length > 15) lines.push('- ……还有 ' + (errs.length - 15) + ' 条');
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// 脚本/环境问题单独成段:它们没有重投给模型,混在错误列表里会误导
|
|
2545
|
+
for (const l of renderInfraBlock(report.infraErrors)) lines.push(l);
|
|
2546
|
+
|
|
2547
|
+
const un = report.unattributed || [];
|
|
2548
|
+
if (un.length) {
|
|
2549
|
+
lines.push('');
|
|
2550
|
+
lines.push('**归因不到具体批次的错误(' + un.length + ' 条,重投时不会自动修)**');
|
|
2551
|
+
for (const e of un.slice(0, 8)) {
|
|
2552
|
+
lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || ''));
|
|
2553
|
+
}
|
|
2554
|
+
if (un.length > 8) lines.push('- ……还有 ' + (un.length - 8) + ' 条');
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const notes = o.progressNotes || [];
|
|
2558
|
+
if (notes.length) {
|
|
2559
|
+
lines.push('');
|
|
2560
|
+
lines.push('进度(共 ' + notes.length + ' 条):' + notes.slice(-3).join(';'));
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
return lines.join('\n');
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* 处理 wiki 命令 - 对 raw/sources 执行 ingest
|
|
2568
|
+
*/
|
|
2569
|
+
async function handleWiki(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken) {
|
|
2570
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
2571
|
+
const { signal } = invocation;
|
|
2572
|
+
// 确保 setup_id 存在
|
|
2573
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
2574
|
+
const setupId = setupResult.id;
|
|
2575
|
+
if (!setupId) {
|
|
2576
|
+
// 本阶段要真的拿 setup_id 去调 llm-wiki API,取不到就必须停,并说清怎么修
|
|
2577
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2578
|
+
type: 'setup_id_unavailable',
|
|
2579
|
+
err: setupResult.reason,
|
|
2580
|
+
detail: setupResult.detail
|
|
2581
|
+
}));
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// 准备"设定"目录并拷贝 schema.md(本阶段只认 schema.md,不需要 purpose.md)。
|
|
2585
|
+
// 之前这里是手写的拷贝:不建目录(目录不存在时静默失败),
|
|
2586
|
+
// 而且 catch 把错误吞掉,用户完全看不到。
|
|
2587
|
+
const schemaCopy = await copySchemaToProject(projectRoot, { includePurpose: false });
|
|
2588
|
+
const schemaNote = schemaCopy.ok
|
|
2589
|
+
? ''
|
|
2590
|
+
: storyBuilder.notify({ type: 'schema_copy_failed', detail: schemaCopy.detail });
|
|
2591
|
+
|
|
2592
|
+
// 只读取 raw/sources/节奏 目录下的文件
|
|
2593
|
+
const rhythmDir = join(projectRoot, '设定', 'raw', 'sources', '节奏');
|
|
2594
|
+
if (!existsSync(rhythmDir)) {
|
|
2595
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_sources_dir' }));
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// 收集节奏目录下所有 .md 文件
|
|
2599
|
+
const filesToIngest = [];
|
|
2600
|
+
const collectErrors = [];
|
|
2601
|
+
function collectFiles(dir, basePath = '') {
|
|
2602
|
+
let entries;
|
|
2603
|
+
try {
|
|
2604
|
+
entries = readdirSync(dir);
|
|
2605
|
+
} catch (e) {
|
|
2606
|
+
collectErrors.push(`读取目录失败 ${dir}: ${e.message}`);
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
for (const entry of entries) {
|
|
2610
|
+
const fullPath = join(dir, entry);
|
|
2611
|
+
const relativePath = basePath ? `${basePath}/${entry}` : entry;
|
|
2612
|
+
let stat;
|
|
2613
|
+
try {
|
|
2614
|
+
stat = statSync(fullPath);
|
|
2615
|
+
} catch (e) {
|
|
2616
|
+
collectErrors.push(`读取条目失败 ${fullPath}: ${e.message}`);
|
|
2617
|
+
continue;
|
|
2618
|
+
}
|
|
2619
|
+
if (stat.isDirectory()) {
|
|
2620
|
+
collectFiles(fullPath, relativePath);
|
|
2621
|
+
} else if (stat.isFile() && entry.endsWith('.md')) {
|
|
2622
|
+
filesToIngest.push(`raw/sources/${relativePath}`);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
collectFiles(rhythmDir, '节奏');
|
|
2627
|
+
|
|
2628
|
+
if (collectErrors.length > 0) {
|
|
2629
|
+
console.error('[fancy-story-builder] 收集文件时出错:\n ' + collectErrors.join('\n '));
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if (filesToIngest.length === 0) {
|
|
2633
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'no_files_to_ingest' }));
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// 清除 ingest-cache 中对应文件的 hash(强制重新解析)
|
|
2637
|
+
const ingestCachePath = join(projectRoot, '设定', '.llm-wiki', 'ingest-cache.json');
|
|
2638
|
+
if (existsSync(ingestCachePath)) {
|
|
2639
|
+
try {
|
|
2640
|
+
const cacheContent = readFileSync(ingestCachePath, 'utf-8');
|
|
2641
|
+
const cacheData = JSON.parse(cacheContent);
|
|
2642
|
+
let cleared = 0;
|
|
2643
|
+
for (const filePath of filesToIngest) {
|
|
2644
|
+
// llm-wiki 的缓存键是相对 raw/sources/ 的路径(如 "节奏/主线大纲.md"),
|
|
2645
|
+
// 不是文件名。之前用 basename 查永远 miss,导致"强制重新解析"形同虚设。
|
|
2646
|
+
const cacheKey = filePath.startsWith('raw/sources/')
|
|
2647
|
+
? filePath.slice('raw/sources/'.length)
|
|
2648
|
+
: filePath;
|
|
2649
|
+
if (cacheData.entries && cacheData.entries[cacheKey]) {
|
|
2650
|
+
cacheData.entries[cacheKey].hash = '';
|
|
2651
|
+
cleared++;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
writeFileSync(ingestCachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
2655
|
+
console.error(`[fancy-story-builder] 已清除 ${cleared}/${filesToIngest.length} 个 ingest-cache 条目的 hash`);
|
|
2656
|
+
} catch (e) {
|
|
2657
|
+
console.error(`[fancy-story-builder] 更新 ingest-cache 失败:${e.message}`);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// 调用 ingest 接口
|
|
2662
|
+
try {
|
|
2663
|
+
const result = await callLlmWikiApi(
|
|
2664
|
+
llmWikiUrl,
|
|
2665
|
+
llmWikiToken,
|
|
2666
|
+
`/projects/${setupId}/sources/ingest`,
|
|
2667
|
+
'POST',
|
|
2668
|
+
{ paths: filesToIngest },
|
|
2669
|
+
signal
|
|
2670
|
+
);
|
|
2671
|
+
// 必须检查响应体:llm-wiki 的错误约定是 HTTP 200 + { ok:false, error:"..." },
|
|
2672
|
+
// 不检查就会把失败当成"已启动 ingest"报给用户。
|
|
2673
|
+
if (result.status >= 400) {
|
|
2674
|
+
const msg = (result.data && (result.data.error || result.data.message)) || ('HTTP ' + result.status);
|
|
2675
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'ingest_failed', err: msg }));
|
|
2676
|
+
}
|
|
2677
|
+
if (result.data && result.data.ok === false) {
|
|
2678
|
+
const msg = result.data.error || '接口返回 ok:false';
|
|
2679
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'ingest_failed', err: msg }));
|
|
2680
|
+
}
|
|
2681
|
+
return notify(session, 'success', storyBuilder.notify({ type: 'ingest_started', fileCount: filesToIngest.length, extra: schemaNote }));
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
const errMsg = describeFetchError(err, LLM_WIKI_TIMEOUT_MS);
|
|
2684
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'ingest_failed', err: errMsg }));
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/**
|
|
2689
|
+
* 查询 ingest 状态
|
|
2690
|
+
*/
|
|
2691
|
+
async function queryIngestStatus(llmWikiUrl, llmWikiToken, projectId, signal = null) {
|
|
2692
|
+
return callLlmWikiApi(llmWikiUrl, llmWikiToken, `/projects/${projectId}/ingest/querystatus`, 'GET', null, signal);
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* 获取 reviews 列表
|
|
2697
|
+
*/
|
|
2698
|
+
async function getReviews(llmWikiUrl, llmWikiToken, projectId, signal = null) {
|
|
2699
|
+
return callLlmWikiApi(llmWikiUrl, llmWikiToken, `/projects/${projectId}/reviews`, 'GET', null, signal);
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* 处理分析阶段
|
|
2704
|
+
*/
|
|
2705
|
+
async function handleAnalyze(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences) {
|
|
2706
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
2707
|
+
const { signal } = invocation;
|
|
2708
|
+
// 确保 setup_id 存在(幂等调用)
|
|
2709
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
2710
|
+
const setupId = setupResult.id;
|
|
2711
|
+
// 说明文字要并入最终返回的消息:notify 的中间返回值会被丢弃,
|
|
2712
|
+
// 单独发一条 info 用户根本看不到。
|
|
2713
|
+
let setupNote = '';
|
|
2714
|
+
if (!setupId) {
|
|
2715
|
+
// 本阶段要真的拿 setup_id 去调 llm-wiki API,取不到就必须停,并说清怎么修
|
|
2716
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2717
|
+
type: 'setup_id_unavailable',
|
|
2718
|
+
err: setupResult.reason,
|
|
2719
|
+
detail: setupResult.detail
|
|
2720
|
+
}));
|
|
2721
|
+
}
|
|
2722
|
+
if (setupResult.reused) {
|
|
2723
|
+
setupNote = storyBuilder.notify({ type: 'setup_id_reused', id: setupId, source: setupResult.source });
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// 准备"设定"目录并拷贝 schema 文件(返回值必须检查:拷失败要能看见)
|
|
2727
|
+
const schemaCopy = await copySchemaToProject(projectRoot);
|
|
2728
|
+
if (!schemaCopy.ok) {
|
|
2729
|
+
setupNote = (setupNote ? setupNote + '\n\n' : '') + storyBuilder.notify({
|
|
2730
|
+
type: 'schema_copy_failed',
|
|
2731
|
+
detail: schemaCopy.detail
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// 1. 查询 ingest 状态
|
|
2736
|
+
try {
|
|
2737
|
+
const statusResult = await queryIngestStatus(llmWikiUrl, llmWikiToken, setupId, signal);
|
|
2738
|
+
if (statusResult && statusResult.data) {
|
|
2739
|
+
const { missingFiles, queue } = statusResult.data;
|
|
2740
|
+
|
|
2741
|
+
// 队列可能是数组,也可能是对象 { pending, processing, done, failed, cancelled }。
|
|
2742
|
+
// 之前只按数组处理(queue.length),对象形式下 queue.length 恒为 undefined,
|
|
2743
|
+
// 导致"还有任务在跑"这个判断永远不生效。
|
|
2744
|
+
const queueCount = Array.isArray(queue)
|
|
2745
|
+
? queue.length
|
|
2746
|
+
: (queue && typeof queue === 'object'
|
|
2747
|
+
? (queue.pending || 0) + (queue.processing || 0)
|
|
2748
|
+
: 0);
|
|
2749
|
+
|
|
2750
|
+
// 检查是否有未 ingest 的文件或正在进行中的任务
|
|
2751
|
+
if ((missingFiles && missingFiles.length > 0) || queueCount > 0) {
|
|
2752
|
+
const missingCount = missingFiles ? missingFiles.length : 0;
|
|
2753
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2754
|
+
type: 'ingest_not_ready',
|
|
2755
|
+
missingCount,
|
|
2756
|
+
queueCount,
|
|
2757
|
+
}));
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
// querystatus 失败不影响主流程,仅记录
|
|
2762
|
+
console.error(`[fancy-story-builder] querystatus failed: ${describeFetchError(err, LLM_WIKI_TIMEOUT_MS)}`);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// 2. 获取 reviews
|
|
2766
|
+
let reviewsContent = '';
|
|
2767
|
+
try {
|
|
2768
|
+
const reviewsResult = await getReviews(llmWikiUrl, llmWikiToken, setupId, signal);
|
|
2769
|
+
if (reviewsResult && reviewsResult.data && reviewsResult.data.reviews) {
|
|
2770
|
+
const reviews = reviewsResult.data.reviews;
|
|
2771
|
+
|
|
2772
|
+
// 分类整理 reviews
|
|
2773
|
+
const contradiction = reviews.filter(r => r.type === 'contradiction');
|
|
2774
|
+
const confirm = reviews.filter(r => r.type === 'confirm');
|
|
2775
|
+
const suggestion = reviews.filter(r => r.type === 'suggestion');
|
|
2776
|
+
|
|
2777
|
+
reviewsContent = `\n\n## LLM Wiki 审查结果\n\n`;
|
|
2778
|
+
reviewsContent += `共发现 ${reviews.length} 个审查项:\n\n`;
|
|
2779
|
+
|
|
2780
|
+
if (contradiction.length > 0) {
|
|
2781
|
+
reviewsContent += `### ⚠️ 矛盾 (${contradiction.length}项)\n\n`;
|
|
2782
|
+
for (const r of contradiction) {
|
|
2783
|
+
reviewsContent += `- **${r.title}**\n - 位置:${r.sourcePath || '未知'}\n - 描述:${r.description || '无描述'}\n\n`;
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
if (confirm.length > 0) {
|
|
2788
|
+
reviewsContent += `### ❓ 需要确认 (${confirm.length}项)\n\n`;
|
|
2789
|
+
for (const r of confirm) {
|
|
2790
|
+
reviewsContent += `- **${r.title}**\n - 位置:${r.sourcePath || '未知'}\n - 描述:${r.description || '无描述'}\n\n`;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
if (suggestion.length > 0) {
|
|
2795
|
+
reviewsContent += `### 💡 改进建议 (${suggestion.length}项)\n\n`;
|
|
2796
|
+
for (const r of suggestion) {
|
|
2797
|
+
reviewsContent += `- **${r.title}**\n - 位置:${r.sourcePath || '未知'}\n - 描述:${r.description || '无描述'}\n\n`;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
// reviews 失败不影响主流程,仅记录
|
|
2803
|
+
console.error(`[fancy-story-builder] getReviews failed: ${describeFetchError(err, LLM_WIKI_TIMEOUT_MS)}`);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// 3. 生成提示词(包含 reviews 内容)
|
|
2807
|
+
const prompt = storyBuilder.generateAnalyzeSystemPrompt({ topicData, wikiReferences, reviewsContent });
|
|
2808
|
+
|
|
2809
|
+
try {
|
|
2810
|
+
const follow = await llmFollowup(invocation, prompt);
|
|
2811
|
+
// llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
|
|
2812
|
+
// 否则投递失败也会报"阶段开始"
|
|
2813
|
+
if (!follow || follow.ok !== true) {
|
|
2814
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: (follow && follow.error) || '提示词投递失败' }));
|
|
2815
|
+
}
|
|
2816
|
+
return notify(session, 'success', storyBuilder.notify({ type: 'phase_started', phase: '分析', extra: setupNote }));
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2819
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg }));
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/**
|
|
2824
|
+
* 处理写作阶段(直接走 prompt 4 - 逐章生成 + 校验 + 台账)
|
|
2825
|
+
*/
|
|
2826
|
+
async function handleWriting(ctx, session, invocation, projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, topicData, wikiReferences) {
|
|
2827
|
+
// 参考分层说明(插件指导 vs 对标样本)+ 本阶段可读的对标文件清单
|
|
2828
|
+
const benchmarkRefs = renderBenchmarkGuidance(projectRoot, 'writing');
|
|
2829
|
+
// 用户中断时让底层 HTTP 请求一起取消(此前 signal 取了却没用)
|
|
2830
|
+
const { signal } = invocation;
|
|
2831
|
+
// 确保 setup_id 存在(幂等调用)
|
|
2832
|
+
const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
|
|
2833
|
+
const setupId = setupResult.id;
|
|
2834
|
+
// 说明文字要并入最终返回的消息:notify 的中间返回值会被丢弃,
|
|
2835
|
+
// 单独发一条 info 用户根本看不到。
|
|
2836
|
+
let setupNote = '';
|
|
2837
|
+
if (!setupId) {
|
|
2838
|
+
// "设定"目录必须先是 llm-wiki 里的项目,本阶段的产物才会被 ingest 纳管
|
|
2839
|
+
// (实测:骨架 7 + 血肉 13 + 节奏 55 个文件全部进了 llm-wiki 的 ingest-cache)。
|
|
2840
|
+
// 拿不到 setup_id = 设定项目没建成 → 继续跑只会产出不被纳管的半成品,
|
|
2841
|
+
// 而且后续 wiki / analyze 阶段必然失败。必须硬失败。
|
|
2842
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2843
|
+
type: 'setup_id_unavailable',
|
|
2844
|
+
err: setupResult.reason,
|
|
2845
|
+
detail: setupResult.detail
|
|
2846
|
+
}));
|
|
2847
|
+
}
|
|
2848
|
+
if (setupResult.reused) {
|
|
2849
|
+
setupNote = storyBuilder.notify({ type: 'setup_id_reused', id: setupId, source: setupResult.source });
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// 准备"设定"目录并拷贝 schema 文件(返回值必须检查:拷失败要能看见)
|
|
2853
|
+
const schemaCopy = await copySchemaToProject(projectRoot);
|
|
2854
|
+
if (!schemaCopy.ok) {
|
|
2855
|
+
setupNote = (setupNote ? setupNote + '\n\n' : '') + storyBuilder.notify({
|
|
2856
|
+
type: 'schema_copy_failed',
|
|
2857
|
+
detail: schemaCopy.detail
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// ============================================================
|
|
2862
|
+
// 写作阶段:插件驱动、按批执行
|
|
2863
|
+
// ============================================================
|
|
2864
|
+
// 旧模式:投一次提示词就返回"写作阶段开始",T 章全靠 LLM 自己 for 循环,
|
|
2865
|
+
// 插件对写作流程零控制(漏章/跳章/校验失败不重试/台账不更新都发现不了)。
|
|
2866
|
+
// 新模式:插件自己按批循环,每批注入细纲与上下文,**自己跑校验器**,
|
|
2867
|
+
// 失败的章回喂重试,通过才推进。
|
|
2868
|
+
const context = loadWritingContext(projectRoot);
|
|
2869
|
+
if (context.error) {
|
|
2870
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: context.error }));
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
const upstreamDirs = [
|
|
2874
|
+
join(projectRoot, '设定', 'raw', 'sources', '骨架'),
|
|
2875
|
+
join(projectRoot, '设定', 'raw', 'sources', '血肉'),
|
|
2876
|
+
join(projectRoot, '设定', 'raw', 'sources', '节奏')
|
|
2877
|
+
];
|
|
2878
|
+
const validator = join(projectRoot, 'references', 'writing-validate.js');
|
|
2879
|
+
|
|
2880
|
+
// 这条信息不能只 notify 出去:DSH 只有 handler 的返回值会经
|
|
2881
|
+
// settleThrown → command/done → channel.notify(),中间调用丢弃 = 用户看不到。
|
|
2882
|
+
// 并入最终返回消息。
|
|
2883
|
+
const writingNote = '写作阶段:共 ' + context.T + ' 章,按每批 ' + WRITING_BATCH_SIZE + ' 章由插件逐批驱动。';
|
|
2884
|
+
setupNote = setupNote ? setupNote + '\n\n' + writingNote : writingNote;
|
|
2885
|
+
|
|
2886
|
+
// 逐批进度同样只在最终消息里给摘要
|
|
2887
|
+
const progressNotes = [];
|
|
2888
|
+
|
|
2889
|
+
// signal 必须传:用户按 Ctrl-C 之后不该还在等 45 分钟。
|
|
2890
|
+
// idleGraceMs 与其余阶段对齐(60 秒):15 秒的宽限期会把"排队稍慢"
|
|
2891
|
+
// 误判成超时,整段写作直接中断。
|
|
2892
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
2893
|
+
timeoutMs: 45 * 60 * 1000,
|
|
2894
|
+
idleGraceMs: 60000,
|
|
2895
|
+
signal: invocation.signal
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
// 写作执行须知(产物契约):每批派活都带一遍
|
|
2899
|
+
const contract = storyBuilder.generateWritingContract({ topicData, benchmarkRefs });
|
|
2900
|
+
|
|
2901
|
+
let report;
|
|
2902
|
+
try {
|
|
2903
|
+
report = await runWritingStage({
|
|
2904
|
+
context,
|
|
2905
|
+
projectRoot,
|
|
2906
|
+
contract,
|
|
2907
|
+
sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
|
|
2908
|
+
waitIdle: async (expectedFiles) => {
|
|
2909
|
+
const r = await waitForTurn(expectedFiles);
|
|
2910
|
+
if (!r.ok) {
|
|
2911
|
+
const err = new Error(r.reason || '等待 LLM 失败');
|
|
2912
|
+
err.__writingWaitFailed = true;
|
|
2913
|
+
throw err;
|
|
2914
|
+
}
|
|
2915
|
+
return r;
|
|
2916
|
+
},
|
|
2917
|
+
validateChapter: async (ch) => {
|
|
2918
|
+
const file = join(context.dirs.writing, chapterFileName(ch));
|
|
2919
|
+
if (!existsSync(file)) {
|
|
2920
|
+
return {
|
|
2921
|
+
status: 2,
|
|
2922
|
+
errors: [{ code: 'CHAPTER_MISSING', path: file, message: '第 ' + ch + ' 章文件没有生成:' + file, hint: 'LLM 本轮没有产出该章' }],
|
|
2923
|
+
warnings: []
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
const r = await spawnScript('node', [
|
|
2927
|
+
validator,
|
|
2928
|
+
'--input=' + file,
|
|
2929
|
+
'--chapter=' + ch,
|
|
2930
|
+
'--upstream=' + upstreamDirs.join(',')
|
|
2931
|
+
], { timeout: 120000, cwd: projectRoot });
|
|
2932
|
+
if (!r.ok) {
|
|
2933
|
+
return {
|
|
2934
|
+
status: 2,
|
|
2935
|
+
errors: [{ code: 'VALIDATOR_CRASH', path: file, message: '校验脚本异常:' + (r.error || r.stderr || '未知错误'), hint: null }],
|
|
2936
|
+
warnings: []
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
try {
|
|
2940
|
+
const parsed = JSON.parse(r.output);
|
|
2941
|
+
return { status: parsed.status, errors: parsed.errors || [], warnings: parsed.warnings || [] };
|
|
2942
|
+
} catch (e) {
|
|
2943
|
+
return {
|
|
2944
|
+
status: 2,
|
|
2945
|
+
errors: [{ code: 'VALIDATOR_OUTPUT_INVALID', path: file, message: '校验脚本输出不是合法 JSON:' + String(e.message), hint: null }],
|
|
2946
|
+
warnings: []
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
},
|
|
2950
|
+
// 进度消息不能直接 notify 出去:DSH 只有 handler 的返回值会被用户看到,
|
|
2951
|
+
// 中间调用一律丢弃。改为收集起来,在最终消息里给摘要
|
|
2952
|
+
// (T=500 有 50 批,全塞进去太长)。
|
|
2953
|
+
onProgress: (msg) => { progressNotes.push(msg); }
|
|
2954
|
+
});
|
|
2955
|
+
} catch (err) {
|
|
2956
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2957
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg, extra: setupNote }));
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
if (!report.ok) {
|
|
2961
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: report.error || '写作阶段中断', extra: setupNote }));
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// 逐批进度的摘要:写进最终消息,用户才看得到"跑到哪了"
|
|
2965
|
+
const progressSummary = progressNotes.length
|
|
2966
|
+
? '写作进度(共 ' + progressNotes.length + ' 条):' +
|
|
2967
|
+
progressNotes.slice(-3).join(';')
|
|
2968
|
+
: '';
|
|
2969
|
+
const finalNote = progressSummary
|
|
2970
|
+
? (setupNote ? setupNote + '\n\n' + progressSummary : progressSummary)
|
|
2971
|
+
: setupNote;
|
|
2972
|
+
|
|
2973
|
+
// 续跑复检没过的章(空文件/半截/标题错/校验不过)——要说明它们被重写了,
|
|
2974
|
+
// 否则用户看到"复用 N 章"却不知道有几章被判定为不合格。
|
|
2975
|
+
const recheckNote = (report.recheckFailed && report.recheckFailed.length)
|
|
2976
|
+
? '复检未通过、已重新写的有 ' + report.recheckFailed.length + ' 章(章号:' +
|
|
2977
|
+
report.recheckFailed.map((r) => r.ch).join('、') + ')。'
|
|
2978
|
+
: '';
|
|
2979
|
+
|
|
2980
|
+
if (report.failed.length > 0) {
|
|
2981
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
2982
|
+
type: 'writing_incomplete',
|
|
2983
|
+
written: report.written.length,
|
|
2984
|
+
reused: (report.reused || []).length,
|
|
2985
|
+
failed: report.failed.map((f) => f.ch),
|
|
2986
|
+
extra: [finalNote, recheckNote].filter(Boolean).join('\n\n')
|
|
2987
|
+
}));
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
return notify(session, 'success', storyBuilder.notify({
|
|
2991
|
+
type: 'writing_done',
|
|
2992
|
+
count: report.written.length,
|
|
2993
|
+
reused: (report.reused || []).length,
|
|
2994
|
+
extra: [finalNote, recheckNote].filter(Boolean).join('\n\n')
|
|
2995
|
+
}));
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* 处理去味阶段(插件驱动、逐章执行)
|
|
3000
|
+
*
|
|
3001
|
+
* 与写作阶段同一套骨架:插件派活 → 等回合 → 自己跑检测器 → 判结果 → 回喂重试。
|
|
3002
|
+
* 去味特有的三件事:
|
|
3003
|
+
* - 改写**只改 AI 味**(模型改,插件不替模型做正则替换);
|
|
3004
|
+
* - 改动比例超 35% 自动回滚(旧 SKILL.md 承诺了,代码里从来没实现);
|
|
3005
|
+
* - 报告由插件按检测数据生成(不让模型自己总结,免得"装作成功")。
|
|
3006
|
+
*
|
|
3007
|
+
* 不需要 setup_id:去味只改本地正文文件,不往"设定"目录新增设定产物。
|
|
3008
|
+
*/
|
|
3009
|
+
async function handleDeslop(ctx, session, invocation, projectRoot, deployedConfig, topicData) {
|
|
3010
|
+
const dirs = deslopDirs(projectRoot);
|
|
3011
|
+
const chapters = listChapterFiles(projectRoot);
|
|
3012
|
+
if (chapters.length === 0) {
|
|
3013
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
3014
|
+
type: 'deslop_no_chapters',
|
|
3015
|
+
extra: dirs.writing
|
|
3016
|
+
}));
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// 校验脚本必须部署到位(部署清单从 import 图推导,缺文件在部署阶段就该报错)
|
|
3020
|
+
const validator = join(projectRoot, 'references', 'deslop-validate.js');
|
|
3021
|
+
if (!existsSync(validator)) {
|
|
3022
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
3023
|
+
type: 'deploy_failed',
|
|
3024
|
+
err: '校验脚本部署失败:references/deslop-validate.js 不存在'
|
|
3025
|
+
}));
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
const contract = storyBuilder.generateDeslopContract({ topicData });
|
|
3029
|
+
const progressNotes = [];
|
|
3030
|
+
|
|
3031
|
+
// 写作复检要用的上游目录(去味阶段自己的作用域,与写作阶段那份互不影响)
|
|
3032
|
+
const upstreamDirs = [
|
|
3033
|
+
join(projectRoot, '设定', 'raw', 'sources', '骨架'),
|
|
3034
|
+
join(projectRoot, '设定', 'raw', 'sources', '血肉'),
|
|
3035
|
+
join(projectRoot, '设定', 'raw', 'sources', '节奏')
|
|
3036
|
+
];
|
|
3037
|
+
|
|
3038
|
+
// 等回合的判据:改写场景里文件**本来就在**,"文件在不在"不能当完成判据,
|
|
3039
|
+
// 必须看内容有没有变。baseline 由每次 waitIdle 调用设置。
|
|
3040
|
+
let baseline = null;
|
|
3041
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
3042
|
+
timeoutMs: 45 * 60 * 1000,
|
|
3043
|
+
idleGraceMs: 60000,
|
|
3044
|
+
signal: invocation.signal,
|
|
3045
|
+
changed: (files) => {
|
|
3046
|
+
if (baseline === null) return true;
|
|
3047
|
+
return files.some((f) => {
|
|
3048
|
+
try {
|
|
3049
|
+
return readFileSync(f, 'utf-8') !== baseline;
|
|
3050
|
+
} catch {
|
|
3051
|
+
return true;
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
const waitIdle = async (files, before) => {
|
|
3058
|
+
baseline = before;
|
|
3059
|
+
try {
|
|
3060
|
+
return await waitForTurn(files);
|
|
3061
|
+
} finally {
|
|
3062
|
+
baseline = null;
|
|
3063
|
+
}
|
|
3064
|
+
};
|
|
3065
|
+
|
|
3066
|
+
// 检测:跑**部署到项目里的那一份**(跑的就是部署下去的那份,缺文件立刻暴露)
|
|
3067
|
+
const detect = async (file) => {
|
|
3068
|
+
const r = await spawnScript('node', [validator, '--input=' + file], {
|
|
3069
|
+
timeout: 120000,
|
|
3070
|
+
cwd: projectRoot
|
|
3071
|
+
});
|
|
3072
|
+
if (!r.ok) {
|
|
3073
|
+
return { ok: false, error: '检测脚本异常:' + (r.error || r.stderr || '未知错误') };
|
|
3074
|
+
}
|
|
3075
|
+
try {
|
|
3076
|
+
return { ok: true, detection: JSON.parse(r.output) };
|
|
3077
|
+
} catch (e) {
|
|
3078
|
+
return { ok: false, error: '检测脚本输出不是合法 JSON:' + String(e.message) };
|
|
3079
|
+
}
|
|
3080
|
+
};
|
|
3081
|
+
|
|
3082
|
+
let report;
|
|
3083
|
+
try {
|
|
3084
|
+
report = await runDeslopStage({
|
|
3085
|
+
projectRoot,
|
|
3086
|
+
dirs,
|
|
3087
|
+
chapters,
|
|
3088
|
+
contract,
|
|
3089
|
+
sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
|
|
3090
|
+
waitIdle,
|
|
3091
|
+
detect,
|
|
3092
|
+
// 去味后的写作复检(P1-3):去味只查标题/字数/空文,
|
|
3093
|
+
// 查不出占位符/字符集/禁用词/一级标题这些"改坏了"的情况。
|
|
3094
|
+
// 这里补跑部署到项目里的 writing-validate.js。
|
|
3095
|
+
validateWriting: async (chapter) => {
|
|
3096
|
+
const script = join(projectRoot, 'references', 'writing-validate.js');
|
|
3097
|
+
if (!existsSync(script)) return { ok: true, errors: [] };
|
|
3098
|
+
const r = await spawnScript('node', [
|
|
3099
|
+
script,
|
|
3100
|
+
'--input=' + chapter.path,
|
|
3101
|
+
'--chapter=' + chapter.ch,
|
|
3102
|
+
'--upstream=' + upstreamDirs.join(',')
|
|
3103
|
+
], { timeout: 120000, cwd: projectRoot });
|
|
3104
|
+
if (!r.ok) {
|
|
3105
|
+
return { ok: false, errors: [{ code: 'VALIDATOR_CRASH', message: '写作校验脚本异常:' + (r.error || r.stderr || '未知错误') }] };
|
|
3106
|
+
}
|
|
3107
|
+
try {
|
|
3108
|
+
const parsed = JSON.parse(r.output);
|
|
3109
|
+
return { ok: parsed.status === 0, errors: parsed.errors || [] };
|
|
3110
|
+
} catch (e) {
|
|
3111
|
+
return { ok: false, errors: [{ code: 'VALIDATOR_OUTPUT_INVALID', message: '写作校验脚本输出不是合法 JSON:' + String(e.message) }] };
|
|
3112
|
+
}
|
|
3113
|
+
},
|
|
3114
|
+
onProgress: (msg) => { progressNotes.push(msg); },
|
|
3115
|
+
onChapter: (chapter) => {
|
|
3116
|
+
const name = '第' + String(chapter.ch).padStart(3, '0') + '章_去AI味报告.md';
|
|
3117
|
+
writeReport(dirs, name, renderChapterReport(chapter));
|
|
3118
|
+
}
|
|
3119
|
+
});
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3122
|
+
return notify(session, 'error', storyBuilder.notify({ type: 'api_failed', err: errMsg }));
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
if (report.chapters.length) {
|
|
3126
|
+
writeReport(dirs, '去AI味总览.md', renderOverviewReport(report.chapters));
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
// 进度摘要必须并进最终消息:DSH 只显示 handler 的返回值,中间 notify 会被丢弃
|
|
3130
|
+
const progressSummary = progressNotes.length
|
|
3131
|
+
? '去味进度(共 ' + progressNotes.length + ' 条):' + progressNotes.slice(-3).join(';')
|
|
3132
|
+
: '';
|
|
3133
|
+
|
|
3134
|
+
const problems = report.failed.concat(report.errored);
|
|
3135
|
+
if (problems.length > 0) {
|
|
3136
|
+
return notify(session, 'error', storyBuilder.notify({
|
|
3137
|
+
type: 'deslop_incomplete',
|
|
3138
|
+
failed: problems,
|
|
3139
|
+
rolledBack: report.rolledBack.length,
|
|
3140
|
+
errored: report.errored.length,
|
|
3141
|
+
extra: progressSummary
|
|
3142
|
+
}));
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
return notify(session, 'success', storyBuilder.notify({
|
|
3146
|
+
type: 'deslop_done',
|
|
3147
|
+
count: report.chapters.length,
|
|
3148
|
+
clean: report.clean,
|
|
3149
|
+
passed: report.passed,
|
|
3150
|
+
extra: progressSummary
|
|
3151
|
+
}));
|
|
3152
|
+
}
|