@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,1061 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* writing-driver.js
|
|
3
|
+
* 写作阶段:插件驱动、按批执行(默认每批 10 章)
|
|
4
|
+
*
|
|
5
|
+
* 为什么要改成插件驱动:
|
|
6
|
+
* 旧模式下插件只投一次提示词就返回"写作阶段开始",剩下 T 章全靠 LLM 在自己
|
|
7
|
+
* 那一轮里 for 循环完成——自己读细纲、自己跑校验、自己更台账。插件对写作
|
|
8
|
+
* 流程零控制:少写一章、跳过一章、校验失败不重试、台账没更新,插件都不知道;
|
|
9
|
+
* 上下文一被压缩,几百章的进度状态就丢了。
|
|
10
|
+
*
|
|
11
|
+
* 改成插件驱动后:
|
|
12
|
+
* 插件自己按批循环 → 每批把细纲/前章尾/台账快照注入 prompt → 投给 LLM →
|
|
13
|
+
* 等 LLM 空闲 → **插件自己跑 writing-validate.js 逐章校验** →
|
|
14
|
+
* 通过就推进台账进下一批,有失败就把 errors[] 回喂重试该批。
|
|
15
|
+
*
|
|
16
|
+
* 对 LLM 的变化:提示词从"执行手册"瘦身成"怎么写好一章"的方法论,
|
|
17
|
+
* 流程(读什么、校验什么、写哪几个文件)由插件执行。
|
|
18
|
+
*
|
|
19
|
+
* 本模块不直接依赖 dsh 运行时:sendPrompt / waitIdle / validateChapter 都由
|
|
20
|
+
* 调用方注入,因此可以在没有 LLM 的情况下用测试夹具驱动。
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
|
|
25
|
+
import { countEffectiveChars } from './scripts/content-quality.js';
|
|
26
|
+
|
|
27
|
+
// 每批章数
|
|
28
|
+
export const WRITING_BATCH_SIZE = 10;
|
|
29
|
+
// 单批最大尝试次数(含首次)。超了就带着失败章继续下一批,并在最终报告里列出。
|
|
30
|
+
export const WRITING_MAX_BATCH_ATTEMPTS = 3;
|
|
31
|
+
// 前章正文注入长度(字)
|
|
32
|
+
export const WRITING_PREV_TAIL_CHARS = 500;
|
|
33
|
+
|
|
34
|
+
// ============================================================
|
|
35
|
+
// 上下文读取
|
|
36
|
+
// ============================================================
|
|
37
|
+
|
|
38
|
+
function readJsonSafe(p) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(p)) return null;
|
|
41
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readTextSafe(p) {
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(p)) return null;
|
|
50
|
+
return readFileSync(p, 'utf-8');
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 章文件路径:设定/raw/sources/写作/第NNN章.md */
|
|
57
|
+
export function chapterFileName(ch) {
|
|
58
|
+
return '第' + String(ch).padStart(3, '0') + '章.md';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writingDirs(projectRoot) {
|
|
62
|
+
const src = join(projectRoot, '设定', 'raw', 'sources');
|
|
63
|
+
return {
|
|
64
|
+
sources: src,
|
|
65
|
+
skeleton: join(src, '骨架'),
|
|
66
|
+
flesh: join(src, '血肉'),
|
|
67
|
+
rhythm: join(src, '节奏'),
|
|
68
|
+
writing: join(src, '写作'),
|
|
69
|
+
ledger: join(src, '写作', '台账')
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 读取写作阶段需要的全部上游数据。
|
|
75
|
+
*
|
|
76
|
+
* 返回 { T, outlines, existing, prevTail, ledgers, dirs, entityRoster }。
|
|
77
|
+
* 任何一项读不到都返回 null,交给调用方报错(不抛异常,方便统一成通知文案)。
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* 读写作阶段的**可变状态**:已写章节、前情衔接、台账。
|
|
81
|
+
*
|
|
82
|
+
* 单独抽出来是因为它有两个调用点:
|
|
83
|
+
* · loadWritingContext —— 开场读一次
|
|
84
|
+
* · refreshWritingContext —— 每批写完后重读(否则第 2 批拿到的还是
|
|
85
|
+
* 第 1 批之前的"前情衔接"和台账快照,角色状态前后矛盾)
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} dirs writingDirs() 的结果
|
|
88
|
+
* @param {number} T 全书章数
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* 轻量复检一个已存在的章节文件。
|
|
92
|
+
*
|
|
93
|
+
* 为什么"文件存在"不等于"这章写完了":
|
|
94
|
+
* 旧实现只按文件名 `第N章.md` 判断,于是
|
|
95
|
+
* - 上次失败留下的 0 字节文件
|
|
96
|
+
* - 上次中断留下的半截文件
|
|
97
|
+
* - 标题写错的文件
|
|
98
|
+
* 都会被当成"已写完"而**永久跳过** —— 续跑多少遍都补不上,
|
|
99
|
+
* 而报告只说"0 章全部写完并通过逐章校验",用户完全看不出来。
|
|
100
|
+
*
|
|
101
|
+
* 这里只做三项便宜且能抓住上述三类残留的检查(同步、不起子进程):
|
|
102
|
+
* 1. 文件非空
|
|
103
|
+
* 2. 首行是 `# 第N章`(章号与文件名一致)
|
|
104
|
+
* 3. 有效字数 ≥ 50(写作校验器的 EMPTY_BODY 门槛)
|
|
105
|
+
*
|
|
106
|
+
* 更严的复检(占位符/字符集/台账…)由 runWritingStage 里的
|
|
107
|
+
* validateChapter 补上 —— 那是完整校验器,能起子进程。
|
|
108
|
+
*
|
|
109
|
+
* @param {string} filePath
|
|
110
|
+
* @param {number} ch 章号
|
|
111
|
+
* @returns {{ok:boolean, reason:string|null}}
|
|
112
|
+
*/
|
|
113
|
+
export function quickCheckChapterFile(filePath, ch) {
|
|
114
|
+
let text = null;
|
|
115
|
+
try {
|
|
116
|
+
text = readFileSync(filePath, 'utf-8');
|
|
117
|
+
} catch {
|
|
118
|
+
return { ok: false, reason: '文件读不出来' };
|
|
119
|
+
}
|
|
120
|
+
if (!text || !text.trim()) return { ok: false, reason: '文件是空的' };
|
|
121
|
+
const first = text.split('\n', 1)[0].trim();
|
|
122
|
+
const m = /^#\s*第\s*(\d+)\s*章(?:\s|$)/.exec(first);
|
|
123
|
+
if (!m) return { ok: false, reason: '首行不是 "# 第N章 标题"(实际:' + first.slice(0, 30) + ')' };
|
|
124
|
+
if (Number(m[1]) !== ch) {
|
|
125
|
+
return { ok: false, reason: '标题章号 ' + m[1] + ' 与文件名章号 ' + ch + ' 不一致' };
|
|
126
|
+
}
|
|
127
|
+
const eff = countEffectiveChars(text);
|
|
128
|
+
if (eff < 50) return { ok: false, reason: '正文太短(有效字数 ' + eff + ' < 50)' };
|
|
129
|
+
return { ok: true, reason: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readWritingState(dirs, T) {
|
|
133
|
+
// 已存在的章(用于续跑)
|
|
134
|
+
const existing = new Set();
|
|
135
|
+
// 章号 → 磁盘上的实际文件名。
|
|
136
|
+
// existing 用正则识别(`第(\d+)章.md`,位数不限),但 chapterFileName 拼的是
|
|
137
|
+
// 三位补零名(第001章.md)。模型要是写成 `第1章.md`,识别得到、却按补零名
|
|
138
|
+
// 读不到 —— 前情衔接就静默失效(prevTail 永远空)。这里记下实际文件名兜底。
|
|
139
|
+
const fileByChapter = new Map();
|
|
140
|
+
// 存在但没通过轻量复检的章:不算"已写完",续跑时要重写。
|
|
141
|
+
const invalid = [];
|
|
142
|
+
let files = [];
|
|
143
|
+
try {
|
|
144
|
+
if (dirs.writing && existsSync(dirs.writing)) files = readdirSync(dirs.writing);
|
|
145
|
+
} catch {
|
|
146
|
+
files = [];
|
|
147
|
+
}
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
const m = /^第(\d+)章\.md$/.exec(f);
|
|
150
|
+
if (!m) continue;
|
|
151
|
+
const ch = Number(m[1]);
|
|
152
|
+
if (!fileByChapter.has(ch)) fileByChapter.set(ch, f);
|
|
153
|
+
// 轻量复检:空文件/半截文件/标题错的**不算已写完**(2026-10-27 修)
|
|
154
|
+
const chk = quickCheckChapterFile(join(dirs.writing, f), ch);
|
|
155
|
+
if (!chk.ok) {
|
|
156
|
+
invalid.push({ ch, file: f, reason: chk.reason });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
existing.add(ch);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 台账
|
|
163
|
+
// 台账目录可能整个不存在(续跑时还没建),这时台账就是空的,不能抛错
|
|
164
|
+
const readLedger = (name) => {
|
|
165
|
+
if (!dirs.ledger) return null;
|
|
166
|
+
return readJsonSafe(join(dirs.ledger, name));
|
|
167
|
+
};
|
|
168
|
+
const ledgers = {
|
|
169
|
+
chapters: readLedger('章台账.json') || {},
|
|
170
|
+
characters: readLedger('人物台账.json') || {},
|
|
171
|
+
foreshadows: readLedger('伏笔台账.json') || {},
|
|
172
|
+
timeline: readLedger('时间线台账.json') || {}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// 前章尾部(衔接用):取**最后写完的那一章**的尾部
|
|
176
|
+
let prevTail = '';
|
|
177
|
+
let prevChapter = T;
|
|
178
|
+
for (let ch = T; ch >= 1; ch--) {
|
|
179
|
+
if (!existing.has(ch)) continue;
|
|
180
|
+
let text = readTextSafe(join(dirs.writing, chapterFileName(ch)));
|
|
181
|
+
// 规范名(第001章.md)读不到时,用磁盘上的实际文件名再试一次
|
|
182
|
+
if (text === null) {
|
|
183
|
+
const actual = fileByChapter.get(ch);
|
|
184
|
+
if (actual) text = readTextSafe(join(dirs.writing, actual));
|
|
185
|
+
}
|
|
186
|
+
if (text === null) continue;
|
|
187
|
+
prevTail = text.slice(-WRITING_PREV_TAIL_CHARS);
|
|
188
|
+
prevChapter = ch;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { existing, ledgers, prevTail, prevChapter, invalid };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function loadWritingContext(projectRoot) {
|
|
196
|
+
const dirs = writingDirs(projectRoot);
|
|
197
|
+
const framework = readJsonSafe(join(dirs.skeleton, '框架.json'));
|
|
198
|
+
const T = framework && framework.capacity ? Number(framework.capacity.target_chapters || 0) : 0;
|
|
199
|
+
if (!T || !Number.isFinite(T) || T <= 0) {
|
|
200
|
+
return { error: '读不到骨架 框架.json 的 capacity.target_chapters,无法确定要写多少章' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 章节细纲:节奏阶段的 章节细纲.json
|
|
204
|
+
const outlinesRaw = readJsonSafe(join(dirs.rhythm, '章节细纲.json'));
|
|
205
|
+
const outlines = new Map();
|
|
206
|
+
for (const o of ((outlinesRaw && outlinesRaw.outlines) || [])) {
|
|
207
|
+
if (o && typeof o.ch === 'number') outlines.set(o.ch, o);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 章节规划 + 时序映射:用来算出"这一章该有谁、该在哪、该埋什么"
|
|
211
|
+
const planRaw = readJsonSafe(join(dirs.rhythm, '章节规划.json'));
|
|
212
|
+
const timelineRaw = readJsonSafe(join(dirs.rhythm, '时序映射.json'));
|
|
213
|
+
const entity = readJsonSafe(join(dirs.skeleton, '实体.json')) || {};
|
|
214
|
+
const fleshChars = readJsonSafe(join(dirs.flesh, '角色.json')) || {};
|
|
215
|
+
const roster = buildEntityRoster(dirs);
|
|
216
|
+
const chapterContext = buildChapterContext(planRaw, timelineRaw, entity, fleshChars, roster);
|
|
217
|
+
|
|
218
|
+
const state = readWritingState(dirs, T);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
T,
|
|
222
|
+
outlines,
|
|
223
|
+
chapterContext,
|
|
224
|
+
existing: state.existing,
|
|
225
|
+
prevTail: state.prevTail,
|
|
226
|
+
prevChapter: state.prevChapter,
|
|
227
|
+
ledgers: state.ledgers,
|
|
228
|
+
// 存在但没通过轻量复检的章(空文件/半截/标题错)——续跑要重写它们
|
|
229
|
+
invalid: state.invalid || [],
|
|
230
|
+
dirs,
|
|
231
|
+
roster,
|
|
232
|
+
error: null
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 刷新写作 context 的可变部分(已写章节 / 前情衔接 / 台账)。
|
|
238
|
+
*
|
|
239
|
+
* 什么时候必须刷:每批写完之后。
|
|
240
|
+
* 不刷的后果(实测):
|
|
241
|
+
* · 第 2 批提示词里的"前情衔接"还是第 1 批**之前**的内容 → 承接断裂
|
|
242
|
+
* · 台账快照是旧的 → 第 1 批把角色写成受伤,第 2 批看到的还是初始状态
|
|
243
|
+
*
|
|
244
|
+
* 静态部分(细纲 / 逐章上下文 / 花名册 / T)不动 —— 它们来自上游产物,
|
|
245
|
+
* 写作阶段不会改。
|
|
246
|
+
*
|
|
247
|
+
* @param {Object} context loadWritingContext() 的返回值
|
|
248
|
+
* @returns {Object} 同一个 context 对象(就地更新)
|
|
249
|
+
*/
|
|
250
|
+
export function refreshWritingContext(context) {
|
|
251
|
+
if (!context || context.error || !context.dirs) return context;
|
|
252
|
+
const state = readWritingState(context.dirs, context.T);
|
|
253
|
+
context.existing = state.existing;
|
|
254
|
+
context.prevTail = state.prevTail;
|
|
255
|
+
context.prevChapter = state.prevChapter;
|
|
256
|
+
context.ledgers = state.ledgers;
|
|
257
|
+
context.invalid = state.invalid || [];
|
|
258
|
+
return context;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 把节奏阶段的"章节规划 + 时序映射"折算成**逐章上下文**。
|
|
263
|
+
*
|
|
264
|
+
* 章节细纲本身只有功能、情绪、钩子、字数这些字段,不含"这一章该有谁、
|
|
265
|
+
* 在哪、该埋哪个伏笔"。这些信息散在 时序映射.json 的各个 *_timeline 里。
|
|
266
|
+
* 写作驱动按批派活时必须把它们逐章摊开——否则 LLM 只能靠前章结尾去猜
|
|
267
|
+
* 本章该出场谁、该推进哪条线。
|
|
268
|
+
*/
|
|
269
|
+
export function buildChapterContext(planRaw, timelineRaw, entity, fleshChars, roster) {
|
|
270
|
+
const nameById = new Map();
|
|
271
|
+
for (const c of (roster && roster.characters) || []) nameById.set(c.id, c.name);
|
|
272
|
+
for (const c of (roster && roster.fleshCharacters) || []) nameById.set(c.id, c.name);
|
|
273
|
+
for (const c of (entity && entity.characters) || []) if (c && c.id) nameById.set(c.id, c.name);
|
|
274
|
+
for (const c of ((fleshChars && fleshChars.characters) || [])) if (c && c.id) nameById.set(c.id, c.name);
|
|
275
|
+
|
|
276
|
+
const locName = new Map();
|
|
277
|
+
for (const l of (entity && entity.locations) || []) if (l && l.id) locName.set(l.id, l.name);
|
|
278
|
+
const itemName = new Map();
|
|
279
|
+
for (const it of (entity && entity.items) || []) if (it && it.id) itemName.set(it.id, it.name);
|
|
280
|
+
const conflictName = new Map();
|
|
281
|
+
for (const c of (entity && entity.conflicts) || []) if (c && c.id) conflictName.set(c.id, c.name);
|
|
282
|
+
const fsName = new Map();
|
|
283
|
+
for (const f of (entity && entity.foreshadows) || []) if (f && f.id) fsName.set(f.id, f.name);
|
|
284
|
+
const climaxName = new Map();
|
|
285
|
+
for (const c of (entity && entity.climaxes) || []) if (c && c.id) climaxName.set(c.id, c.name);
|
|
286
|
+
const pleasureName = new Map();
|
|
287
|
+
for (const p of (entity && entity.pleasures) || []) if (p && p.id) pleasureName.set(p.id, p.name);
|
|
288
|
+
const endingName = new Map();
|
|
289
|
+
for (const e of (entity && entity.endings) || []) if (e && e.id) endingName.set(e.id, e.name);
|
|
290
|
+
|
|
291
|
+
const ctx = new Map();
|
|
292
|
+
const get = (ch) => {
|
|
293
|
+
if (!ctx.has(ch)) {
|
|
294
|
+
ctx.set(ch, {
|
|
295
|
+
ch,
|
|
296
|
+
volume: null, act: null, sceneFocus: '',
|
|
297
|
+
characters: [], locations: [], items: [],
|
|
298
|
+
conflicts: [], foreshadows: [], climax: null, pleasure: null,
|
|
299
|
+
hook: null, ending: null
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return ctx.get(ch);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// 章节规划:卷 / 幕 / 场景焦点
|
|
306
|
+
for (const c of ((planRaw && planRaw.chapters) || [])) {
|
|
307
|
+
if (!c || typeof c.ch !== 'number') continue;
|
|
308
|
+
const e = get(c.ch);
|
|
309
|
+
e.volume = c.volume || null;
|
|
310
|
+
e.act = typeof c.act === 'number' ? c.act : null;
|
|
311
|
+
e.sceneFocus = c.scene_focus || '';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const t = timelineRaw || {};
|
|
315
|
+
|
|
316
|
+
// 角色 / 地点 / 道具:按出场区间铺到每一章
|
|
317
|
+
const spread = (list, idKey, target, nameMap) => {
|
|
318
|
+
for (const it of (list || [])) {
|
|
319
|
+
if (!it) continue;
|
|
320
|
+
const id = it[idKey];
|
|
321
|
+
const from = typeof it.appear_start === 'number' ? it.appear_start : null;
|
|
322
|
+
const to = typeof it.appear_end === 'number' ? it.appear_end : null;
|
|
323
|
+
if (from === null || to === null) continue;
|
|
324
|
+
for (let ch = from; ch <= to; ch++) {
|
|
325
|
+
get(ch)[target].push({ id, name: nameMap.get(id) || id });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
spread(t.character_timeline, 'char_id', 'characters', nameById);
|
|
330
|
+
spread(t.location_timeline, 'loc_id', 'locations', locName);
|
|
331
|
+
spread(t.item_timeline, 'item_id', 'items', itemName);
|
|
332
|
+
|
|
333
|
+
// 冲突:升级节点落在哪一章
|
|
334
|
+
for (const it of (t.conflict_timeline || [])) {
|
|
335
|
+
if (!it) continue;
|
|
336
|
+
const nodes = Array.isArray(it.escalation_nodes) ? it.escalation_nodes : [];
|
|
337
|
+
for (const n of nodes) {
|
|
338
|
+
// 契约字段是 chapter(templates/节奏/时序映射模板.json,
|
|
339
|
+
// rhythm-validate.js 读的也是 n.chapter)——原来这里读的是 n.ch,
|
|
340
|
+
// 字段名对不上 → 冲突升级章节永远进不了正文上下文(实测 conflicts 全空)。
|
|
341
|
+
const ch = typeof n === 'number'
|
|
342
|
+
? n
|
|
343
|
+
: (n && typeof n.chapter === 'number' ? n.chapter
|
|
344
|
+
: (n && typeof n.ch === 'number' ? n.ch : null));
|
|
345
|
+
if (ch === null) continue;
|
|
346
|
+
get(ch).conflicts.push({ id: it.conflict_id, name: conflictName.get(it.conflict_id) || it.conflict_id });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 伏笔:埋设章与回收章
|
|
351
|
+
for (const it of (t.foreshadow_timeline || [])) {
|
|
352
|
+
if (!it) continue;
|
|
353
|
+
const mark = (ch, kind) => {
|
|
354
|
+
if (typeof ch !== 'number') return;
|
|
355
|
+
get(ch).foreshadows.push({ id: it.fs_id, name: fsName.get(it.fs_id) || it.fs_id, kind });
|
|
356
|
+
};
|
|
357
|
+
mark(it.buried_chapter, '埋设');
|
|
358
|
+
mark(it.recovered_chapter, '回收');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 高潮 / 爽点 / 结局
|
|
362
|
+
for (const it of (t.climax_timeline || [])) {
|
|
363
|
+
if (!it || typeof it.chapter !== 'number') continue;
|
|
364
|
+
get(it.chapter).climax = { id: it.climax_id, name: climaxName.get(it.climax_id) || it.climax_id };
|
|
365
|
+
}
|
|
366
|
+
for (const it of (t.pleasure_timeline || [])) {
|
|
367
|
+
if (!it || typeof it.chapter !== 'number') continue;
|
|
368
|
+
get(it.chapter).pleasure = { id: it.pleasure_id, name: pleasureName.get(it.pleasure_id) || it.pleasure_id };
|
|
369
|
+
}
|
|
370
|
+
for (const it of (t.ending_timeline || [])) {
|
|
371
|
+
if (!it || typeof it.chapter !== 'number') continue;
|
|
372
|
+
get(it.chapter).ending = { id: it.ending_id, name: endingName.get(it.ending_id) || it.ending_id };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 钩子
|
|
376
|
+
for (const it of (t.hook_timeline || [])) {
|
|
377
|
+
if (!it || typeof it.ch !== 'number') continue;
|
|
378
|
+
get(it.ch).hook = { head: it.hook_head || '', tail: it.hook_tail || '', strength: it.hook_strength || '' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return ctx;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function collectNamed(list) {
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const it of (list || [])) {
|
|
387
|
+
if (it && it.id && it.name) out.push({ id: it.id, name: it.name });
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 实体名册:角色 / 势力 / 地点 / 道具 / 冲突 的 id+name。
|
|
394
|
+
* 写作阶段引用实体时必须用这些 id 与 name,不能自创。
|
|
395
|
+
*/
|
|
396
|
+
export function buildEntityRoster(dirs) {
|
|
397
|
+
const entity = readJsonSafe(join(dirs.skeleton, '实体.json')) || {};
|
|
398
|
+
const flesh = readJsonSafe(join(dirs.flesh, '角色.json')) || {};
|
|
399
|
+
return {
|
|
400
|
+
characters: collectNamed(entity.characters),
|
|
401
|
+
conflicts: collectNamed(entity.conflicts),
|
|
402
|
+
factions: collectNamed(entity.factions),
|
|
403
|
+
locations: collectNamed(entity.locations),
|
|
404
|
+
items: collectNamed(entity.items),
|
|
405
|
+
endings: collectNamed(entity.endings),
|
|
406
|
+
fleshCharacters: collectNamed(flesh.characters)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============================================================
|
|
411
|
+
// 分批
|
|
412
|
+
// ============================================================
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 把 T 章切成若干批。
|
|
416
|
+
*
|
|
417
|
+
* 已经存在的章(续跑)不重复写:批次里跳过它们,
|
|
418
|
+
* 但仍然把整段章号范围交给 LLM,让它知道自己在写的是哪一段。
|
|
419
|
+
*/
|
|
420
|
+
export function planBatches(context, batchSize) {
|
|
421
|
+
const size = batchSize || WRITING_BATCH_SIZE;
|
|
422
|
+
const batches = [];
|
|
423
|
+
let start = 1;
|
|
424
|
+
while (start <= context.T) {
|
|
425
|
+
const end = Math.min(start + size - 1, context.T);
|
|
426
|
+
const chapters = [];
|
|
427
|
+
const skipped = [];
|
|
428
|
+
for (let ch = start; ch <= end; ch++) {
|
|
429
|
+
if (context.existing.has(ch)) skipped.push(ch);
|
|
430
|
+
else chapters.push(ch);
|
|
431
|
+
}
|
|
432
|
+
batches.push({ start, end, chapters, skipped });
|
|
433
|
+
start = end + 1;
|
|
434
|
+
}
|
|
435
|
+
return batches;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ============================================================
|
|
439
|
+
// Prompt 构建
|
|
440
|
+
// ============================================================
|
|
441
|
+
|
|
442
|
+
function formatOutline(o) {
|
|
443
|
+
if (!o) return '(本章无细纲)';
|
|
444
|
+
const lines = [];
|
|
445
|
+
const push = (k, v) => {
|
|
446
|
+
if (v === undefined || v === null || v === '') return;
|
|
447
|
+
if (Array.isArray(v)) {
|
|
448
|
+
if (v.length === 0) return;
|
|
449
|
+
lines.push('- ' + k + ':' + v.map((x) => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(';'));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (typeof v === 'object') {
|
|
453
|
+
lines.push('- ' + k + ':' + JSON.stringify(v));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
lines.push('- ' + k + ':' + String(v));
|
|
457
|
+
};
|
|
458
|
+
push('功能', o.function_main);
|
|
459
|
+
push('辅助功能', o.function_aux);
|
|
460
|
+
push('情绪阶段', o.emotion_phase);
|
|
461
|
+
push('爽点', o.satisfaction_type || o.satisfaction_audience);
|
|
462
|
+
push('伏笔动作', o.foreshadow_action);
|
|
463
|
+
push('势力出场', o.faction_appearance);
|
|
464
|
+
push('章首钩子', o.hook_head);
|
|
465
|
+
push('章尾钩子', o.hook_tail);
|
|
466
|
+
push('钩子强度', o.hook_strength);
|
|
467
|
+
push('出场角色', o.char_ids);
|
|
468
|
+
push('出场地点', o.loc_ids);
|
|
469
|
+
push('关联道具', o.item_ids);
|
|
470
|
+
push('关联冲突', o.conflict_ids);
|
|
471
|
+
push('字数目标', o.word_target);
|
|
472
|
+
push('所属卷', o.volume);
|
|
473
|
+
return lines.length ? lines.join('\n') : '(本章无细纲)';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function formatRoster(roster) {
|
|
477
|
+
const seg = (label, arr) => {
|
|
478
|
+
if (!arr || arr.length === 0) return '';
|
|
479
|
+
return label + ':' + arr.map((x) => x.id + '(' + x.name + ')').join('、');
|
|
480
|
+
};
|
|
481
|
+
const parts = [
|
|
482
|
+
seg('角色', roster.characters),
|
|
483
|
+
seg('势力', roster.factions),
|
|
484
|
+
seg('地点', roster.locations),
|
|
485
|
+
seg('道具', roster.items),
|
|
486
|
+
seg('冲突', roster.conflicts)
|
|
487
|
+
].filter(Boolean);
|
|
488
|
+
return parts.join('\n');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatLedgerSnapshot(ledgers) {
|
|
492
|
+
const out = [];
|
|
493
|
+
// 人物台账:只要有人物(写作阶段需要人物当前状态)
|
|
494
|
+
const chars = ledgers.characters || {};
|
|
495
|
+
const charKeys = Object.keys(chars);
|
|
496
|
+
if (charKeys.length) {
|
|
497
|
+
out.push('【人物台账】');
|
|
498
|
+
for (const k of charKeys) {
|
|
499
|
+
const c = chars[k] || {};
|
|
500
|
+
out.push('- ' + k + ':位置=' + (c.current_location || '未记') +
|
|
501
|
+
';目标=' + (c.current_goal || '未记') +
|
|
502
|
+
';状态=' + (c.status || '未记') +
|
|
503
|
+
';上次出场=' + (c.last_appear_chapter === undefined ? '未记' : c.last_appear_chapter));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// 伏笔台账:只要未回收的
|
|
507
|
+
const fs = ledgers.foreshadows || {};
|
|
508
|
+
const open = Object.entries(fs).filter(([, v]) => v && (v.status === 'open' || v.status === 'progressing'));
|
|
509
|
+
if (open.length) {
|
|
510
|
+
out.push('');
|
|
511
|
+
out.push('【未回收伏笔】');
|
|
512
|
+
for (const [k, v] of open) {
|
|
513
|
+
out.push('- ' + k + ':' + (v.content || '') + '(埋于第 ' + (v.buried_chapter === undefined ? '?' : v.buried_chapter) + ' 章,状态 ' + v.status + ')');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// 时间线
|
|
517
|
+
const tl = ledgers.timeline || {};
|
|
518
|
+
if (tl.current_time) {
|
|
519
|
+
out.push('');
|
|
520
|
+
out.push('【当前时间】' + tl.current_time + '(已过 ' + (tl.elapsed_days === undefined ? '?' : tl.elapsed_days) + ' 天)');
|
|
521
|
+
}
|
|
522
|
+
return out.join('\n');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 渲染"这一章该有谁、该在哪、该推进什么"。
|
|
527
|
+
* 数据来自节奏阶段的 章节规划 + 时序映射,由驱动算好后逐章摊开,
|
|
528
|
+
* LLM 不需要自己去翻上游 JSON。
|
|
529
|
+
*/
|
|
530
|
+
function formatChapterContext(context, ch) {
|
|
531
|
+
const c = (context.chapterContext && context.chapterContext.get(ch)) || null;
|
|
532
|
+
if (!c) return '';
|
|
533
|
+
const lines = [];
|
|
534
|
+
if (c.volume) lines.push('- 所属卷:' + c.volume + (c.act ? '(第 ' + c.act + ' 幕)' : ''));
|
|
535
|
+
if (c.sceneFocus) lines.push('- 场景焦点:' + c.sceneFocus);
|
|
536
|
+
if (c.characters.length) {
|
|
537
|
+
lines.push('- 出场角色:' + c.characters.map((x) => x.id + '(' + x.name + ')').join('、'));
|
|
538
|
+
}
|
|
539
|
+
if (c.locations.length) {
|
|
540
|
+
lines.push('- 出场地点:' + c.locations.map((x) => x.id + '(' + x.name + ')').join('、'));
|
|
541
|
+
}
|
|
542
|
+
if (c.items.length) {
|
|
543
|
+
lines.push('- 出场道具:' + c.items.map((x) => x.id + '(' + x.name + ')').join('、'));
|
|
544
|
+
}
|
|
545
|
+
if (c.conflicts.length) {
|
|
546
|
+
lines.push('- 涉及冲突:' + c.conflicts.map((x) => x.id + '(' + x.name + ')').join('、'));
|
|
547
|
+
}
|
|
548
|
+
if (c.foreshadows.length) {
|
|
549
|
+
lines.push('- 伏笔动作:' + c.foreshadows.map((x) => x.id + '(' + x.name + ')' + x.kind).join('、'));
|
|
550
|
+
}
|
|
551
|
+
if (c.climax) lines.push('- 本章高潮:' + c.climax.id + '(' + c.climax.name + ')');
|
|
552
|
+
if (c.pleasure) lines.push('- 本章爽点:' + c.pleasure.id + '(' + c.pleasure.name + ')');
|
|
553
|
+
if (c.ending) lines.push('- 本章结局:' + c.ending.id + '(' + c.ending.name + ')');
|
|
554
|
+
if (c.hook) {
|
|
555
|
+
lines.push('- 钩子:章首 ' + (c.hook.head || '未指定') + ' / 章尾 ' + (c.hook.tail || '未指定') +
|
|
556
|
+
(c.hook.strength ? '(强度 ' + c.hook.strength + ')' : ''));
|
|
557
|
+
}
|
|
558
|
+
if (lines.length === 0) return '';
|
|
559
|
+
return '**本章上下文**\n\n' + lines.join('\n') + '\n\n';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 构建一个批次的提示词。
|
|
564
|
+
*
|
|
565
|
+
* attempt > 1 时是重试提示词:只要求重写失败的章,并把校验错误原样带上。
|
|
566
|
+
*
|
|
567
|
+
* contract 是写作执行须知(产物契约),由调用方从 prompts/04-writing-prompt.md 读出,
|
|
568
|
+
* 每批带一遍;它只讲"写成什么样",不讲流程——流程由驱动执行。
|
|
569
|
+
*/
|
|
570
|
+
export function buildBatchPrompt(context, batch, attempt, failures, contract) {
|
|
571
|
+
const isRetry = attempt > 1 && failures && failures.length > 0;
|
|
572
|
+
const targetChapters = isRetry ? failures.map((f) => f.ch) : batch.chapters;
|
|
573
|
+
const parts = [];
|
|
574
|
+
|
|
575
|
+
if (contract) {
|
|
576
|
+
parts.push(contract.trim());
|
|
577
|
+
parts.push('');
|
|
578
|
+
parts.push('---');
|
|
579
|
+
parts.push('');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
parts.push('# 本轮派活:第 ' + batch.start + '-' + batch.end + ' 批' + (isRetry ? '(重试)' : ''));
|
|
583
|
+
parts.push('');
|
|
584
|
+
parts.push('本轮由写作驱动分批派活。请**严格按下面的章号顺序**完成本轮的每一章。');
|
|
585
|
+
parts.push('');
|
|
586
|
+
|
|
587
|
+
if (isRetry) {
|
|
588
|
+
parts.push('## 本轮需要重写的章(校验未通过)');
|
|
589
|
+
parts.push('');
|
|
590
|
+
for (const f of failures) {
|
|
591
|
+
parts.push('### 第 ' + f.ch + ' 章 —— 校验未通过');
|
|
592
|
+
parts.push('');
|
|
593
|
+
parts.push('错误清单:');
|
|
594
|
+
for (const e of (f.errors || [])) {
|
|
595
|
+
parts.push('- [' + e.code + '] ' + (e.path || '(无路径)') + ':' + e.message + (e.hint ? '(' + e.hint + ')' : ''));
|
|
596
|
+
}
|
|
597
|
+
parts.push('');
|
|
598
|
+
const cc = formatChapterContext(context, f.ch);
|
|
599
|
+
if (cc) parts.push(cc);
|
|
600
|
+
parts.push('该章细纲:');
|
|
601
|
+
parts.push(formatOutline(context.outlines.get(f.ch)));
|
|
602
|
+
parts.push('');
|
|
603
|
+
}
|
|
604
|
+
parts.push('**只重写上面列出的章**,其它已通过的章不要动,也不要重复写。');
|
|
605
|
+
parts.push('');
|
|
606
|
+
parts.push('**重写完要同步更新台账**:把章台账里这些章的同 `ch` 旧条目**替换**掉(不要留两条),' +
|
|
607
|
+
'并把本轮的 `hook_head_type` / `hook_tail_type` / `micro_payoffs` 登记对;' +
|
|
608
|
+
'人物台账、伏笔台账、时间线台账也要跟上。台账没更新,校验器读到的还是上一版,会一直报同一个错。');
|
|
609
|
+
parts.push('');
|
|
610
|
+
} else {
|
|
611
|
+
parts.push('## 本轮要写的章');
|
|
612
|
+
parts.push('');
|
|
613
|
+
for (const ch of batch.chapters) {
|
|
614
|
+
parts.push('### 第 ' + ch + ' 章');
|
|
615
|
+
parts.push('');
|
|
616
|
+
const cc = formatChapterContext(context, ch);
|
|
617
|
+
if (cc) parts.push(cc);
|
|
618
|
+
parts.push(formatOutline(context.outlines.get(ch)));
|
|
619
|
+
parts.push('');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
parts.push('## 落盘路径');
|
|
624
|
+
parts.push('');
|
|
625
|
+
for (const ch of targetChapters) {
|
|
626
|
+
parts.push('- 第 ' + ch + ' 章 → `设定/raw/sources/写作/' + chapterFileName(ch) + '`');
|
|
627
|
+
}
|
|
628
|
+
parts.push('');
|
|
629
|
+
|
|
630
|
+
if (context.prevTail) {
|
|
631
|
+
parts.push('## 前情衔接(第 ' + context.prevChapter + ' 章结尾 ' + WRITING_PREV_TAIL_CHARS + ' 字)');
|
|
632
|
+
parts.push('');
|
|
633
|
+
parts.push('```');
|
|
634
|
+
parts.push(context.prevTail);
|
|
635
|
+
parts.push('```');
|
|
636
|
+
parts.push('');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const snapshot = formatLedgerSnapshot(context.ledgers);
|
|
640
|
+
if (snapshot) {
|
|
641
|
+
parts.push('## 当前台账快照');
|
|
642
|
+
parts.push('');
|
|
643
|
+
parts.push(snapshot);
|
|
644
|
+
parts.push('');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const roster = formatRoster(context.roster);
|
|
648
|
+
if (roster) {
|
|
649
|
+
parts.push('## 实体名册(引用实体时只能用这里的 id 与 name,不得自创)');
|
|
650
|
+
parts.push('');
|
|
651
|
+
parts.push(roster);
|
|
652
|
+
parts.push('');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
parts.push('## 怎么写');
|
|
656
|
+
parts.push('');
|
|
657
|
+
parts.push('1. 逐章读 `references/写作指导/` 下的相关文件(`01-scene.md` 场景五要素、`02-chapter-workflow.md` 成章流程、' +
|
|
658
|
+
'`03-opening-chapter.md` 开篇、`04-dialogue-mastery.md` 对话、`06-tone-ensemble.md` 文风、`05-genre-formulas.md` 题材公式)。');
|
|
659
|
+
parts.push('2. 写完一章就把正文写进上面的落盘路径(正文第一行是 `# 第N章 <标题>`,N 与章号一致)。');
|
|
660
|
+
parts.push('3. 写完一章就更新台账:`设定/raw/sources/写作/台账/` 下的 `章台账.json`(追加本章)、' +
|
|
661
|
+
'`人物台账.json`(更新出场角色)、`伏笔台账.json`(更新 open/progressing 状态)、`时间线台账.json`(推进当前位置)。');
|
|
662
|
+
parts.push(' **重写某章时,要把章台账里同 `ch` 的旧条目替换掉,不要留两条** —— ' +
|
|
663
|
+
'校验器按 ch 取最后一条生效版本,留两条会让钩子类型、微兑现这些登记读不到。');
|
|
664
|
+
parts.push(' 章台账每章要登记 `hook_head_type`(7 式章首引子之一)、`hook_tail_type`(13 式章末钩子之一)、' +
|
|
665
|
+
'`micro_payoffs`(7 种微兑现,至少 1 个);有角色复生就登记 `revival_events`。取值见 `references/写作指导/09-reading-power.md`。');
|
|
666
|
+
parts.push('4. **不要自己跑校验脚本**——写完本轮所有章并更新完台账即可,驱动会逐章校验并把错误发回来。');
|
|
667
|
+
parts.push('5. 全部写完后,用一段话汇报:本轮写了哪几章、每章标题与字数、埋/收了哪些伏笔、哪些章没有细纲。');
|
|
668
|
+
parts.push('');
|
|
669
|
+
|
|
670
|
+
return parts.join('\n');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ============================================================
|
|
674
|
+
// 等待 LLM 跑完一轮
|
|
675
|
+
// ============================================================
|
|
676
|
+
|
|
677
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 造一个"等 LLM 把这一轮做完"的函数。
|
|
681
|
+
*
|
|
682
|
+
* 直接用 agent.whenIdle() 有竞态:send() 之后 agent 可能还没被唤醒,
|
|
683
|
+
* whenIdle() 立刻就 resolve 了。所以这里改成轮询 agent.status:
|
|
684
|
+
* - 观察到 running → 说明这一轮已经启动;
|
|
685
|
+
* - 从 running 变回 idle → 这一轮结束;
|
|
686
|
+
* - 如果一直没观察到 running,但连续 idle 超过 idleGraceMs,
|
|
687
|
+
* 判定为"这一轮没跑起来或已经空转结束"。
|
|
688
|
+
*
|
|
689
|
+
* 同时用"预期产物是否都落盘"作为第二重判据:LLM 没写文件时能立刻判定失败,
|
|
690
|
+
* 不必干等到超时。
|
|
691
|
+
*
|
|
692
|
+
* 改写场景(去 AI 味)需要第三重判据:文件**本来就存在**,
|
|
693
|
+
* 光看"文件在不在"会立刻判定成功(模型其实什么都没改)。
|
|
694
|
+
* 这时传 options.changed(files, before) —— 返回 true 才算这轮真的改了东西。
|
|
695
|
+
* before 是本轮开始前的文件内容(基线),由调用方每次 waitIdle 时传入。
|
|
696
|
+
*
|
|
697
|
+
* 失败时返回 code(机器可读),调用方据此决定是重试还是放弃:
|
|
698
|
+
* NO_CHANGE —— 模型跑了但没动目标文件(改写场景的可重试失败)
|
|
699
|
+
* NO_OUTPUT —— 模型跑了但没产出预期文件
|
|
700
|
+
* TIMEOUT / ABORTED / NO_AGENT —— 环境问题,重试也没用
|
|
701
|
+
*
|
|
702
|
+
* @param {Object} invocation
|
|
703
|
+
* @param {Object} [opts]
|
|
704
|
+
* @param {number} [opts.pollMs]
|
|
705
|
+
* @param {number} [opts.timeoutMs]
|
|
706
|
+
* @param {number} [opts.idleGraceMs]
|
|
707
|
+
* @param {AbortSignal} [opts.signal]
|
|
708
|
+
* @param {(files:string[], before?:string) => boolean} [opts.changed] 内容变化判据
|
|
709
|
+
* @returns {(expectedFiles?: string[], before?: string) => Promise<{ok:boolean, reason?:string, code?:string, missing?:string[]}>}
|
|
710
|
+
*/
|
|
711
|
+
/**
|
|
712
|
+
* 读最近一次「回合结束」的失败原因。
|
|
713
|
+
*
|
|
714
|
+
* 为什么需要它(2026-10-25 线上故障):
|
|
715
|
+
* provider 报错(429 限流 / 400 / 401…)时,DSH 的 agent 会把这一轮以
|
|
716
|
+
* `turn/end {reason:{kind:'error', error:{code, message}}}` 收掉
|
|
717
|
+
* (dsh-agent-loop/lib/index.js:984-990),**模型一个字都没写**。
|
|
718
|
+
* 而等待逻辑只看"文件在不在",于是把它报成
|
|
719
|
+
* "NO_OUTPUT:LLM 本轮结束但没有产出预期文件" ——
|
|
720
|
+
* 真因(限流/鉴权/上下文超限)被完全盖掉,用户只能猜。
|
|
721
|
+
*
|
|
722
|
+
* @param {object} session - DSH Session
|
|
723
|
+
* @param {number} sinceSeq - 只看这个 seq 之后的回合(避免拿到上一轮的错误)
|
|
724
|
+
* @returns {{code: string, reason: string} | null}
|
|
725
|
+
*/
|
|
726
|
+
export function lastTurnFailure(session, sinceSeq) {
|
|
727
|
+
const log = session && Array.isArray(session.log) ? session.log : null;
|
|
728
|
+
if (!log) return null;
|
|
729
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
730
|
+
const event = log[i];
|
|
731
|
+
if (!event || event.type !== 'turn/end') continue;
|
|
732
|
+
if (typeof sinceSeq === 'number' && event.seq < sinceSeq) return null;
|
|
733
|
+
const reason = event.data && event.data.reason;
|
|
734
|
+
if (!reason || reason.kind === 'completed') return null;
|
|
735
|
+
if (reason.kind === 'error') {
|
|
736
|
+
const failure = reason.error || {};
|
|
737
|
+
const code = failure.code || 'UNKNOWN';
|
|
738
|
+
const message = failure.message || JSON.stringify(failure);
|
|
739
|
+
return { code: 'TURN_ERROR', providerCode: code, reason: '本轮对话出错(' + code + '):' + message };
|
|
740
|
+
}
|
|
741
|
+
if (reason.kind === 'aborted') {
|
|
742
|
+
return { code: 'ABORTED', reason: '本轮对话被中断' };
|
|
743
|
+
}
|
|
744
|
+
return { code: 'TURN_' + String(reason.kind).toUpperCase(), reason: '本轮对话未完成(' + reason.kind + ')' };
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function makeWaitForTurn(invocation, opts) {
|
|
750
|
+
const options = opts || {};
|
|
751
|
+
const pollMs = options.pollMs || 300;
|
|
752
|
+
const timeoutMs = options.timeoutMs || 45 * 60 * 1000;
|
|
753
|
+
const idleGraceMs = options.idleGraceMs || 15000;
|
|
754
|
+
const signal = options.signal || null;
|
|
755
|
+
const changed = typeof options.changed === 'function' ? options.changed : null;
|
|
756
|
+
|
|
757
|
+
return async function waitForTurn(expectedFiles, before) {
|
|
758
|
+
const agent = invocation && invocation.agent;
|
|
759
|
+
// 守卫要检查实际使用的 API:下面轮询的是 agent.status(DSH 的 AgentStatus
|
|
760
|
+
// 只有 'idle' | 'running' 两个值),此前却检查 agent.whenIdle 是否存在,
|
|
761
|
+
// 守卫与被守卫的对象不是同一个。
|
|
762
|
+
if (!agent || typeof agent.status !== 'string') {
|
|
763
|
+
return { ok: false, code: 'NO_AGENT', reason: '当前运行时没有暴露 agent,无法等待 LLM 完成本轮' };
|
|
764
|
+
}
|
|
765
|
+
const deadline = Date.now() + timeoutMs;
|
|
766
|
+
const startedAt = Date.now();
|
|
767
|
+
let sawRunning = false;
|
|
768
|
+
// 本轮开始前的日志长度:只认这之后的 turn/end,
|
|
769
|
+
// 免得把上一轮的 provider 错误算到这一轮头上。
|
|
770
|
+
const session = agent.session || null;
|
|
771
|
+
const sinceSeq = session && Array.isArray(session.log) ? session.log.length : undefined;
|
|
772
|
+
|
|
773
|
+
while (Date.now() < deadline) {
|
|
774
|
+
// 用户中断时立刻放手:命令处理器不该在用户取消后还傻等 45 分钟
|
|
775
|
+
if (signal && signal.aborted) {
|
|
776
|
+
return { ok: false, code: 'ABORTED', reason: '已被用户中断', aborted: true };
|
|
777
|
+
}
|
|
778
|
+
const running = agent.status === 'running';
|
|
779
|
+
if (running) {
|
|
780
|
+
sawRunning = true;
|
|
781
|
+
await sleep(pollMs);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
// idle
|
|
785
|
+
const files = expectedFiles || [];
|
|
786
|
+
const missing = files.filter((f) => !existsSync(f));
|
|
787
|
+
// changed 为空时行为与从前一致(文件在就算成功)。
|
|
788
|
+
// before 是本轮的基线内容:重投场景里文件**本来就在**,
|
|
789
|
+
// "文件在不在"不能当完成判据,必须看内容有没有变。
|
|
790
|
+
const changedOk = !changed || changed(files, before);
|
|
791
|
+
if (files.length > 0 && missing.length === 0 && changedOk) {
|
|
792
|
+
return { ok: true, sawRunning };
|
|
793
|
+
}
|
|
794
|
+
if (files.length > 0 && missing.length === 0) {
|
|
795
|
+
// 文件在,但内容没变(重投场景):模型这轮等于什么都没做。
|
|
796
|
+
//
|
|
797
|
+
// 必须**立刻**判失败,不能继续等:继续等会一直等到 idleGraceMs
|
|
798
|
+
// (阶段驱动给的是 60 秒),几十个批次 × 3 轮 = 卡死十几分钟
|
|
799
|
+
// (实测 2026-10-24:全量回归挂在 apply-orchestration 上 11 分钟)。
|
|
800
|
+
// 有没有观察到 running 不影响这个结论:内容没变就是没变。
|
|
801
|
+
return { ok: false, code: 'NO_CHANGE', reason: 'LLM 本轮结束但目标文件内容没有变化', missing: [] };
|
|
802
|
+
}
|
|
803
|
+
if (sawRunning) {
|
|
804
|
+
// 这一轮跑完了,但产物没落盘。
|
|
805
|
+
//
|
|
806
|
+
// 先看这一轮是不是**带着错误**结束的(provider 限流 / 鉴权 / 上下文超限…):
|
|
807
|
+
// 那种情况下模型是被打断的,本来就写不出文件 —— 报 NO_OUTPUT 会把真因盖掉,
|
|
808
|
+
// 用户只能猜(2026-10-25 线上:骨架报"NO_OUTPUT",真因是 provider 报错)。
|
|
809
|
+
const failure = lastTurnFailure(session, sinceSeq);
|
|
810
|
+
if (failure) {
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
code: failure.code,
|
|
814
|
+
...(failure.providerCode ? { providerCode: failure.providerCode } : {}),
|
|
815
|
+
reason: failure.reason,
|
|
816
|
+
missing,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
return { ok: false, code: 'NO_OUTPUT', reason: 'LLM 本轮结束但没有产出预期文件', missing };
|
|
820
|
+
}
|
|
821
|
+
if (Date.now() - startedAt > idleGraceMs) {
|
|
822
|
+
return { ok: false, code: 'TIMEOUT', reason: '等待 LLM 响应超时(未观察到本轮启动)' };
|
|
823
|
+
}
|
|
824
|
+
await sleep(pollMs);
|
|
825
|
+
}
|
|
826
|
+
return { ok: false, code: 'TIMEOUT', reason: '等待 LLM 响应超时' };
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ============================================================
|
|
831
|
+
// 主循环
|
|
832
|
+
// ============================================================
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 分片内容变化判定(阶段驱动用)。
|
|
836
|
+
*
|
|
837
|
+
* 为什么需要它:阶段驱动的重投**不能删旧分片** ——
|
|
838
|
+
* DSH 的 write 工具记着"本会话上次看到这个文件时的版本",
|
|
839
|
+
* 文件被外部删掉后再写同一路径会报 FS_STALE_VERSION
|
|
840
|
+
* ("cannot write ...: file no longer exists — re-read the file, then retry"),
|
|
841
|
+
* 而 re-read 同样失败(文件已经没了),模型就此卡死。
|
|
842
|
+
* 所以旧分片留着,完成判据改成"内容相对基线有没有变"。
|
|
843
|
+
*
|
|
844
|
+
* before 为 null(首轮,文件本来就不存在)时不做判定,直接算变了。
|
|
845
|
+
*
|
|
846
|
+
* @param {string[]} files - 本轮预期产物的路径
|
|
847
|
+
* @param {string|null} before - 本轮开始前的内容基线
|
|
848
|
+
* @returns {boolean} 有任一文件的内容变了就算变了
|
|
849
|
+
*/
|
|
850
|
+
export function shardContentChanged(files, before) {
|
|
851
|
+
if (before === null || before === undefined) return true;
|
|
852
|
+
return (files || []).some((f) => {
|
|
853
|
+
try {
|
|
854
|
+
return readFileSync(f, 'utf-8') !== before;
|
|
855
|
+
} catch {
|
|
856
|
+
// 读不到 = 文件没了 = 算变了(交给上层按"没产出"处理)
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* 按批驱动写作阶段。
|
|
864
|
+
*
|
|
865
|
+
* @param {Object} opts
|
|
866
|
+
* @param {string} opts.projectRoot
|
|
867
|
+
* @param {number} [opts.batchSize]
|
|
868
|
+
* @param {number} [opts.maxAttempts]
|
|
869
|
+
* @param {(prompt: string) => Promise<{ok: boolean, error?: string}>} opts.sendPrompt
|
|
870
|
+
* @param {() => Promise<void>} opts.waitIdle
|
|
871
|
+
* @param {(ch: number) => Promise<{status: number, errors: Array, warnings: Array}>} opts.validateChapter
|
|
872
|
+
* @param {(msg: string) => void} [opts.onProgress]
|
|
873
|
+
* @param {Array} [opts.outline] 预置的细纲(测试用;不传则从磁盘读)
|
|
874
|
+
* @returns {Promise<Object>} 汇总报告
|
|
875
|
+
*/
|
|
876
|
+
export async function runWritingStage(opts) {
|
|
877
|
+
const context = opts.context || loadWritingContext(opts.projectRoot);
|
|
878
|
+
if (context.error) {
|
|
879
|
+
return { ok: false, error: context.error, batches: [], written: [], failed: [] };
|
|
880
|
+
}
|
|
881
|
+
const batchSize = opts.batchSize || WRITING_BATCH_SIZE;
|
|
882
|
+
const maxAttempts = opts.maxAttempts || WRITING_MAX_BATCH_ATTEMPTS;
|
|
883
|
+
|
|
884
|
+
// ---- 续跑复检(P1-1)----
|
|
885
|
+
//
|
|
886
|
+
// 旧实现里"已存在的章"**从来没有被校验过**:文件在磁盘上就算"写完了"。
|
|
887
|
+
// 于是上次失败留下的空文件/半截文件会被永久跳过 —— 续跑多少遍都补不上,
|
|
888
|
+
// 而最终消息还写着"0 章全部写完并通过逐章校验",用户完全看不出来。
|
|
889
|
+
//
|
|
890
|
+
// 现在对每个已存在的章跑一次完整校验:
|
|
891
|
+
// 通过 → 计入 reused(复用,不重写)
|
|
892
|
+
// 没过 → 移出 existing,当作待写章重新写
|
|
893
|
+
const reused = [];
|
|
894
|
+
const recheckFailed = [];
|
|
895
|
+
const existingBefore = [...(context.existing || [])].sort((a, b) => a - b);
|
|
896
|
+
for (const ch of existingBefore) {
|
|
897
|
+
if (!opts.validateChapter) {
|
|
898
|
+
reused.push(ch);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const r = await opts.validateChapter(ch);
|
|
902
|
+
if (r && r.status === 0) {
|
|
903
|
+
reused.push(ch);
|
|
904
|
+
} else {
|
|
905
|
+
context.existing.delete(ch);
|
|
906
|
+
recheckFailed.push({ ch, errors: (r && r.errors) || [] });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const batches = planBatches(context, batchSize);
|
|
911
|
+
|
|
912
|
+
const report = {
|
|
913
|
+
ok: true, error: null, batches: [], written: [], failed: [], skipped: [],
|
|
914
|
+
reused, recheckFailed
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
for (const batch of batches) {
|
|
918
|
+
if (batch.chapters.length === 0) {
|
|
919
|
+
report.skipped.push({ start: batch.start, end: batch.end, chapters: batch.skipped });
|
|
920
|
+
if (opts.onProgress) opts.onProgress('第 ' + batch.start + '-' + batch.end + ' 章已存在,跳过。');
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
let attempt = 1;
|
|
925
|
+
let failures = [];
|
|
926
|
+
let attemptLog = [];
|
|
927
|
+
|
|
928
|
+
while (attempt <= maxAttempts) {
|
|
929
|
+
const prompt = buildBatchPrompt(context, batch, attempt, failures, opts.contract);
|
|
930
|
+
const sent = await opts.sendPrompt(prompt);
|
|
931
|
+
if (!sent || sent.ok !== true) {
|
|
932
|
+
report.ok = false;
|
|
933
|
+
report.error = (sent && sent.error) || '提示词投递失败';
|
|
934
|
+
report.batches.push({ start: batch.start, end: batch.end, attempt, ok: false, error: report.error });
|
|
935
|
+
return report;
|
|
936
|
+
}
|
|
937
|
+
// waitIdle 的返回值必须检查:它失败时(例如某章没落盘 → NO_OUTPUT),
|
|
938
|
+
// 不检查就往下走"逐章校验",会把没写的章当成"写完了"(实测:
|
|
939
|
+
// 30 章全被记成已写,报告还写着成功)。
|
|
940
|
+
const waited = await opts.waitIdle(batch.chapters.map((ch) => join(context.dirs.writing, chapterFileName(ch))));
|
|
941
|
+
if (!waited || waited.ok !== true) {
|
|
942
|
+
const code = (waited && waited.code) || '';
|
|
943
|
+
const reason = (waited && waited.reason) || '等待 LLM 结束失败';
|
|
944
|
+
attemptLog.push({ attempt, failed: batch.chapters.slice(), note: reason });
|
|
945
|
+
// 环境类失败重试没有意义(超时 / 用户中断 / 运行时缺失)
|
|
946
|
+
if (code === 'TIMEOUT' || code === 'ABORTED' || code === 'NO_AGENT') {
|
|
947
|
+
report.ok = false;
|
|
948
|
+
report.error = reason;
|
|
949
|
+
report.batches.push({ start: batch.start, end: batch.end, attempt, ok: false, error: reason });
|
|
950
|
+
return report;
|
|
951
|
+
}
|
|
952
|
+
// waitIdle 失败不代表整批都没写出来 —— 可能是最后一章超时,
|
|
953
|
+
// 前面的章已经落盘了。旧实现把**整批**列进 failures,
|
|
954
|
+
// 下一次 prompt 就要求重写 1~10 章,已经写好的 1~9 章会被覆盖。
|
|
955
|
+
// 现在先逐章看:真的没落盘的、或者落盘了但校验不过的,才要重写。
|
|
956
|
+
const missing = [];
|
|
957
|
+
const okChapters = [];
|
|
958
|
+
for (const ch of batch.chapters) {
|
|
959
|
+
const p = join(context.dirs.writing, chapterFileName(ch));
|
|
960
|
+
const text = readTextSafe(p);
|
|
961
|
+
if (text === null || !text.trim()) {
|
|
962
|
+
missing.push(ch);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (!opts.validateChapter) {
|
|
966
|
+
okChapters.push(ch);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const r = await opts.validateChapter(ch);
|
|
970
|
+
if (r && r.status === 0) okChapters.push(ch);
|
|
971
|
+
else missing.push(ch);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!missing.length) {
|
|
975
|
+
// 整批其实都写好了(waitIdle 失败多半是最后一章超时)
|
|
976
|
+
failures = [];
|
|
977
|
+
refreshWritingContext(context);
|
|
978
|
+
report.batches.push({
|
|
979
|
+
start: batch.start, end: batch.end, attempt, ok: true,
|
|
980
|
+
results: okChapters.map((ch) => ({ ch, status: 0, errors: [], warnings: [] }))
|
|
981
|
+
});
|
|
982
|
+
report.written.push(...batch.chapters);
|
|
983
|
+
if (opts.onProgress) {
|
|
984
|
+
opts.onProgress('第 ' + batch.start + '-' + batch.end + ' 章已写完并通过校验(等待超时但内容已落盘)。');
|
|
985
|
+
}
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
failures = missing.map((ch) => ({
|
|
990
|
+
ch,
|
|
991
|
+
status: 2,
|
|
992
|
+
errors: [{ code: code || 'WAIT_FAILED', path: chapterFileName(ch), message: reason, hint: null }],
|
|
993
|
+
warnings: []
|
|
994
|
+
}));
|
|
995
|
+
if (attempt < maxAttempts) {
|
|
996
|
+
attempt += 1;
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
report.ok = false;
|
|
1000
|
+
report.error = reason;
|
|
1001
|
+
report.batches.push({ start: batch.start, end: batch.end, attempt, ok: false, error: reason });
|
|
1002
|
+
for (const f of failures) report.failed.push({ ch: f.ch, errors: f.errors });
|
|
1003
|
+
return report;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// 逐章校验
|
|
1007
|
+
const results = [];
|
|
1008
|
+
for (const ch of batch.chapters) {
|
|
1009
|
+
const r = await opts.validateChapter(ch);
|
|
1010
|
+
results.push({ ch, status: r.status, errors: r.errors || [], warnings: r.warnings || [] });
|
|
1011
|
+
}
|
|
1012
|
+
const bad = results.filter((r) => r.status !== 0);
|
|
1013
|
+
attemptLog.push({ attempt, failed: bad.map((b) => b.ch) });
|
|
1014
|
+
|
|
1015
|
+
if (bad.length === 0) {
|
|
1016
|
+
// 本批通过:清掉上一轮遗留的失败清单,否则重试成功的批仍会被记成失败
|
|
1017
|
+
failures = [];
|
|
1018
|
+
// 刷新 context:下一批要用**本批写完之后**的前情衔接和台账快照,
|
|
1019
|
+
// 否则第 2 批承接的还是第 1 批之前的内容(角色状态前后矛盾)。
|
|
1020
|
+
// 注意只在"本批通过"时刷 —— 同批重试时前情衔接必须停在上一批末尾,
|
|
1021
|
+
// 不能拿本批的半成品当衔接。
|
|
1022
|
+
refreshWritingContext(context);
|
|
1023
|
+
report.batches.push({ start: batch.start, end: batch.end, attempt, ok: true, results });
|
|
1024
|
+
report.written.push(...batch.chapters);
|
|
1025
|
+
if (opts.onProgress) {
|
|
1026
|
+
opts.onProgress('第 ' + batch.start + '-' + batch.end + ' 章已写完并通过校验(' + batch.chapters.length + ' 章)。');
|
|
1027
|
+
}
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
failures = bad;
|
|
1032
|
+
if (attempt < maxAttempts) {
|
|
1033
|
+
if (opts.onProgress) {
|
|
1034
|
+
opts.onProgress('第 ' + batch.start + '-' + batch.end + ' 批有 ' + bad.length + ' 章校验未通过,正在重写。');
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
attempt++;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (failures.length > 0) {
|
|
1041
|
+
// 报告不能说"成功":有章没过校验,ok 必须是 false,error 要写清楚。
|
|
1042
|
+
// (之前只把失败塞进 batches,report.ok 仍是 true、error 仍是 null ——
|
|
1043
|
+
// 任何按 report.ok 判断的调用方都会被误导。)
|
|
1044
|
+
report.ok = false;
|
|
1045
|
+
if (!report.error) {
|
|
1046
|
+
report.error = '有 ' + failures.length + ' 章没通过校验';
|
|
1047
|
+
}
|
|
1048
|
+
report.batches.push({ start: batch.start, end: batch.end, attempt: maxAttempts, ok: false, results: failures });
|
|
1049
|
+
for (const f of failures) report.failed.push({ ch: f.ch, errors: f.errors });
|
|
1050
|
+
// 本批没过,但可能已经把部分章写到磁盘上了。
|
|
1051
|
+
// 下一批的"前情衔接"必须按**磁盘上的真实状态**来 ——
|
|
1052
|
+
// 不刷新的话,失败批残留的章会被跳过,时间线/人物状态就错位了。
|
|
1053
|
+
refreshWritingContext(context);
|
|
1054
|
+
if (opts.onProgress) {
|
|
1055
|
+
opts.onProgress('第 ' + batch.start + '-' + batch.end + ' 批仍有 ' + failures.length + ' 章未通过,已记入报告,继续下一批。');
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return report;
|
|
1061
|
+
}
|