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