@waterwx/dsh-novel-forge 1.2.0 → 1.2.1-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.
Files changed (125) hide show
  1. package/README.md +237 -349
  2. package/lib/client.js +54447 -10560
  3. package/lib/index.js +12008 -5071
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +18 -3
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +147 -9
  11. package/lib/types/client/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +33 -0
  14. package/lib/types/client/locales.d.ts +28 -11
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  36. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  37. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  38. package/lib/types/client/panel/SubPage.d.ts +27 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +25 -7
  41. package/lib/types/client/run-status.d.ts +19 -0
  42. package/lib/types/client/text.d.ts +9 -0
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +146 -40
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/index.d.ts +18 -2
  48. package/lib/types/llm-live.d.ts +44 -0
  49. package/lib/types/market-radar-scan.d.ts +19 -0
  50. package/lib/types/market-radar-sources.d.ts +31 -0
  51. package/lib/types/migrate-output.d.ts +25 -0
  52. package/lib/types/novel-context.d.ts +80 -0
  53. package/lib/types/prompt-utils.d.ts +35 -0
  54. package/lib/types/prompting.d.ts +10 -0
  55. package/lib/types/protocol.d.ts +1029 -123
  56. package/lib/types/review-policy.d.ts +97 -0
  57. package/lib/types/routes.d.ts +4 -0
  58. package/lib/types/run.d.ts +3 -1
  59. package/lib/types/stage-contract.d.ts +60 -0
  60. package/package.json +51 -27
  61. package/scripts/patch-schemastery.mjs +47 -0
  62. package/src/action-guard.ts +127 -0
  63. package/src/ai-scan.ts +179 -0
  64. package/src/assets.ts +1774 -520
  65. package/src/assistant.ts +1082 -770
  66. package/src/author-assets.ts +149 -0
  67. package/src/bookshelf.ts +187 -151
  68. package/src/client/api.ts +684 -410
  69. package/src/client/docx.ts +37 -69
  70. package/src/client/index.ts +3 -1
  71. package/src/client/llmLive.ts +101 -0
  72. package/src/client/locales.ts +418 -383
  73. package/src/client/mount.tsx +1 -1
  74. package/src/client/panel/AdaptModeView.tsx +535 -0
  75. package/src/client/panel/AiTag.tsx +7 -0
  76. package/src/client/panel/AssetsTab.tsx +448 -458
  77. package/src/client/panel/AssistantTab.tsx +355 -301
  78. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  79. package/src/client/panel/AuthorHome.tsx +309 -0
  80. package/src/client/panel/BookAnalysisView.tsx +63 -0
  81. package/src/client/panel/CmdPalette.tsx +102 -0
  82. package/src/client/panel/CreateBookView.tsx +366 -348
  83. package/src/client/panel/DirectorView.tsx +200 -0
  84. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  85. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  86. package/src/client/panel/ImportModal.tsx +303 -0
  87. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  88. package/src/client/panel/LiveFeedLog.tsx +67 -0
  89. package/src/client/panel/MarketRadarView.tsx +377 -0
  90. package/src/client/panel/ModelManager.tsx +205 -0
  91. package/src/client/panel/NovelPanel.tsx +5403 -4597
  92. package/src/client/panel/PageHeader.tsx +37 -0
  93. package/src/client/panel/ProgressConsole.tsx +63 -0
  94. package/src/client/panel/ReaderView.tsx +306 -306
  95. package/src/client/panel/ReasoningSection.tsx +31 -0
  96. package/src/client/panel/RunPanel.tsx +223 -204
  97. package/src/client/panel/SettingsView.tsx +305 -0
  98. package/src/client/panel/ShelfView.tsx +266 -236
  99. package/src/client/panel/SubPage.tsx +42 -0
  100. package/src/client/panel/WorldTab.tsx +202 -202
  101. package/src/client/panel/panel.module.css +5764 -2941
  102. package/src/client/panel/toast.ts +51 -0
  103. package/src/client/panel/views.tsx +401 -314
  104. package/src/client/run-status.ts +143 -0
  105. package/src/client/sidebar-entry.ts +1 -1
  106. package/src/client/text.ts +28 -0
  107. package/src/docx-core.ts +53 -0
  108. package/src/docx.ts +44 -83
  109. package/src/engine.ts +4231 -2787
  110. package/src/global-assets.ts +59 -0
  111. package/src/index.ts +311 -252
  112. package/src/llm-live.ts +68 -0
  113. package/src/market-radar-scan.ts +41 -0
  114. package/src/market-radar-sources.ts +173 -0
  115. package/src/migrate-output.ts +76 -0
  116. package/src/novel-context.ts +286 -0
  117. package/src/prompting.ts +26 -0
  118. package/src/protocol.ts +2072 -1182
  119. package/src/review-policy.ts +161 -0
  120. package/src/routes.ts +3405 -2151
  121. package/src/run.ts +342 -330
  122. package/src/stage-contract.ts +189 -0
  123. package/lib/client.js.map +0 -1
  124. package/lib/index.js.map +0 -1
  125. package/src/client/panel/BookshelfBar.tsx +0 -170
package/src/run.ts CHANGED
@@ -1,330 +1,342 @@
1
- /**
2
- * 生产单(Production Run):批量章节生产的标准执行器。
3
- * 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
4
- * 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
5
- * 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
6
- */
7
- import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
8
- import { join } from 'node:path'
9
- import type { Context } from '@deepseek-ai/cordis'
10
- import type { ChapterPlan, NovelConfig, ProjectState, ReviewReport, RunState } from './protocol.ts'
11
- import {
12
- loadProject,
13
- saveProject,
14
- mergeVolatileFromDisk,
15
- planChapters,
16
- generateChapterStream,
17
- summarizeAndExtractFacts,
18
- markForeshadowPlanted,
19
- reviewChapter,
20
- reviewChapterText,
21
- authorReviewChapter,
22
- autoLinkPlotlines,
23
- readChapterFile,
24
- rewriteChapterStream,
25
- chapterFileName,
26
- } from './engine.ts'
27
- import { loadBookshelf } from './bookshelf.ts'
28
-
29
- /** 生产单 checkpoint 文件名(放在书目录下)。 */
30
- function runStateFile(outputDir: string): string {
31
- return join(outputDir, 'run-state.json')
32
- }
33
-
34
- export class ProductionRunner {
35
- private state: RunState | null = null
36
- private working = false
37
- private pauseRequested = false
38
- private stopRequested = false
39
- /** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
40
- private bookDir: string | null = null
41
-
42
- constructor(private deps: { ctx: Context; getConfig: () => NovelConfig }) {}
43
-
44
- /** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
45
- status(): RunState | null {
46
- if (this.state !== null) return this.state
47
- // 重启恢复:先找当前激活书目录,再扫书架所有书目录(防切书后找不到)。
48
- const candidates: string[] = [this.deps.getConfig().outputDir]
49
- const bookshelf = loadBookshelf()
50
- for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir)
51
- for (const outputDir of candidates) {
52
- const file = runStateFile(outputDir)
53
- if (!existsSync(file)) continue
54
- try {
55
- const raw = readFileSync(file, 'utf8')
56
- const parsed = JSON.parse(raw) as RunState
57
- if (parsed?.runId === undefined) continue
58
- // 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
59
- if (parsed.status === 'running') parsed.status = 'paused'
60
- this.state = parsed
61
- this.bookDir = outputDir
62
- return this.state
63
- } catch { /* 该目录 run-state 损坏则跳过 */ }
64
- }
65
- return null
66
- }
67
-
68
- private persist(): void {
69
- if (this.state === null) return
70
- const outputDir = this.bookDir ?? this.deps.getConfig().outputDir
71
- mkdirSync(outputDir, { recursive: true })
72
- writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), 'utf8')
73
- }
74
-
75
- private log(text: string): void {
76
- if (this.state === null) return
77
- this.state.log.push({ at: new Date().toISOString(), text })
78
- if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300)
79
- this.state.updatedAt = new Date().toISOString()
80
- }
81
-
82
- /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
83
- async start(startNo: number, endNo: number): Promise<RunState> {
84
- const config = this.deps.getConfig()
85
- if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
86
- let project = loadProject(config.outputDir)
87
- if (project === undefined) throw new Error('输出目录中没有项目')
88
-
89
- // 计划补足:目标区间超出已有计划 → 续写规划追加。
90
- if (endNo > project.chapters.length) {
91
- const need = endNo - project.chapters.length
92
- this.log(`计划不足,追加 ${need} 章计划…`)
93
- const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, config.outputDir)
94
- mergeVolatileFromDisk(config.outputDir, project)
95
- project.chapters.push(...chapters)
96
- project.updatedAt = new Date().toISOString()
97
- saveProject(config.outputDir, project)
98
- this.log(`计划已追加,全书 ${project.chapters.length} 章`)
99
- }
100
-
101
- this.state = {
102
- runId: `run-${Date.now().toString(36)}`,
103
- startNo,
104
- endNo,
105
- status: 'running',
106
- currentNo: startNo,
107
- stats: { generated: 0, revised: 0, exempted: 0, regenerated: 0, error: 0 },
108
- pendingManual: [],
109
- log: [],
110
- startedAt: new Date().toISOString(),
111
- updatedAt: new Date().toISOString(),
112
- }
113
- this.pauseRequested = false
114
- this.stopRequested = false
115
- // 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
116
- this.bookDir = config.outputDir
117
- this.working = true
118
- this.persist()
119
- void this.loop()
120
- return this.state
121
- }
122
-
123
- pause(): void { this.pauseRequested = true }
124
- resume(): void {
125
- if (this.state === null || this.state.status !== 'paused') return
126
- this.state.status = 'running'
127
- this.working = true
128
- this.pauseRequested = false
129
- this.stopRequested = false
130
- this.persist()
131
- void this.loop()
132
- }
133
- stop(): void { this.stopRequested = true }
134
-
135
- /** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
136
- private async loop(): Promise<void> {
137
- // 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
138
- const config = this.deps.getConfig()
139
- const outputDir = this.bookDir ?? config.outputDir
140
- try {
141
- while (this.state !== null) {
142
- if (this.stopRequested) {
143
- this.stopRequested = false
144
- this.state.status = 'stopped'
145
- this.log('生产单已停止')
146
- this.persist()
147
- break
148
- }
149
- if (this.pauseRequested) {
150
- this.pauseRequested = false
151
- this.state.status = 'paused'
152
- this.log('生产单已暂停(随时可继续)')
153
- this.persist()
154
- break
155
- }
156
- const project = loadProject(outputDir)
157
- if (project === undefined) {
158
- this.state.status = 'error'
159
- this.state.error = '项目丢失'
160
- this.persist()
161
- break
162
- }
163
- // 找下一个需要处理的章(approved 快进)。
164
- let next: ChapterPlan | undefined
165
- for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
166
- const ch = project.chapters.find(c => c.no === no)
167
- if (ch === undefined) continue
168
- if (ch.status === 'approved') { this.state.currentNo = no; continue }
169
- next = ch
170
- break
171
- }
172
- if (next === undefined) {
173
- this.state.status = 'done'
174
- this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`)
175
- this.persist()
176
- break
177
- }
178
- this.state.currentNo = next.no
179
- await this.processChapter(project, next)
180
- this.persist()
181
- }
182
- } catch (error) {
183
- if (this.state !== null) {
184
- this.state.status = 'error'
185
- this.state.error = (error as Error).message
186
- this.log(`生产单异常:${(error as Error).message}`)
187
- this.persist()
188
- }
189
- } finally {
190
- this.working = false
191
- }
192
- }
193
-
194
- private async processChapter(project: ProjectState, chapter: ChapterPlan): Promise<void> {
195
- if (chapter.status === 'pending' || chapter.status === 'error' || chapter.status === 'written') {
196
- await this.produce(project, chapter)
197
- } else if (chapter.status === 'rejected') {
198
- await this.handleRejected(project, chapter)
199
- } else if (chapter.status === 'generating') {
200
- // 中断残留:复位重生成。
201
- chapter.status = 'pending'
202
- await this.produce(project, chapter)
203
- }
204
- }
205
-
206
- /** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
207
- private async produce(project: ProjectState, chapter: ChapterPlan): Promise<void> {
208
- const { ctx, getConfig } = this.deps
209
- const config = getConfig()
210
- const outputDir = this.bookDir ?? config.outputDir
211
- const no = chapter.no
212
- const wasError = chapter.status === 'error'
213
- chapter.status = 'generating'
214
- chapter.error = undefined
215
- chapter.generatingAt = new Date().toISOString()
216
- mergeVolatileFromDisk(outputDir, project)
217
- saveProject(outputDir, project)
218
- this.log(wasError ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`)
219
- try {
220
- for await (const step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
221
- try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
222
- try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
223
- if (config.autoReview ?? true) {
224
- const report = await reviewChapter(ctx, config, project, outputDir, no)
225
- if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
226
- this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? '✅ 通过' : '⚠️ 被拒'}`)
227
- } else {
228
- chapter.status = 'approved'
229
- mergeVolatileFromDisk(outputDir, project)
230
- saveProject(outputDir, project)
231
- }
232
- if (config.autoAuthorReview ?? true) {
233
- try {
234
- const body = readChapterFile(outputDir, chapter)
235
- if (body !== undefined) {
236
- let prevTail = ''
237
- if (no > 1) {
238
- const prev = project.chapters.find(c => c.no === no - 1)
239
- if (prev !== undefined) prevTail = (readChapterFile(outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
240
- }
241
- const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
242
- chapter.authorReview = review
243
- if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
244
- mergeVolatileFromDisk(outputDir, project)
245
- saveProject(outputDir, project)
246
- }
247
- } catch (e) { console.warn('[dsh-novel-forge] run author review:', (e as Error).message) }
248
- }
249
- } catch (error) {
250
- chapter.status = 'error'
251
- chapter.error = (error as Error).message
252
- mergeVolatileFromDisk(outputDir, project)
253
- saveProject(outputDir, project)
254
- if (this.state !== null) this.state.stats.error++
255
- this.log(`第${no}章 失败:${(error as Error).message}`)
256
- }
257
- }
258
-
259
- /** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
260
- private async handleRejected(project: ProjectState, chapter: ChapterPlan): Promise<void> {
261
- const { ctx, getConfig } = this.deps
262
- const config = getConfig()
263
- const outputDir = this.bookDir ?? config.outputDir
264
- const no = chapter.no
265
- const report = chapter.review
266
- const highs = (report?.issues ?? []).filter(i => i.severity === 'high')
267
- if (highs.length === 0) {
268
- chapter.status = 'approved'
269
- project.updatedAt = new Date().toISOString()
270
- saveProject(outputDir, project)
271
- if (this.state !== null) this.state.stats.exempted++
272
- this.log(`第${no}章 豁免通过(无 high)`)
273
- return
274
- }
275
- this.log(`第${no}章 修订(${highs.length} 个 high)…`)
276
- for (let round = 1; round <= 2; round++) {
277
- const instr = '按审稿意见修订(优先处理):\n' + highs.map(h => `[${h.severity}] ${h.item}${h.suggestion !== '' ? `\n建议:${h.suggestion}` : ''}`).join('\n\n')
278
- try {
279
- for await (const step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
280
- } catch (error) {
281
- this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
282
- continue
283
- }
284
- // rewrite 已写盘(pendingDraft + saveProject),重载拿最新草稿。
285
- const fresh = loadProject(outputDir)
286
- const freshCh = fresh?.chapters.find(c => c.no === no)
287
- const draft = freshCh?.pendingDraft
288
- if (draft === undefined || draft.length < 50) {
289
- this.log(`第${no}章 第${round}轮草稿缺失,重试`)
290
- continue
291
- }
292
- const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
293
- const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
294
- if (verify.passed || highs2.length === 0) {
295
- // 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
296
- this.applyDraft(fresh!, freshCh!, draft, verify)
297
- if (this.state !== null) this.state.stats.revised++
298
- this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`)
299
- return
300
- }
301
- this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
302
- }
303
- if (this.state !== null) this.state.pendingManual.push(no)
304
- this.log(`第${no}章 ⚠️ 两轮修订仍不过 → 保留草稿待人工`)
305
- }
306
-
307
- private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
308
- const config = this.deps.getConfig()
309
- const fileName = chapterFileName(chapter)
310
- mkdirSync(config.outputDir, { recursive: true })
311
- const targetPath = join(config.outputDir, fileName)
312
- if (existsSync(targetPath)) {
313
- copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
314
- }
315
- writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
316
- chapter.pendingDraft = undefined
317
- chapter.chars = draft.length
318
- chapter.file = fileName
319
- if (typeof report.score === 'number') {
320
- chapter.review = report
321
- chapter.status = report.passed === true ? 'approved' : 'rejected'
322
- } else {
323
- chapter.status = 'written'
324
- chapter.review = undefined
325
- }
326
- chapter.error = undefined
327
- project.updatedAt = new Date().toISOString()
328
- saveProject(config.outputDir, project)
329
- }
330
- }
1
+ /**
2
+ * 生产单(Production Run):批量章节生产的标准执行器。
3
+ * 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
4
+ * 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
5
+ * 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { ChapterPlan, NovelConfig, ProjectState, ReviewReport, RunState } from './protocol.ts'
11
+ import {
12
+ loadProject,
13
+ saveProject,
14
+ mergeVolatileFromDisk,
15
+ planChapters,
16
+ generateChapterStream,
17
+ summarizeAndExtractFacts,
18
+ markForeshadowPlanted,
19
+ reviewChapter,
20
+ reviewChapterText,
21
+ authorReviewChapter,
22
+ autoLinkPlotlines,
23
+ readChapterFile,
24
+ rewriteChapterStream,
25
+ chapterFileName,
26
+ } from './engine.ts'
27
+ import { countHanzi } from './engine.ts'
28
+ import { loadBookshelf } from './bookshelf.ts'
29
+
30
+ /** 生产单 checkpoint 文件名(放在书目录下)。 */
31
+ function runStateFile(outputDir: string): string {
32
+ return join(outputDir, 'run-state.json')
33
+ }
34
+
35
+ export class ProductionRunner {
36
+ private state: RunState | null = null
37
+ private working = false
38
+ private pauseRequested = false
39
+ private stopRequested = false
40
+ /** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
41
+ private bookDir: string | null = null
42
+
43
+ constructor(private deps: { ctx: Context; getConfig: () => NovelConfig }) {}
44
+
45
+ /** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
46
+ status(): RunState | null {
47
+ if (this.state !== null) return this.state
48
+ // 重启恢复:先找当前激活书目录,再扫书架所有书目录(防切书后找不到)。
49
+ const candidates: string[] = [this.deps.getConfig().outputDir]
50
+ const bookshelf = loadBookshelf()
51
+ for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir)
52
+ for (const outputDir of candidates) {
53
+ const file = runStateFile(outputDir)
54
+ if (!existsSync(file)) continue
55
+ try {
56
+ const raw = readFileSync(file, 'utf8')
57
+ const parsed = JSON.parse(raw) as RunState
58
+ if (parsed?.runId === undefined) continue
59
+ // 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
60
+ if (parsed.status === 'running') parsed.status = 'paused'
61
+ this.state = parsed
62
+ this.bookDir = outputDir
63
+ return this.state
64
+ } catch { /* 该目录 run-state 损坏则跳过 */ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ private persist(): void {
70
+ if (this.state === null) return
71
+ const outputDir = this.bookDir ?? this.deps.getConfig().outputDir
72
+ mkdirSync(outputDir, { recursive: true })
73
+ writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), 'utf8')
74
+ }
75
+
76
+ private log(text: string): void {
77
+ if (this.state === null) return
78
+ this.state.log.push({ at: new Date().toISOString(), text })
79
+ if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300)
80
+ this.state.updatedAt = new Date().toISOString()
81
+ }
82
+
83
+ /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
84
+ async start(startNo: number, endNo: number, runDir?: string): Promise<RunState> {
85
+ const config = this.deps.getConfig()
86
+ if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
87
+ const outputDir = runDir ?? config.outputDir
88
+ let project = loadProject(outputDir)
89
+ if (project === undefined) throw new Error('输出目录中没有项目')
90
+
91
+ // 计划补足:目标区间超出已有计划 → 续写规划追加。
92
+ if (endNo > project.chapters.length) {
93
+ const need = endNo - project.chapters.length
94
+ this.log(`计划不足,追加 ${need} 章计划…`)
95
+ const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, outputDir)
96
+ mergeVolatileFromDisk(outputDir, project)
97
+ project.chapters.push(...chapters)
98
+ project.updatedAt = new Date().toISOString()
99
+ saveProject(outputDir, project)
100
+ this.log(`计划已追加,全书 ${project.chapters.length} 章`)
101
+ }
102
+
103
+ this.state = {
104
+ runId: `run-${Date.now().toString(36)}`,
105
+ startNo,
106
+ endNo,
107
+ status: 'running',
108
+ currentNo: startNo,
109
+ stats: { generated: 0, revised: 0, exempted: 0, regenerated: 0, error: 0 },
110
+ pendingManual: [],
111
+ log: [],
112
+ startedAt: new Date().toISOString(),
113
+ updatedAt: new Date().toISOString(),
114
+ }
115
+ this.pauseRequested = false
116
+ this.stopRequested = false
117
+ // 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
118
+ this.bookDir = outputDir
119
+ this.working = true
120
+ this.persist()
121
+ void this.loop()
122
+ return this.state
123
+ }
124
+
125
+ pause(): void { this.pauseRequested = true }
126
+ resume(): void {
127
+ if (this.state === null || this.state.status !== 'paused') return
128
+ this.state.status = 'running'
129
+ this.working = true
130
+ this.pauseRequested = false
131
+ this.stopRequested = false
132
+ this.persist()
133
+ void this.loop()
134
+ }
135
+ stop(): void { this.stopRequested = true }
136
+
137
+ /** 是否有生产单正在执行(目录迁移等磁盘级操作前须为 false)。 */
138
+ isWorking(): boolean { return this.working }
139
+
140
+ /** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
141
+ private async loop(): Promise<void> {
142
+ // 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
143
+ const config = this.deps.getConfig()
144
+ const outputDir = this.bookDir ?? config.outputDir
145
+ try {
146
+ while (this.state !== null) {
147
+ if (this.stopRequested) {
148
+ this.stopRequested = false
149
+ this.state.status = 'stopped'
150
+ this.log('生产单已停止')
151
+ this.persist()
152
+ break
153
+ }
154
+ if (this.pauseRequested) {
155
+ this.pauseRequested = false
156
+ this.state.status = 'paused'
157
+ this.log('生产单已暂停(随时可继续)')
158
+ this.persist()
159
+ break
160
+ }
161
+ const project = loadProject(outputDir)
162
+ if (project === undefined) {
163
+ this.state.status = 'error'
164
+ this.state.error = '项目丢失'
165
+ this.persist()
166
+ break
167
+ }
168
+ // 找下一个需要处理的章(approved 快进)。
169
+ let next: ChapterPlan | undefined
170
+ for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
171
+ const ch = project.chapters.find(c => c.no === no)
172
+ if (ch === undefined) continue
173
+ if (ch.status === 'approved') { this.state.currentNo = no; continue }
174
+ next = ch
175
+ break
176
+ }
177
+ if (next === undefined) {
178
+ this.state.status = 'done'
179
+ this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`)
180
+ this.persist()
181
+ break
182
+ }
183
+ this.state.currentNo = next.no
184
+ await this.processChapter(project, next)
185
+ this.persist()
186
+ }
187
+ } catch (error) {
188
+ if (this.state !== null) {
189
+ this.state.status = 'error'
190
+ this.state.error = (error as Error).message
191
+ this.log(`生产单异常:${(error as Error).message}`)
192
+ this.persist()
193
+ }
194
+ } finally {
195
+ this.working = false
196
+ }
197
+ }
198
+
199
+ private async processChapter(project: ProjectState, chapter: ChapterPlan): Promise<void> {
200
+ if (chapter.status === 'pending' || chapter.status === 'error' || chapter.status === 'written') {
201
+ await this.produce(project, chapter)
202
+ } else if (chapter.status === 'rejected') {
203
+ await this.handleRejected(project, chapter)
204
+ } else if (chapter.status === 'generating') {
205
+ // 中断残留:复位重生成。
206
+ chapter.status = 'pending'
207
+ await this.produce(project, chapter)
208
+ }
209
+ }
210
+
211
+ /** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
212
+ private async produce(project: ProjectState, chapter: ChapterPlan): Promise<void> {
213
+ const { ctx, getConfig } = this.deps
214
+ const config = getConfig()
215
+ const outputDir = this.bookDir ?? config.outputDir
216
+ const no = chapter.no
217
+ const wasError = chapter.status === 'error'
218
+ chapter.status = 'generating'
219
+ chapter.error = undefined
220
+ chapter.generatingAt = new Date().toISOString()
221
+ mergeVolatileFromDisk(outputDir, project)
222
+ saveProject(outputDir, project)
223
+ this.log(`${wasError ? '重新生成' : '生成'} 第${no}章《${chapter.title}》…(模型 ${config.generateModel || config.model})`)
224
+ try {
225
+ for await (const _step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
226
+ try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
227
+ try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
228
+ if (config.autoReview ?? true) {
229
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
230
+ if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
231
+ this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? ' 通过' : ' 被拒'}(模型 ${config.reviewModel || config.model})`)
232
+ } else {
233
+ chapter.status = 'approved'
234
+ mergeVolatileFromDisk(outputDir, project)
235
+ saveProject(outputDir, project)
236
+ }
237
+ if (config.autoAuthorReview ?? true) {
238
+ try {
239
+ const body = readChapterFile(outputDir, chapter)
240
+ if (body !== undefined) {
241
+ let prevTail = ''
242
+ if (no > 1) {
243
+ const prev = project.chapters.find(c => c.no === no - 1)
244
+ if (prev !== undefined) prevTail = (readChapterFile(outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
245
+ }
246
+ const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
247
+ chapter.authorReview = review
248
+ if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
249
+ // 结果回灌:把本章状态变化/新线索写进编年录事实库,供后续计划与整本控制用。
250
+ const backfillFacts: string[] = [...(review.stateChanges ?? []), ...(review.clues ?? [])]
251
+ if (backfillFacts.length > 0) {
252
+ project.facts ??= []
253
+ for (const text of backfillFacts) project.facts.push({ chapterNo: no, text })
254
+ }
255
+ mergeVolatileFromDisk(outputDir, project)
256
+ saveProject(outputDir, project)
257
+ }
258
+ } catch (e) { console.warn('[dsh-novel-forge] run author review:', (e as Error).message) }
259
+ }
260
+ } catch (error) {
261
+ chapter.status = 'error'
262
+ chapter.error = (error as Error).message
263
+ mergeVolatileFromDisk(outputDir, project)
264
+ saveProject(outputDir, project)
265
+ if (this.state !== null) this.state.stats.error++
266
+ this.log(`第${no}章 失败:${(error as Error).message}`)
267
+ }
268
+ }
269
+
270
+ /** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
271
+ private async handleRejected(project: ProjectState, chapter: ChapterPlan): Promise<void> {
272
+ const { ctx, getConfig } = this.deps
273
+ const config = getConfig()
274
+ const outputDir = this.bookDir ?? config.outputDir
275
+ const no = chapter.no
276
+ const report = chapter.review
277
+ const highs = (report?.issues ?? []).filter(i => i.severity === 'high')
278
+ if (highs.length === 0) {
279
+ chapter.status = 'approved'
280
+ project.updatedAt = new Date().toISOString()
281
+ saveProject(outputDir, project)
282
+ if (this.state !== null) this.state.stats.exempted++
283
+ this.log(`第${no}章 豁免通过(无 high)`)
284
+ return
285
+ }
286
+ this.log(`第${no}章 修订(${highs.length} 个 high)…`)
287
+ for (let round = 1; round <= 2; round++) {
288
+ const instr = '按审稿意见修订(优先处理):\n' + highs.map(h => `[${h.severity}] ${h.item}${h.suggestion !== '' ? `\n建议:${h.suggestion}` : ''}`).join('\n\n')
289
+ try {
290
+ for await (const _step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
291
+ } catch (error) {
292
+ this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
293
+ continue
294
+ }
295
+ // rewrite 已写盘(pendingDraft + saveProject),重载拿最新草稿。
296
+ const fresh = loadProject(outputDir)
297
+ const freshCh = fresh?.chapters.find(c => c.no === no)
298
+ const draft = freshCh?.pendingDraft
299
+ if (draft === undefined || draft.length < 50) {
300
+ this.log(`第${no}章 第${round}轮草稿缺失,重试`)
301
+ continue
302
+ }
303
+ const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
304
+ const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
305
+ if (verify.passed || highs2.length === 0) {
306
+ // 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
307
+ this.applyDraft(fresh!, freshCh!, draft, verify)
308
+ if (this.state !== null) this.state.stats.revised++
309
+ this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`)
310
+ return
311
+ }
312
+ this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
313
+ }
314
+ if (this.state !== null) this.state.pendingManual.push(no)
315
+ this.log(`第${no}章 两轮修订仍不过 → 保留草稿待人工`)
316
+ }
317
+
318
+ private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
319
+ const config = this.deps.getConfig()
320
+ const outputDir = this.bookDir ?? config.outputDir
321
+ const fileName = chapterFileName(chapter)
322
+ mkdirSync(outputDir, { recursive: true })
323
+ const targetPath = join(outputDir, fileName)
324
+ if (existsSync(targetPath)) {
325
+ copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
326
+ }
327
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
328
+ chapter.pendingDraft = undefined
329
+ chapter.chars = countHanzi(draft)
330
+ chapter.file = fileName
331
+ if (typeof report.score === 'number') {
332
+ chapter.review = report
333
+ chapter.status = report.passed === true ? 'approved' : 'rejected'
334
+ } else {
335
+ chapter.status = 'written'
336
+ chapter.review = undefined
337
+ }
338
+ chapter.error = undefined
339
+ project.updatedAt = new Date().toISOString()
340
+ saveProject(outputDir, project)
341
+ }
342
+ }