@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha
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/README.md +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/comic-presets.ts +0 -67
package/src/run.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
planChapters,
|
|
16
16
|
generateChapterStream,
|
|
17
17
|
summarizeAndExtractFacts,
|
|
18
|
+
extractTimelineForChapter,
|
|
18
19
|
markForeshadowPlanted,
|
|
19
20
|
reviewChapter,
|
|
20
21
|
reviewChapterText,
|
|
@@ -24,13 +25,39 @@ import {
|
|
|
24
25
|
rewriteChapterStream,
|
|
25
26
|
chapterFileName,
|
|
26
27
|
} from './engine.ts'
|
|
28
|
+
import { countHanzi } from './engine.ts'
|
|
27
29
|
import { loadBookshelf } from './bookshelf.ts'
|
|
30
|
+
import { buildRevisionPlan, describeRevisionPlan, MAX_REVISION_ROUNDS, revisionTodoText } from './revision.ts'
|
|
28
31
|
|
|
29
32
|
/** 生产单 checkpoint 文件名(放在书目录下)。 */
|
|
30
33
|
function runStateFile(outputDir: string): string {
|
|
31
34
|
return join(outputDir, 'run-state.json')
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 解析某个输出目录的书名(用于旧 run-state.json 的兜底)。
|
|
39
|
+
* 优先项目文件里的 bookName,其次目录名——两者都没有时返回空串。
|
|
40
|
+
* @param outputDir - 该书输出目录。
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRunBookName(outputDir: string): string {
|
|
43
|
+
const fromProject = loadProject(outputDir)?.bookName
|
|
44
|
+
if (typeof fromProject === 'string' && fromProject.trim() !== '') return fromProject.trim()
|
|
45
|
+
const base = outputDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
|
46
|
+
return base.trim()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 一句话描述"正在跑的是哪本书的哪一章"。
|
|
51
|
+
* 面板状态行与 409 报错共用,避免两处各写各的(也便于单测覆盖)。
|
|
52
|
+
* @param state - 运行中的生产单状态。
|
|
53
|
+
*/
|
|
54
|
+
export function describeRunningBatch(state: RunState): string {
|
|
55
|
+
const name = typeof state.bookName === 'string' && state.bookName.trim() !== ''
|
|
56
|
+
? `《${state.bookName.trim()}》`
|
|
57
|
+
: '未标注书目'
|
|
58
|
+
return `${name}第${state.currentNo}章`
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
export class ProductionRunner {
|
|
35
62
|
private state: RunState | null = null
|
|
36
63
|
private working = false
|
|
@@ -57,6 +84,11 @@ export class ProductionRunner {
|
|
|
57
84
|
if (parsed?.runId === undefined) continue
|
|
58
85
|
// 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
|
|
59
86
|
if (parsed.status === 'running') parsed.status = 'paused'
|
|
87
|
+
// 旧 run-state.json 没有 bookName:用该目录的项目名兜底,再退回目录名,
|
|
88
|
+
// 保证"这是哪本书"永远有答案(否则芯片/面板会显示一个无法归属的状态)。
|
|
89
|
+
if (parsed.bookName === undefined || parsed.bookName === '') {
|
|
90
|
+
parsed.bookName = resolveRunBookName(outputDir)
|
|
91
|
+
}
|
|
60
92
|
this.state = parsed
|
|
61
93
|
this.bookDir = outputDir
|
|
62
94
|
return this.state
|
|
@@ -80,26 +112,34 @@ export class ProductionRunner {
|
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
/** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
|
|
83
|
-
async start(startNo: number, endNo: number): Promise<RunState> {
|
|
115
|
+
async start(startNo: number, endNo: number, runDir?: string): Promise<RunState> {
|
|
84
116
|
const config = this.deps.getConfig()
|
|
85
|
-
if (this.working)
|
|
86
|
-
|
|
117
|
+
if (this.working) {
|
|
118
|
+
// 报错要说清"在跑哪本书的哪一章":全局单实例,含糊的提示会让多书用户无从下手。
|
|
119
|
+
const running = this.state
|
|
120
|
+
throw new Error(running === null
|
|
121
|
+
? '生产单正在运行中,请先暂停或停止'
|
|
122
|
+
: `生产单正在运行中(${describeRunningBatch(running)}),请先暂停或停止`)
|
|
123
|
+
}
|
|
124
|
+
const outputDir = runDir ?? config.outputDir
|
|
125
|
+
let project = loadProject(outputDir)
|
|
87
126
|
if (project === undefined) throw new Error('输出目录中没有项目')
|
|
88
127
|
|
|
89
128
|
// 计划补足:目标区间超出已有计划 → 续写规划追加。
|
|
90
129
|
if (endNo > project.chapters.length) {
|
|
91
130
|
const need = endNo - project.chapters.length
|
|
92
131
|
this.log(`计划不足,追加 ${need} 章计划…`)
|
|
93
|
-
const chapters = await planChapters(this.deps.ctx, config, project, need, undefined,
|
|
94
|
-
mergeVolatileFromDisk(
|
|
132
|
+
const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, outputDir)
|
|
133
|
+
mergeVolatileFromDisk(outputDir, project)
|
|
95
134
|
project.chapters.push(...chapters)
|
|
96
135
|
project.updatedAt = new Date().toISOString()
|
|
97
|
-
saveProject(
|
|
136
|
+
saveProject(outputDir, project)
|
|
98
137
|
this.log(`计划已追加,全书 ${project.chapters.length} 章`)
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
this.state = {
|
|
102
141
|
runId: `run-${Date.now().toString(36)}`,
|
|
142
|
+
bookName: project.bookName,
|
|
103
143
|
startNo,
|
|
104
144
|
endNo,
|
|
105
145
|
status: 'running',
|
|
@@ -113,7 +153,7 @@ export class ProductionRunner {
|
|
|
113
153
|
this.pauseRequested = false
|
|
114
154
|
this.stopRequested = false
|
|
115
155
|
// 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
|
|
116
|
-
this.bookDir =
|
|
156
|
+
this.bookDir = outputDir
|
|
117
157
|
this.working = true
|
|
118
158
|
this.persist()
|
|
119
159
|
void this.loop()
|
|
@@ -132,6 +172,9 @@ export class ProductionRunner {
|
|
|
132
172
|
}
|
|
133
173
|
stop(): void { this.stopRequested = true }
|
|
134
174
|
|
|
175
|
+
/** 是否有生产单正在执行(目录迁移等磁盘级操作前须为 false)。 */
|
|
176
|
+
isWorking(): boolean { return this.working }
|
|
177
|
+
|
|
135
178
|
/** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
|
|
136
179
|
private async loop(): Promise<void> {
|
|
137
180
|
// 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
|
|
@@ -201,6 +244,42 @@ export class ProductionRunner {
|
|
|
201
244
|
chapter.status = 'pending'
|
|
202
245
|
await this.produce(project, chapter)
|
|
203
246
|
}
|
|
247
|
+
this.auditChapterAdvisories(chapter.no)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 出章即核对曲线(建议性,不阻塞):
|
|
252
|
+
* 规则初筛得到的时间线矛盾 + 张力曲线偏差写进运行日志;本章已完结时再落成项目待办。
|
|
253
|
+
* 默认**不**触发任何自动修订——要看/要改由作者在面板发起「一键按建议修订」;
|
|
254
|
+
* 只有当本章因审稿 high 本来就要修订时,这些问题才搭同一轮车(见 handleRejected),
|
|
255
|
+
* 因此不额外增加轮次,也不会出现「改完甲又违反乙」。
|
|
256
|
+
*/
|
|
257
|
+
private auditChapterAdvisories(chapterNo: number): void {
|
|
258
|
+
const config = this.deps.getConfig()
|
|
259
|
+
const outputDir = this.bookDir ?? config.outputDir
|
|
260
|
+
const project = loadProject(outputDir)
|
|
261
|
+
if (project === undefined) return
|
|
262
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
263
|
+
if (chapter === undefined) return
|
|
264
|
+
// reviewIssues 传空数组:这里只核对曲线/时间线,审稿意见走审稿通道。
|
|
265
|
+
const plan = buildRevisionPlan(project, { chapterNo, reviewIssues: [], includeTimeline: true, includeTension: true })
|
|
266
|
+
if (plan.items.length === 0) return
|
|
267
|
+
this.log(`第${chapterNo}章 曲线/时间线核对:${describeRevisionPlan(plan)}——建议性提示,未自动改动`)
|
|
268
|
+
if (chapter.status !== 'approved') return
|
|
269
|
+
if (plan.counts.timeline === 0 && plan.counts.tension === 0) return
|
|
270
|
+
const todos = (project.todos ??= [])
|
|
271
|
+
const added: string[] = []
|
|
272
|
+
for (const item of plan.items.slice(0, 3)) {
|
|
273
|
+
const text = revisionTodoText(item)
|
|
274
|
+
if (todos.some(t => t.text === text)) continue
|
|
275
|
+
todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text, source: 'risk', done: false, createdAt: new Date().toISOString() })
|
|
276
|
+
added.push(text)
|
|
277
|
+
}
|
|
278
|
+
if (added.length > 0) {
|
|
279
|
+
project.updatedAt = new Date().toISOString()
|
|
280
|
+
saveProject(outputDir, project)
|
|
281
|
+
this.log(`第${chapterNo}章 已登记 ${added.length} 条建议性待办(可在时间线/张力面板一键按建议修订)`)
|
|
282
|
+
}
|
|
204
283
|
}
|
|
205
284
|
|
|
206
285
|
/** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
|
|
@@ -215,15 +294,18 @@ export class ProductionRunner {
|
|
|
215
294
|
chapter.generatingAt = new Date().toISOString()
|
|
216
295
|
mergeVolatileFromDisk(outputDir, project)
|
|
217
296
|
saveProject(outputDir, project)
|
|
218
|
-
this.log(wasError ?
|
|
297
|
+
this.log(`${wasError ? '重新生成' : '生成'} 第${no}章《${chapter.title}》…(模型 ${config.generateModel || config.model})`)
|
|
219
298
|
try {
|
|
220
|
-
for await (const
|
|
299
|
+
for await (const _step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
|
|
221
300
|
try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
|
|
301
|
+
if (config.autoTimeline ?? true) {
|
|
302
|
+
try { await extractTimelineForChapter(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run timeline:', (e as Error).message) }
|
|
303
|
+
}
|
|
222
304
|
try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
|
|
223
305
|
if (config.autoReview ?? true) {
|
|
224
306
|
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
225
307
|
if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
|
|
226
|
-
this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? '
|
|
308
|
+
this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? ' 通过' : ' 被拒'}(模型 ${config.reviewModel || config.model})`)
|
|
227
309
|
} else {
|
|
228
310
|
chapter.status = 'approved'
|
|
229
311
|
mergeVolatileFromDisk(outputDir, project)
|
|
@@ -241,6 +323,12 @@ export class ProductionRunner {
|
|
|
241
323
|
const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
|
|
242
324
|
chapter.authorReview = review
|
|
243
325
|
if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
|
|
326
|
+
// 结果回灌:把本章状态变化/新线索写进编年录事实库,供后续计划与整本控制用。
|
|
327
|
+
const backfillFacts: string[] = [...(review.stateChanges ?? []), ...(review.clues ?? [])]
|
|
328
|
+
if (backfillFacts.length > 0) {
|
|
329
|
+
project.facts ??= []
|
|
330
|
+
for (const text of backfillFacts) project.facts.push({ chapterNo: no, text })
|
|
331
|
+
}
|
|
244
332
|
mergeVolatileFromDisk(outputDir, project)
|
|
245
333
|
saveProject(outputDir, project)
|
|
246
334
|
}
|
|
@@ -272,11 +360,15 @@ export class ProductionRunner {
|
|
|
272
360
|
this.log(`第${no}章 豁免通过(无 high)`)
|
|
273
361
|
return
|
|
274
362
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
363
|
+
// 合并层:审稿 high + 时间线矛盾 + 张力偏差 → 一份指令,一轮改完。
|
|
364
|
+
// 张力/时间线本身不触发修订(建议性),只在「反正要改」时搭车,避免多通道互相覆盖。
|
|
365
|
+
let plan = buildRevisionPlan(project, { chapterNo: no, reviewIssues: highs, baseReport: report })
|
|
366
|
+
this.log(`第${no}章 修订(${describeRevisionPlan(plan)})…`)
|
|
367
|
+
if (plan.omitted > 0) this.log(`第${no}章 修订指令已截断 ${plan.omitted} 条(超出单轮上限,优先级低者留待下一轮)`)
|
|
368
|
+
for (let round = 1; round <= MAX_REVISION_ROUNDS; round++) {
|
|
369
|
+
const instr = plan.instruction
|
|
278
370
|
try {
|
|
279
|
-
for await (const
|
|
371
|
+
for await (const _step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
|
|
280
372
|
} catch (error) {
|
|
281
373
|
this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
|
|
282
374
|
continue
|
|
@@ -289,7 +381,8 @@ export class ProductionRunner {
|
|
|
289
381
|
this.log(`第${no}章 第${round}轮草稿缺失,重试`)
|
|
290
382
|
continue
|
|
291
383
|
}
|
|
292
|
-
|
|
384
|
+
// 验证基准 = 本轮实际下发的全部条目(审稿 + 时间线 + 张力):复核核对的就是「要求改的东西」。
|
|
385
|
+
const verify = await reviewChapterText(ctx, config, fresh!, draft, plan.baseline)
|
|
293
386
|
const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
|
|
294
387
|
if (verify.passed || highs2.length === 0) {
|
|
295
388
|
// 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
|
|
@@ -299,22 +392,26 @@ export class ProductionRunner {
|
|
|
299
392
|
return
|
|
300
393
|
}
|
|
301
394
|
this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
|
|
395
|
+
// 下一轮重新合并:以本轮仍未解决的 high 为审稿来源,并重新核对时间线/张力
|
|
396
|
+
// (上一轮基线里已解决的条目不再下发,避免「越修越多」)。
|
|
397
|
+
plan = buildRevisionPlan(fresh ?? project, { chapterNo: no, reviewIssues: highs2, baseReport: verify })
|
|
302
398
|
}
|
|
303
399
|
if (this.state !== null) this.state.pendingManual.push(no)
|
|
304
|
-
this.log(`第${no}章
|
|
400
|
+
this.log(`第${no}章 ${MAX_REVISION_ROUNDS} 轮修订仍不过 → 保留草稿待人工`)
|
|
305
401
|
}
|
|
306
402
|
|
|
307
403
|
private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
|
|
308
404
|
const config = this.deps.getConfig()
|
|
405
|
+
const outputDir = this.bookDir ?? config.outputDir
|
|
309
406
|
const fileName = chapterFileName(chapter)
|
|
310
|
-
mkdirSync(
|
|
311
|
-
const targetPath = join(
|
|
407
|
+
mkdirSync(outputDir, { recursive: true })
|
|
408
|
+
const targetPath = join(outputDir, fileName)
|
|
312
409
|
if (existsSync(targetPath)) {
|
|
313
|
-
copyFileSync(targetPath, join(
|
|
410
|
+
copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
|
|
314
411
|
}
|
|
315
412
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
316
413
|
chapter.pendingDraft = undefined
|
|
317
|
-
chapter.chars = draft
|
|
414
|
+
chapter.chars = countHanzi(draft)
|
|
318
415
|
chapter.file = fileName
|
|
319
416
|
if (typeof report.score === 'number') {
|
|
320
417
|
chapter.review = report
|
|
@@ -325,6 +422,6 @@ export class ProductionRunner {
|
|
|
325
422
|
}
|
|
326
423
|
chapter.error = undefined
|
|
327
424
|
project.updatedAt = new Date().toISOString()
|
|
328
|
-
saveProject(
|
|
425
|
+
saveProject(outputDir, project)
|
|
329
426
|
}
|
|
330
427
|
}
|
package/src/snapshots.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 章节历史版本(快照):覆盖写入前自动留存正文,支持列表 / 回滚 / 清理。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:生成、按意见重写、去 AI 味润色、采纳草稿都会**覆盖** `第N章_*.md`,
|
|
5
|
+
* 一旦改差(尤其是自动修订把好稿改坏)就没有退路。这里在覆盖前把旧稿存到
|
|
6
|
+
* `<书目录>/snapshots/`,并维护一份 `snapshots/index.json` 索引。
|
|
7
|
+
*
|
|
8
|
+
* 纯 Node 文件操作,不依赖模型;路由层只做参数校验与响应。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import type { ChapterPlan, ChapterSnapshot } from './protocol.ts'
|
|
14
|
+
|
|
15
|
+
/** 快照目录名(书目录下)。 */
|
|
16
|
+
export const SNAPSHOT_DIR = 'snapshots'
|
|
17
|
+
|
|
18
|
+
/** 索引文件名。 */
|
|
19
|
+
const INDEX_FILE = 'index.json'
|
|
20
|
+
|
|
21
|
+
/** 每章默认保留的快照数(超出后按时间淘汰最旧的)。 */
|
|
22
|
+
export const DEFAULT_SNAPSHOT_KEEP = 20
|
|
23
|
+
|
|
24
|
+
/** 快照目录绝对路径。 */
|
|
25
|
+
export function snapshotDir(outputDir: string): string {
|
|
26
|
+
return join(outputDir, SNAPSHOT_DIR)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 读取索引(缺失/损坏时返回空数组)。 */
|
|
30
|
+
export function loadSnapshots(outputDir: string): ChapterSnapshot[] {
|
|
31
|
+
const file = join(snapshotDir(outputDir), INDEX_FILE)
|
|
32
|
+
if (!existsSync(file)) return []
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { snapshots?: ChapterSnapshot[] }
|
|
35
|
+
return Array.isArray(parsed.snapshots) ? parsed.snapshots : []
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 写回索引。 */
|
|
42
|
+
function saveSnapshots(outputDir: string, snapshots: ChapterSnapshot[]): void {
|
|
43
|
+
const dir = snapshotDir(outputDir)
|
|
44
|
+
mkdirSync(dir, { recursive: true })
|
|
45
|
+
writeFileSync(join(dir, INDEX_FILE), JSON.stringify({ snapshots }, null, 2), 'utf8')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 快照占用字节数(索引 + 正文)。 */
|
|
49
|
+
export function snapshotsBytes(outputDir: string): number {
|
|
50
|
+
const dir = snapshotDir(outputDir)
|
|
51
|
+
if (!existsSync(dir)) return 0
|
|
52
|
+
let total = 0
|
|
53
|
+
try {
|
|
54
|
+
for (const name of readdirSync(dir)) {
|
|
55
|
+
try { total += statSync(join(dir, name)).size } catch { /* 跳过读不到的条目 */ }
|
|
56
|
+
}
|
|
57
|
+
} catch { /* 目录不可读时按 0 计 */ }
|
|
58
|
+
return total
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 生成快照文件名(章节 + 时间戳 + 原因短标识)。 */
|
|
62
|
+
function snapshotFileName(chapterNo: number, reason: string, at: string): string {
|
|
63
|
+
const stamp = at.replace(/[:.]/g, '-')
|
|
64
|
+
const tag = reason.replace(/[^\w\u4e00-\u9fa5-]+/g, '').slice(0, 12) || 'snap'
|
|
65
|
+
return `ch${String(chapterNo).padStart(4, '0')}_${stamp}_${tag}.md`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 为一个章节留存快照(覆盖写入前调用)。
|
|
70
|
+
*
|
|
71
|
+
* @param outputDir 书目录。
|
|
72
|
+
* @param chapter 章节(需要已存在正文文件)。
|
|
73
|
+
* @param reason 留存原因(生成覆盖 / 采纳草稿 / 修订 / 润色 / 手工)。
|
|
74
|
+
* @param keep 每章保留数量(超出淘汰最旧)。
|
|
75
|
+
* @returns 新建的快照;无正文文件时返回 undefined(不产生空快照)。
|
|
76
|
+
*/
|
|
77
|
+
export function createSnapshot(
|
|
78
|
+
outputDir: string,
|
|
79
|
+
chapter: ChapterPlan,
|
|
80
|
+
reason: string,
|
|
81
|
+
keep = DEFAULT_SNAPSHOT_KEEP,
|
|
82
|
+
): ChapterSnapshot | undefined {
|
|
83
|
+
if (chapter.file === undefined || chapter.file === '') return undefined
|
|
84
|
+
const source = join(outputDir, chapter.file)
|
|
85
|
+
if (!existsSync(source)) return undefined
|
|
86
|
+
const at = new Date().toISOString()
|
|
87
|
+
const dir = snapshotDir(outputDir)
|
|
88
|
+
mkdirSync(dir, { recursive: true })
|
|
89
|
+
const name = snapshotFileName(chapter.no, reason, at)
|
|
90
|
+
const target = join(dir, name)
|
|
91
|
+
try {
|
|
92
|
+
copyFileSync(source, target)
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
const chars = chapter.chars ?? 0
|
|
97
|
+
const snapshot: ChapterSnapshot = {
|
|
98
|
+
id: name,
|
|
99
|
+
chapterNo: chapter.no,
|
|
100
|
+
file: `${SNAPSHOT_DIR}/${name}`,
|
|
101
|
+
reason,
|
|
102
|
+
chars,
|
|
103
|
+
at,
|
|
104
|
+
}
|
|
105
|
+
const snapshots = [...loadSnapshots(outputDir), snapshot]
|
|
106
|
+
saveSnapshots(outputDir, pruneList(snapshots, keep))
|
|
107
|
+
return snapshot
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 按「每章保留最近 keep 个」裁剪列表,并删除被淘汰的文件。 */
|
|
111
|
+
function pruneList(snapshots: ChapterSnapshot[], keep: number): ChapterSnapshot[] {
|
|
112
|
+
const byChapter = new Map<number, ChapterSnapshot[]>()
|
|
113
|
+
for (const s of snapshots) {
|
|
114
|
+
const list = byChapter.get(s.chapterNo) ?? []
|
|
115
|
+
list.push(s)
|
|
116
|
+
byChapter.set(s.chapterNo, list)
|
|
117
|
+
}
|
|
118
|
+
const kept: ChapterSnapshot[] = []
|
|
119
|
+
for (const list of byChapter.values()) {
|
|
120
|
+
list.sort((a, b) => b.at.localeCompare(a.at))
|
|
121
|
+
kept.push(...list.slice(0, Math.max(1, keep)))
|
|
122
|
+
}
|
|
123
|
+
return kept.sort((a, b) => a.at.localeCompare(b.at))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 回滚到某个快照:把快照内容写回章节正文文件。
|
|
128
|
+
*
|
|
129
|
+
* 回滚前会**先给当前正文留一份快照**(reason=回滚前自动留存),所以回滚本身也可撤销。
|
|
130
|
+
*
|
|
131
|
+
* @param outputDir 书目录。
|
|
132
|
+
* @param chapter 目标章节。
|
|
133
|
+
* @param snapshotId 快照 id。
|
|
134
|
+
* @returns 回滚后的字符数。
|
|
135
|
+
* @throws 快照不存在或章节无正文文件时抛错。
|
|
136
|
+
*/
|
|
137
|
+
export function restoreSnapshot(outputDir: string, chapter: ChapterPlan, snapshotId: string): number {
|
|
138
|
+
const snapshots = loadSnapshots(outputDir)
|
|
139
|
+
const snapshot = snapshots.find(s => s.id === snapshotId)
|
|
140
|
+
if (snapshot === undefined) throw new Error(`快照不存在:${snapshotId}`)
|
|
141
|
+
if (snapshot.chapterNo !== chapter.no) throw new Error(`快照属于第 ${snapshot.chapterNo} 章,与目标第 ${chapter.no} 章不符`)
|
|
142
|
+
const source = join(outputDir, snapshot.file)
|
|
143
|
+
if (!existsSync(source)) throw new Error(`快照文件已丢失:${snapshot.file}`)
|
|
144
|
+
if (chapter.file === undefined || chapter.file === '') throw new Error(`第 ${chapter.no} 章没有正文文件,无法回滚`)
|
|
145
|
+
createSnapshot(outputDir, chapter, '回滚前留存')
|
|
146
|
+
const body = readFileSync(source, 'utf8')
|
|
147
|
+
writeFileSync(join(outputDir, chapter.file), body, 'utf8')
|
|
148
|
+
return body.length
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 删除一个快照(连同文件)。 */
|
|
152
|
+
export function removeSnapshot(outputDir: string, snapshotId: string): boolean {
|
|
153
|
+
const snapshots = loadSnapshots(outputDir)
|
|
154
|
+
const snapshot = snapshots.find(s => s.id === snapshotId)
|
|
155
|
+
if (snapshot === undefined) return false
|
|
156
|
+
try { rmSync(join(outputDir, snapshot.file), { force: true }) } catch { /* 文件已不在 */ }
|
|
157
|
+
saveSnapshots(outputDir, snapshots.filter(s => s.id !== snapshotId))
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 按每章保留数量清理(连文件一起删)。 */
|
|
162
|
+
export function pruneSnapshots(outputDir: string, keep = DEFAULT_SNAPSHOT_KEEP): number {
|
|
163
|
+
const snapshots = loadSnapshots(outputDir)
|
|
164
|
+
const kept = pruneList(snapshots, keep)
|
|
165
|
+
const keptIds = new Set(kept.map(s => s.id))
|
|
166
|
+
let removed = 0
|
|
167
|
+
for (const s of snapshots) {
|
|
168
|
+
if (keptIds.has(s.id)) continue
|
|
169
|
+
try { rmSync(join(outputDir, s.file), { force: true }) } catch { /* ignore */ }
|
|
170
|
+
removed++
|
|
171
|
+
}
|
|
172
|
+
saveSnapshots(outputDir, kept)
|
|
173
|
+
return removed
|
|
174
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 阶段契约(Stage Contract)——宿主计算的「当前阶段 → 本轮允许的动作」。
|
|
3
|
+
*
|
|
4
|
+
* 设计来源(「作者请求中的当前阶段决定唯一允许命令」这一约定):
|
|
5
|
+
* 允许的动作必须由宿主根据真实项目状态算出,而不是指望模型记住或自律。
|
|
6
|
+
* 宿主计算结果同时用于三处:
|
|
7
|
+
* 1) 注入助手系统提示词:模型知道自己在哪一步、下一步该做什么;
|
|
8
|
+
* 2) 通用续写指令(「继续 / 下一步」)下的写操作白名单强约束(阶段即唯一命令);
|
|
9
|
+
* 3) 拒绝写操作时给出可执行的、阶段相关的理由,而不是一句「不允许」。
|
|
10
|
+
*
|
|
11
|
+
* 注意:作者显式点名的写操作永远优先于阶段白名单(作者终审权);
|
|
12
|
+
* 阶段强约束只在作者没有指定具体对象(通用续写)时生效。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChapterPlan, ProjectState, StageInfo } from './protocol.ts'
|
|
16
|
+
|
|
17
|
+
/** 创作阶段标识(线上形状见 protocol.ts 的 StageInfo)。 */
|
|
18
|
+
export type BookStageId = StageInfo['id']
|
|
19
|
+
|
|
20
|
+
/** 一个阶段契约。 */
|
|
21
|
+
export type BookStage = StageInfo
|
|
22
|
+
|
|
23
|
+
/** 大纲视为「已开书」的最短长度。 */
|
|
24
|
+
const MIN_OUTLINE_CHARS = 50
|
|
25
|
+
|
|
26
|
+
/** 通用续写指令(作者没有指定具体对象时,阶段白名单成为唯一命令)。 */
|
|
27
|
+
const GENERIC_CONTINUE = /^(继续|下一步|接着来|接着写|往下|往下走|继续吧|go\s*on|continue|next)[。.!!??\s]*$/i
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 作者这一轮是否只说了「继续/下一步」这类没有具体对象的指令。
|
|
31
|
+
*
|
|
32
|
+
* @param message 本轮用户消息。
|
|
33
|
+
* @returns 是通用续写指令时为 true。
|
|
34
|
+
*/
|
|
35
|
+
export function isGenericContinue(message: string): boolean {
|
|
36
|
+
return GENERIC_CONTINUE.test((message ?? '').trim())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 根据项目真实状态计算当前创作阶段。
|
|
41
|
+
*
|
|
42
|
+
* @param project 当前项目(未加载时为 undefined)。
|
|
43
|
+
* @returns 阶段契约(推荐动作 + 写操作白名单 + 判定理由)。
|
|
44
|
+
*/
|
|
45
|
+
export function computeBookStage(project: ProjectState | undefined): BookStage {
|
|
46
|
+
if (project === undefined || (project.outline ?? '').trim().length < MIN_OUTLINE_CHARS) {
|
|
47
|
+
return {
|
|
48
|
+
id: 'outline',
|
|
49
|
+
label: '开书',
|
|
50
|
+
recommend: ['outline_replace'],
|
|
51
|
+
allow: ['outline_replace'],
|
|
52
|
+
reason: project === undefined ? '尚无项目' : '大纲为空或过短',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (project.bible === undefined) {
|
|
56
|
+
return {
|
|
57
|
+
id: 'bible',
|
|
58
|
+
label: '立设定',
|
|
59
|
+
recommend: ['bible_set_rule', 'bible_set_redline'],
|
|
60
|
+
allow: ['bible_set_rule', 'bible_set_redline'],
|
|
61
|
+
reason: '已有大纲,尚未提炼道藏',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const chapters = project.chapters ?? []
|
|
65
|
+
if (chapters.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
id: 'plan',
|
|
68
|
+
label: '排章节',
|
|
69
|
+
recommend: [],
|
|
70
|
+
// 章节规划不在助手的动作集里:通用「继续」不应被解释成改写总纲或设定。
|
|
71
|
+
allow: [],
|
|
72
|
+
reason: '有道藏但还没有章节计划(规划在「小说工坊」面板或生产单里执行)',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const generating = chapters.filter(c => c.status === 'generating')
|
|
76
|
+
if (generating.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
id: 'wait',
|
|
79
|
+
label: '等待中',
|
|
80
|
+
recommend: [],
|
|
81
|
+
allow: [],
|
|
82
|
+
reason: `第 ${generating.map(c => c.no).join('、')} 章正在生成,等它结束后再安排下一步`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const errored = chapters.filter(c => c.status === 'error')
|
|
86
|
+
if (errored.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
id: 'write',
|
|
89
|
+
label: '逐章编译',
|
|
90
|
+
recommend: ['chapter_generate'],
|
|
91
|
+
allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
|
|
92
|
+
reason: `第 ${errored.map(c => c.no).join('、')} 章生成失败,需要重写`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const rejected = chapters.filter(c => c.status === 'rejected')
|
|
96
|
+
if (rejected.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
id: 'revise',
|
|
99
|
+
label: '修订循环',
|
|
100
|
+
recommend: ['chapter_rewrite'],
|
|
101
|
+
allow: ['chapter_rewrite', 'chapter_review'],
|
|
102
|
+
reason: `第 ${rejected.map(c => c.no).join('、')} 章审稿未过,需要按意见修订`,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const awaitingReview = chapters.filter(c => c.status === 'written' || c.status === 'reviewing')
|
|
106
|
+
if (awaitingReview.length > 0) {
|
|
107
|
+
return {
|
|
108
|
+
id: 'review',
|
|
109
|
+
label: '审稿',
|
|
110
|
+
recommend: ['chapter_review'],
|
|
111
|
+
allow: ['chapter_review', 'chapter_rewrite'],
|
|
112
|
+
reason: `第 ${awaitingReview.map(c => c.no).join('、')} 章已写出但尚未审稿`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const pending = chapters.filter(c => c.status === 'pending')
|
|
116
|
+
if (pending.length > 0) {
|
|
117
|
+
return {
|
|
118
|
+
id: 'write',
|
|
119
|
+
label: '逐章编译',
|
|
120
|
+
recommend: ['chapter_generate'],
|
|
121
|
+
allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
|
|
122
|
+
reason: `还有 ${pending.length} 章待写(下一章:第 ${pending[0]!.no} 章)`,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
id: 'export',
|
|
127
|
+
label: '定稿导出',
|
|
128
|
+
recommend: ['export_txt', 'blurb'],
|
|
129
|
+
// 全部通过后不再允许在「继续」下改写正文:要改必须由作者点名章节。
|
|
130
|
+
allow: ['export_txt', 'blurb'],
|
|
131
|
+
reason: `全书 ${chapters.length} 章均已通过审稿`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 判断某写操作是否在当前阶段的允许白名单内。
|
|
137
|
+
*
|
|
138
|
+
* @param stage 阶段契约。
|
|
139
|
+
* @param tool 助手工具名。
|
|
140
|
+
* @returns 允许时为 true。
|
|
141
|
+
*/
|
|
142
|
+
export function stageAllows(stage: BookStage, tool: string): boolean {
|
|
143
|
+
return stage.allow.includes(tool)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 按阶段把一组动作分成「本阶段可用」与「阶段外」。
|
|
148
|
+
*
|
|
149
|
+
* @param stage 阶段契约。
|
|
150
|
+
* @param tools 动作名列表(通常是写操作登记表)。
|
|
151
|
+
* @returns 两个保持输入顺序的子集。
|
|
152
|
+
*/
|
|
153
|
+
export function splitByStage(stage: BookStage, tools: readonly string[]): { available: string[]; outside: string[] } {
|
|
154
|
+
const available: string[] = []
|
|
155
|
+
const outside: string[] = []
|
|
156
|
+
for (const tool of tools) {
|
|
157
|
+
if (stageAllows(stage, tool)) available.push(tool)
|
|
158
|
+
else outside.push(tool)
|
|
159
|
+
}
|
|
160
|
+
return { available, outside }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 渲染阶段契约为可注入提示词的一段文字。
|
|
165
|
+
*
|
|
166
|
+
* @param stage 阶段契约。
|
|
167
|
+
* @returns 多行文本。
|
|
168
|
+
*/
|
|
169
|
+
export function renderStageContract(stage: BookStage): string {
|
|
170
|
+
const recommend = stage.recommend.length > 0 ? stage.recommend.join('、') : '无(等待或由作者指定)'
|
|
171
|
+
const allow = stage.allow.length > 0 ? stage.allow.join('、') : '无'
|
|
172
|
+
return [
|
|
173
|
+
'==================== 当前创作阶段(宿主根据项目真实状态计算) ====================',
|
|
174
|
+
`阶段:${stage.label}(${stage.id})`,
|
|
175
|
+
`判定依据:${stage.reason}`,
|
|
176
|
+
`建议下一步:${recommend}`,
|
|
177
|
+
`本阶段允许的写操作:${allow}`,
|
|
178
|
+
'阶段纪律:作者只说了「继续/下一步」而没指定具体对象时,只有上面的写操作会被执行,其余写操作一律拒绝;作者显式点名某个操作时以作者指令为准。',
|
|
179
|
+
].join('\n')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 汇总章节状态计数(供面板/提示词展示,避免各处重复统计)。 */
|
|
183
|
+
export function summarizeChapters(chapters: ChapterPlan[]): Record<string, number> {
|
|
184
|
+
const summary: Record<string, number> = {}
|
|
185
|
+
for (const chapter of chapters) {
|
|
186
|
+
summary[chapter.status] = (summary[chapter.status] ?? 0) + 1
|
|
187
|
+
}
|
|
188
|
+
return summary
|
|
189
|
+
}
|