@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.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  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 +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. 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) throw new Error('生产单正在运行中,请先暂停或停止')
86
- let project = loadProject(config.outputDir)
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, config.outputDir)
94
- mergeVolatileFromDisk(config.outputDir, project)
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(config.outputDir, project)
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 = config.outputDir
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 ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`)
297
+ this.log(`${wasError ? '重新生成' : '生成'} 第${no}章《${chapter.title}》…(模型 ${config.generateModel || config.model})`)
219
298
  try {
220
- for await (const step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
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
- 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')
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 step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
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
- const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
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(config.outputDir, { recursive: true })
311
- const targetPath = join(config.outputDir, fileName)
407
+ mkdirSync(outputDir, { recursive: true })
408
+ const targetPath = join(outputDir, fileName)
312
409
  if (existsSync(targetPath)) {
313
- copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
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.length
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(config.outputDir, project)
425
+ saveProject(outputDir, project)
329
426
  }
330
427
  }
@@ -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
+ }